diff --git a/.version b/.version index 6d32dec3a..82683e837 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -0.4.0-pre-alpha-v6 +0.4.0-pre-alpha-v7 diff --git a/cli/Cargo.lock b/cli/Cargo.lock index f3f3375a2..419e5f08a 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -3720,7 +3720,7 @@ dependencies = [ [[package]] name = "shared-context-engineering" -version = "0.4.0-pre-alpha-v6" +version = "0.4.0-pre-alpha-v7" dependencies = [ "anyhow", "apple-native-keyring-store", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index c8314dbf5..a1e0b1344 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shared-context-engineering" -version = "0.4.0-pre-alpha-v6" +version = "0.4.0-pre-alpha-v7" edition = "2021" description = "Shared Context Engineering CLI" license = "Apache-2.0" diff --git a/cli/src/services/doctor/fixes.rs b/cli/src/services/doctor/fixes.rs index eea14a5a1..f4ecf7513 100644 --- a/cli/src/services/doctor/fixes.rs +++ b/cli/src/services/doctor/fixes.rs @@ -1,15 +1,42 @@ -use super::types::{DoctorFixResultRecord, FixResult, ProblemFixability}; +use super::types::{ + DoctorFixResultRecord, DoctorProblem, FixResult, IntegrationTarget, ProblemCategory, + ProblemFixability, +}; use super::HookDoctorReport; -pub(super) fn build_manual_fix_results(report: &HookDoctorReport) -> Vec { +pub(super) fn build_manual_fix_results( + report: &HookDoctorReport, + attempted_mutation_scope_targets: &[IntegrationTarget], +) -> Vec { report .problems .iter() .filter(|problem| problem.fixability == ProblemFixability::ManualOnly) + .filter(|problem| { + !owned_by_attempted_mutation_scope_repair(problem, attempted_mutation_scope_targets) + }) .map(|problem| DoctorFixResultRecord { category: problem.category, outcome: FixResult::Manual, - detail: format!("{} Manual remediation is still required.", problem.summary), + detail: manual_fix_detail(problem), }) .collect() } + +fn owned_by_attempted_mutation_scope_repair( + problem: &DoctorProblem, + attempted_mutation_scope_targets: &[IntegrationTarget], +) -> bool { + problem.category == ProblemCategory::MutationScopeHealth + && problem + .mutation_scope_target + .is_some_and(|target| attempted_mutation_scope_targets.contains(&target)) +} + +fn manual_fix_detail(problem: &DoctorProblem) -> String { + if problem.category == ProblemCategory::MutationScopeHealth { + problem.remediation.clone() + } else { + format!("{} Manual remediation is still required.", problem.summary) + } +} diff --git a/cli/src/services/doctor/inspect.rs b/cli/src/services/doctor/inspect.rs index e4818db12..ba21119eb 100644 --- a/cli/src/services/doctor/inspect.rs +++ b/cli/src/services/doctor/inspect.rs @@ -14,12 +14,14 @@ use crate::services::default_paths::{ repo_dir, InstallTargetPaths, RepoPaths, }; use crate::services::hooks::mutation_scope_health::{ - MutationScopeAdapterHealth, MutationScopeHealthStatus, + MutationScopeAdapterHealth, MutationScopeHealthStatus, Repairability, }; use crate::services::hooks::{ - claude_mutation_scope, codex_mutation_scope, opencode_mutation_scope, pi_mutation_scope, + claude_mutation_scope, codex_mutation_scope, mutation_scope, opencode_mutation_scope, + pi_mutation_scope, }; use crate::services::mutation_trace::runtime::resolve_git_dir; +use crate::services::observability::traits::Logger; use crate::services::repository_identity::resolve::{ resolve_repository_identity, RepositoryIdentitySource, }; @@ -215,17 +217,146 @@ fn inspect_mutation_scope_health( pi_mutation_scope::health::classify_health(&git_dir), ), }; - push_mutation_scope_health_problem(target, &health, &git_dir, problems); + let remediation = + push_mutation_scope_health_problem(target, &health, &git_dir, problems); MutationScopeHealthRow { target, status: health.status, reason: health.reason, detail: health.detail, + remediation, } }) .collect() } +pub(super) type MutationScopeRepairSeam<'a> = + &'a dyn Fn(&Path, &str, Option<&dyn Logger>) -> anyhow::Result; + +pub(super) fn repair_blocked_mutation_scope_targets_with_seam( + initial_report: &HookDoctorReport, + seam: MutationScopeRepairSeam<'_>, +) -> Vec { + let Some(repository_root) = initial_report.repository_root.as_deref() else { + return Vec::new(); + }; + let Ok(git_dir) = resolve_git_dir(repository_root) else { + return Vec::new(); + }; + + initial_report + .mutation_scope_health + .iter() + .filter(|row| row.status == MutationScopeHealthStatus::Blocked) + .filter_map(|row| { + repair_blocked_mutation_scope_target(row.target, &git_dir, repository_root, seam) + }) + .collect() +} + +pub(super) fn mutation_scope_repair_seam( + repository_root: &Path, + payload: &str, + logger: Option<&dyn Logger>, +) -> anyhow::Result { + mutation_scope::run_mutation_scope_from_payload(repository_root, payload, logger) +} + +fn repair_blocked_mutation_scope_target( + target: IntegrationTarget, + git_dir: &Path, + repository_root: &Path, + seam: MutationScopeRepairSeam<'_>, +) -> Option { + match target { + IntegrationTarget::ClaudeCode => { + if claude_repairability(git_dir) != Repairability::AutoFixable { + return None; + } + let _ = claude_mutation_scope::repair_blocked(git_dir, repository_root, None, seam); + Some(target) + } + IntegrationTarget::OpenCode => { + if opencode_repairability(git_dir) != Repairability::AutoFixable { + return None; + } + let _ = opencode_mutation_scope::repair_blocked(git_dir, repository_root, None, seam); + Some(target) + } + IntegrationTarget::Pi | IntegrationTarget::Codex => None, + } +} + +fn claude_repairability(git_dir: &Path) -> Repairability { + match claude_mutation_scope::assess_repairability(git_dir) { + claude_mutation_scope::Repairability::AutoFixable => Repairability::AutoFixable, + claude_mutation_scope::Repairability::ManualOnly => Repairability::ManualOnly, + } +} + +fn opencode_repairability(git_dir: &Path) -> Repairability { + match opencode_mutation_scope::assess_repairability(git_dir) { + opencode_mutation_scope::Repairability::AutoFixable => Repairability::AutoFixable, + opencode_mutation_scope::Repairability::ManualOnly => Repairability::ManualOnly, + } +} + +fn mutation_scope_repairability(target: IntegrationTarget, git_dir: &Path) -> Repairability { + match target { + IntegrationTarget::ClaudeCode => claude_repairability(git_dir), + IntegrationTarget::OpenCode => opencode_repairability(git_dir), + IntegrationTarget::Pi | IntegrationTarget::Codex => Repairability::ManualOnly, + } +} + +pub(super) fn finalize_mutation_scope_repair_results( + attempted_targets: &[IntegrationTarget], + final_mutation_scope_health: &[MutationScopeHealthRow], +) -> Vec { + attempted_targets + .iter() + .filter_map(|target| { + let row = final_mutation_scope_health + .iter() + .find(|row| row.target == *target)?; + Some(mutation_scope_repair_result_from_final_row(*target, row)) + }) + .collect() +} + +fn mutation_scope_repair_result_from_final_row( + target: IntegrationTarget, + row: &MutationScopeHealthRow, +) -> DoctorFixResultRecord { + match row.status { + MutationScopeHealthStatus::Healthy | MutationScopeHealthStatus::Recovering => { + DoctorFixResultRecord { + category: ProblemCategory::MutationScopeHealth, + outcome: FixResult::Fixed, + detail: format!( + "Recovered {} Agent tracing (now {}: {}).", + integration_target_label(target), + mutation_scope_health_status(row.status), + row.reason + ), + } + } + MutationScopeHealthStatus::Blocked | MutationScopeHealthStatus::Invalid => { + DoctorFixResultRecord { + category: ProblemCategory::MutationScopeHealth, + outcome: FixResult::Manual, + detail: row.remediation.clone().unwrap_or_else(|| { + format!( + "{} Agent tracing remains {} after an attempted repair.", + integration_target_label(target), + mutation_scope_health_status(row.status) + ) + }), + } + } + } +} + fn mutation_scope_state_path(target: IntegrationTarget, git_dir: &Path) -> PathBuf { match target { IntegrationTarget::ClaudeCode => claude_mutation_scope::state::state_path(git_dir), @@ -240,9 +371,9 @@ fn push_mutation_scope_health_problem( health: &MutationScopeAdapterHealth, git_dir: &Path, problems: &mut Vec, -) { +) -> Option { let (kind, severity, fixability, next_action, remediation) = match health.status { - MutationScopeHealthStatus::Healthy => return, + MutationScopeHealthStatus::Healthy => return None, MutationScopeHealthStatus::Recovering => ( ProblemKind::MutationScopeHealthRecovering, ProblemSeverity::Warning, @@ -256,29 +387,45 @@ fn push_mutation_scope_health_problem( state remains recovering unexpectedly.", ), ), - MutationScopeHealthStatus::Blocked => ( - ProblemKind::MutationScopeHealthBlocked, - ProblemSeverity::Error, - ProblemFixability::ManualOnly, - "manual_steps", - format!( - "'sce doctor --fix' will not modify persisted mutation-scope recovery state: \ - clearing it automatically could silently discard unresolved mutation-scope \ - lifecycle or recovery evidence. No safe generic recovery command exists yet \ - for this case. Inspect '{}' and this adapter's recovery model directly before \ - taking manual action; do not delete the state file.", - mutation_scope_state_path(target, git_dir).display() + MutationScopeHealthStatus::Blocked => match mutation_scope_repairability(target, git_dir) { + Repairability::AutoFixable => ( + ProblemKind::MutationScopeHealthBlocked, + ProblemSeverity::Error, + ProblemFixability::AutoFixable, + "doctor_fix", + format!( + "Run 'sce doctor --fix' to recover this state: the owning process for \ + the blocking attempt(s) has been positively proven dead, so automatic \ + recovery is safe. The persisted state is at '{}'.", + mutation_scope_state_path(target, git_dir).display() + ), ), - ), + Repairability::ManualOnly => ( + ProblemKind::MutationScopeHealthBlocked, + ProblemSeverity::Error, + ProblemFixability::ManualOnly, + "manual_steps", + format!( + "Agent tracing remains blocked. Inspect '{}'. 'sce doctor --fix' will \ + not modify persisted mutation-scope recovery state automatically: \ + clearing it could silently discard unresolved mutation-scope \ + lifecycle or recovery evidence. No safe generic recovery command \ + exists yet for this case; preserve the persisted state while \ + reviewing this adapter's recovery model directly before taking \ + manual action.", + mutation_scope_state_path(target, git_dir).display() + ), + ), + }, MutationScopeHealthStatus::Invalid => ( ProblemKind::MutationScopeHealthInvalid, ProblemSeverity::Error, ProblemFixability::ManualOnly, "manual_steps", format!( - "The persisted mutation-scope state at '{}' could not be safely interpreted. \ - No safe generic recovery command exists yet; inspect the file directly rather \ - than deleting it.", + "Agent tracing state could not be safely interpreted. Inspect '{}'. No safe \ + generic recovery command exists yet; preserve the persisted state for \ + diagnosis and review the adapter's recovery model directly.", mutation_scope_state_path(target, git_dir).display() ), ), @@ -301,10 +448,13 @@ fn push_mutation_scope_health_problem( severity, fixability, summary, - remediation, + remediation: remediation.clone(), next_action, scope: None, + mutation_scope_target: Some(target), }); + + Some(remediation) } fn collect_post_commit_auto_sync_health( @@ -424,6 +574,7 @@ fn collect_agent_trace_db_health( remediation: problem.remediation.clone(), next_action: problem.next_action, scope: None, + mutation_scope_target: None, }); continue; } @@ -552,6 +703,7 @@ fn inspect_repository_hooks( remediation: String::from("Install an accessible 'git' binary and ensure it is on PATH before rerunning 'sce doctor'."), next_action: "manual_steps", scope: None, + mutation_scope_target: None, }); return Vec::new(); } @@ -568,6 +720,7 @@ fn inspect_repository_hooks( remediation: String::from("Run 'sce doctor' from a non-bare working tree clone to inspect repo-scoped SCE hook health."), next_action: "manual_steps", scope: None, + mutation_scope_target: None, }); return Vec::new(); } @@ -582,6 +735,7 @@ fn inspect_repository_hooks( remediation: String::from("Run 'sce doctor' from inside the target repository working tree to inspect repo-scoped SCE hook health."), next_action: "manual_steps", scope: None, + mutation_scope_target: None, }); return Vec::new(); } @@ -600,6 +754,7 @@ fn inspect_repository_hooks( remediation: String::from("Verify that git repository inspection succeeds and rerun 'sce doctor' inside a non-bare git repository."), next_action: "manual_steps", scope: None, + mutation_scope_target: None, }); Vec::new() } @@ -691,6 +846,7 @@ fn inspect_repository_integrations( ), next_action: "manual_steps", scope: None, + mutation_scope_target: None, }); return Vec::new(); } @@ -919,6 +1075,7 @@ fn collect_global_state_health( remediation: String::from("Verify that the current platform exposes a writable SCE state directory before rerunning 'sce doctor'."), next_action: "manual_steps", scope: None, + mutation_scope_target: None, }), } @@ -941,6 +1098,7 @@ fn collect_global_state_health( ), next_action: "manual_steps", scope: None, + mutation_scope_target: None, }); } } @@ -959,6 +1117,7 @@ fn collect_global_state_health( remediation: String::from("Verify that the current platform exposes a writable SCE config directory before rerunning 'sce doctor'."), next_action: "manual_steps", scope: None, + mutation_scope_target: None, }), } @@ -980,6 +1139,7 @@ fn collect_global_state_health( ), next_action: "manual_steps", scope: None, + mutation_scope_target: None, }); } } @@ -1000,6 +1160,7 @@ fn collect_global_state_health( } #[allow(dead_code)] +#[allow(clippy::too_many_lines)] fn collect_hook_health(directory: &Path, problems: &mut Vec) -> Vec { if !directory.exists() { problems.push(DoctorProblem { @@ -1014,6 +1175,7 @@ fn collect_hook_health(directory: &Path, problems: &mut Vec) -> V ), next_action: "doctor_fix", scope: None, + mutation_scope_target: None, }); } else if !directory.is_dir() { problems.push(DoctorProblem { @@ -1028,6 +1190,7 @@ fn collect_hook_health(directory: &Path, problems: &mut Vec) -> V ), next_action: "manual_steps", scope: None, + mutation_scope_target: None, }); } @@ -1058,6 +1221,7 @@ fn collect_hook_health(directory: &Path, problems: &mut Vec) -> V ), next_action: "doctor_fix", scope: None, + mutation_scope_target: None, }); } else if !executable { problems.push(DoctorProblem { @@ -1072,6 +1236,7 @@ fn collect_hook_health(directory: &Path, problems: &mut Vec) -> V ), next_action: "doctor_fix", scope: None, + mutation_scope_target: None, }); } @@ -1091,6 +1256,7 @@ fn collect_hook_health(directory: &Path, problems: &mut Vec) -> V ), next_action: "doctor_fix", scope: None, + mutation_scope_target: None, }); } @@ -1182,6 +1348,7 @@ fn push_codex_hook_malformed_problems( .to_string(), next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1228,6 +1395,7 @@ fn push_codex_hook_policy_blocked_problems( remediation: CODEX_HOOK_POLICY_BLOCKED_REMEDIATION.to_string(), next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1271,6 +1439,7 @@ fn push_codex_hook_policy_unknown_problems( .to_string(), next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1311,6 +1480,7 @@ fn push_codex_hook_trust_problems( remediation: CODEX_HOOK_TRUST_GUIDANCE.to_string(), next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1349,6 +1519,7 @@ fn push_opencode_integration_missing_problems( ), next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1387,6 +1558,7 @@ fn push_opencode_integration_mismatch_problems( ), next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1416,6 +1588,7 @@ fn push_opencode_integration_read_fail_problems( ), next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1455,6 +1628,7 @@ fn push_claude_integration_missing_problems( ), next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1493,6 +1667,7 @@ fn push_claude_integration_mismatch_problems( ), next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1522,6 +1697,7 @@ fn push_claude_integration_read_fail_problems( ), next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1561,6 +1737,7 @@ fn push_pi_integration_missing_problems( ), next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1599,6 +1776,7 @@ fn push_pi_integration_mismatch_problems( ), next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1628,6 +1806,7 @@ fn push_pi_integration_read_fail_problems( ), next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1678,6 +1857,7 @@ fn push_codex_integration_missing_problems( remediation, next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1727,6 +1907,7 @@ fn push_codex_integration_mismatch_problems( remediation, next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1756,6 +1937,7 @@ fn push_codex_integration_read_fail_problems( ), next_action: "manual_steps", scope: Some(group.key), + mutation_scope_target: None, }); } } @@ -1811,6 +1993,7 @@ fn inspect_opencode_plugin_ordering_health( IntegrationTarget::OpenCode, IntegrationArea::Plugins, )), + mutation_scope_target: None, }); } @@ -1854,6 +2037,7 @@ fn inspect_opencode_plugin_registry_health( IntegrationTarget::OpenCode, IntegrationArea::Plugins, )), + mutation_scope_target: None, }); } @@ -1908,6 +2092,7 @@ fn inspect_opencode_asset_presence( IntegrationTarget::OpenCode, IntegrationArea::Plugins, )), + mutation_scope_target: None, }); } @@ -2492,6 +2677,7 @@ fn inspect_hook_content_state( ), next_action: "manual_steps", scope: None, + mutation_scope_target: None, }); HookContentState::Unknown } @@ -2502,6 +2688,7 @@ fn inspect_hook_content_state( mod tests { use std::path::PathBuf; + use super::super::fixes::build_manual_fix_results; use super::{ claude_mutation_scope, codex_hook_config, codex_hook_registration_child, codex_hook_trust, collect_claude_integration_groups, collect_codex_integration_groups, @@ -2509,9 +2696,11 @@ mod tests { collect_pi_integration_groups, compute_readiness, inspect_claude_integration_health, inspect_codex_integration_health, inspect_mutation_scope_health, inspect_opencode_plugin_ordering_health, resolve_doctor_integration_targets, - CodexHookPolicyReadiness, HookContentState, IntegrationArea, IntegrationContentState, - IntegrationGroupHealth, IntegrationGroupKey, IntegrationTarget, MutationScopeHealthStatus, - ProblemFixability, ProblemKind, ProblemSeverity, Readiness, + CodexHookPolicyReadiness, DoctorMode, DoctorProblem, HookContentState, HookDoctorReport, + HookPathSource, IntegrationArea, IntegrationContentState, IntegrationGroupHealth, + IntegrationGroupKey, IntegrationTarget, MutationScopeHealthRow, MutationScopeHealthStatus, + PostCommitAutoSyncHealth, PostCommitAutoSyncState, ProblemFixability, ProblemKind, + ProblemSeverity, Readiness, }; use crate::services::config::IntegrationTargetId; use crate::services::hooks::claude_mutation_scope::state::{ @@ -4081,6 +4270,30 @@ mod tests { } } + fn claude_autofixable_blocked_state() -> AdapterState { + let key = crate::services::hooks::claude_mutation_scope::AttemptKey { + session_id: "session-a".to_string(), + agent_id: None, + tool_use_id: "tool-use-a".to_string(), + }; + let scope_id = + crate::services::hooks::claude_mutation_scope::format_claude_scope_id(1, &key); + AdapterState { + version: 1, + next_attempt_seq: 2, + recovery_pending: true, + attempts: vec![AdapterAttempt { + attempt_seq: 1, + scope_id, + session_id: key.session_id, + agent_id: key.agent_id, + tool_use_id: key.tool_use_id, + tool_name: "Edit".to_string(), + phase: AttemptPhase::PendingAbandon, + }], + } + } + fn claude_recovering_state() -> AdapterState { AdapterState { version: 1, @@ -4178,12 +4391,21 @@ mod tests { assert_eq!(problems[0].severity, ProblemSeverity::Error); assert_eq!(problems[0].fixability, ProblemFixability::ManualOnly); assert_eq!(problems[0].next_action, "manual_steps"); + let state_path = + claude_mutation_scope::state::state_path(&super::resolve_git_dir(&repo).unwrap()); assert!( problems[0] + .remediation + .contains(&state_path.display().to_string()), + "remediation must name the exact persisted state path: {}", + problems[0].remediation + ); + assert!( + !problems[0] .remediation .to_ascii_lowercase() - .contains("do not delete the state file"), - "remediation must explicitly say not to delete the state file: {}", + .contains("delete"), + "manual mutation-scope remediation must not contain deletion wording: {}", problems[0].remediation ); assert_eq!(compute_readiness(&problems), Readiness::NotReady); @@ -4218,6 +4440,87 @@ mod tests { std::fs::remove_dir_all(&repo).ok(); } + #[test] + fn mutation_scope_health_blocked_autofixable_remediation_names_doctor_fix() { + let repo = init_git_repo_with_claude_target("mutation-scope-health-blocked-autofixable"); + write_claude_mutation_scope_state(&repo, &claude_autofixable_blocked_state()); + + let mut problems = Vec::new(); + let rows = inspect_mutation_scope_health(true, false, Some(&repo), &mut problems); + + let claude_row = rows + .iter() + .find(|row| row.target == IntegrationTarget::ClaudeCode) + .expect("claude row present"); + assert_eq!(claude_row.status, MutationScopeHealthStatus::Blocked); + assert!( + claude_row + .remediation + .as_deref() + .is_some_and(|text| text.contains("sce doctor --fix")), + "row remediation must name 'sce doctor --fix': {:?}", + claude_row.remediation + ); + + assert_eq!(problems.len(), 1); + assert_eq!(problems[0].fixability, ProblemFixability::AutoFixable); + assert_eq!(problems[0].next_action, "doctor_fix"); + assert!( + problems[0].remediation.contains("sce doctor --fix"), + "problem remediation must name 'sce doctor --fix': {}", + problems[0].remediation + ); + + std::fs::remove_dir_all(&repo).ok(); + } + + #[test] + fn mutation_scope_health_manual_only_states_name_the_real_state_path_and_never_suggest_deletion( + ) { + let blocked_repo = + init_git_repo_with_claude_target("mutation-scope-health-manual-path-blocked"); + write_claude_mutation_scope_state(&blocked_repo, &claude_blocked_state()); + let blocked_git_dir = super::resolve_git_dir(&blocked_repo).expect("resolve git dir"); + let blocked_path = claude_mutation_scope::state::state_path(&blocked_git_dir); + + let mut blocked_problems = Vec::new(); + inspect_mutation_scope_health(true, false, Some(&blocked_repo), &mut blocked_problems); + assert_eq!(blocked_problems.len(), 1); + let blocked_remediation = &blocked_problems[0].remediation; + assert!( + blocked_remediation.contains(&blocked_path.display().to_string()), + "manual-only Blocked remediation must name the real state file path: {blocked_remediation}" + ); + assert!( + !blocked_remediation.to_ascii_lowercase().contains("delete"), + "manual-only Blocked remediation must never contain deletion wording: {blocked_remediation}" + ); + + std::fs::remove_dir_all(&blocked_repo).ok(); + + let invalid_repo = + init_git_repo_with_claude_target("mutation-scope-health-manual-path-invalid"); + let invalid_git_dir = super::resolve_git_dir(&invalid_repo).expect("resolve git dir"); + let invalid_path = claude_mutation_scope::state::state_path(&invalid_git_dir); + std::fs::create_dir_all(invalid_path.parent().expect("state path has a parent")).unwrap(); + std::fs::write(&invalid_path, b"not json").expect("write malformed state file"); + + let mut invalid_problems = Vec::new(); + inspect_mutation_scope_health(true, false, Some(&invalid_repo), &mut invalid_problems); + assert_eq!(invalid_problems.len(), 1); + let invalid_remediation = &invalid_problems[0].remediation; + assert!( + invalid_remediation.contains(&invalid_path.display().to_string()), + "manual-only Invalid remediation must name the real state file path: {invalid_remediation}" + ); + assert!( + !invalid_remediation.to_ascii_lowercase().contains("delete"), + "manual-only Invalid remediation must never contain deletion wording: {invalid_remediation}" + ); + + std::fs::remove_dir_all(&invalid_repo).ok(); + } + #[test] fn mutation_scope_health_is_absent_for_an_unconfigured_undetected_target() { let repo = init_git_repo("mutation-scope-health-no-targets"); @@ -4373,6 +4676,14 @@ mod tests { fn run_full_doctor_report( repo: &std::path::Path, mode: super::DoctorMode, + ) -> super::super::DoctorExecution { + run_full_doctor_report_with_seam(repo, mode, &super::mutation_scope_repair_seam) + } + + fn run_full_doctor_report_with_seam( + repo: &std::path::Path, + mode: super::DoctorMode, + mutation_scope_seam: super::MutationScopeRepairSeam<'_>, ) -> super::super::DoctorExecution { let context = FullReportRepoContext { repo_root: repo.to_path_buf(), @@ -4384,6 +4695,7 @@ mod tests { }, repo, &context, + mutation_scope_seam, ) } @@ -4524,8 +4836,17 @@ mod tests { )); assert!(remediation.contains("no safe generic recovery command exists yet")); assert!( - !remediation.contains("delete the state file") - || remediation.contains("do not delete the state file") + !remediation.contains("delete"), + "manual mutation-scope remediation must not contain deletion wording: {remediation}" + ); + let state_path = + claude_mutation_scope::state::state_path(&super::resolve_git_dir(&repo).unwrap()); + assert!( + problem + .remediation + .contains(&state_path.display().to_string()), + "remediation must name the exact persisted state path: {}", + problem.remediation ); let text = render_text(&execution); @@ -4602,4 +4923,879 @@ mod tests { std::fs::remove_dir_all(&repo).ok(); }); } + + #[test] + fn full_report_autofixable_blocked_names_doctor_fix_in_text_and_json() { + with_isolated_global_state(|| { + let repo = init_git_repo_with_healthy_claude_target("full-report-autofixable-diagnose"); + write_claude_mutation_scope_state(&repo, &claude_autofixable_blocked_state()); + + let execution = run_full_doctor_report(&repo, super::DoctorMode::Diagnose); + assert_eq!(execution.report.readiness, Readiness::NotReady); + let problem = execution + .report + .problems + .iter() + .find(|problem| problem.category == super::ProblemCategory::MutationScopeHealth) + .expect("mutation-scope health problem present"); + assert_eq!(problem.fixability, ProblemFixability::AutoFixable); + assert_eq!(problem.next_action, "doctor_fix"); + assert!(problem.remediation.contains("sce doctor --fix")); + + let text = render_text(&execution); + assert!( + text.contains("sce doctor --fix"), + "human text must name 'sce doctor --fix' for an auto-fixable blocked state: {text}" + ); + + let json = render_json(&execution); + let json_problems = json["problems"].as_array().expect("problems is an array"); + let mutation_scope_problem = json_problems + .iter() + .find(|problem| problem["category"] == "mutation_scope_health") + .expect("mutation-scope health JSON problem present"); + assert_eq!(mutation_scope_problem["fixability"], "auto_fixable"); + assert_eq!( + mutation_scope_problem["remediation"]["next_action"], + "doctor_fix" + ); + assert!(mutation_scope_problem["remediation"]["text"] + .as_str() + .expect("remediation text is a string") + .contains("sce doctor --fix")); + + std::fs::remove_dir_all(&repo).ok(); + }); + } + + fn no_op_repair_seam() -> super::MutationScopeRepairSeam<'static> { + &|_root, _payload, _logger| Ok(String::new()) + } + + #[test] + fn repair_blocked_mutation_scope_target_repairs_an_autofixable_claude_state() { + let repo = init_git_repo_with_claude_target("repair-target-claude-auto"); + write_claude_mutation_scope_state(&repo, &claude_autofixable_blocked_state()); + let git_dir = super::resolve_git_dir(&repo).expect("resolve git dir"); + + let repaired = super::repair_blocked_mutation_scope_target( + IntegrationTarget::ClaudeCode, + &git_dir, + &repo, + no_op_repair_seam(), + ); + assert_eq!( + repaired, + Some(IntegrationTarget::ClaudeCode), + "an autofixable claude blocked target must have a repair attempted" + ); + + let health = claude_mutation_scope::health::classify_health(&git_dir); + assert_ne!( + health.status, + MutationScopeHealthStatus::Blocked, + "a reported fix must never leave the target still blocked" + ); + assert_ne!(health.status, MutationScopeHealthStatus::Invalid); + + let mut problems = Vec::new(); + let final_rows = inspect_mutation_scope_health(true, false, Some(&repo), &mut problems); + let records = super::finalize_mutation_scope_repair_results( + &[IntegrationTarget::ClaudeCode], + &final_rows, + ); + assert_eq!(records.len(), 1, "{records:?}"); + assert_eq!( + records[0].category, + super::ProblemCategory::MutationScopeHealth + ); + assert_eq!(records[0].outcome, super::FixResult::Fixed); + assert!( + records[0] + .detail + .starts_with("Recovered Claude Code Agent tracing ("), + "fix-result detail must match the documented '[fixed] Recovered Agent \ + tracing (...)' contract: {}", + records[0].detail + ); + + std::fs::remove_dir_all(&repo).ok(); + } + + #[test] + fn repair_blocked_mutation_scope_target_never_touches_a_manual_only_claude_state() { + let repo = init_git_repo_with_claude_target("repair-target-claude-manual"); + write_claude_mutation_scope_state(&repo, &claude_blocked_state()); + let git_dir = super::resolve_git_dir(&repo).expect("resolve git dir"); + + let record = super::repair_blocked_mutation_scope_target( + IntegrationTarget::ClaudeCode, + &git_dir, + &repo, + no_op_repair_seam(), + ); + + assert!( + record.is_none(), + "a ManualOnly blocked state must never produce a fix result: {record:?}" + ); + + let path = claude_mutation_scope::state::state_path(&git_dir); + let persisted: AdapterState = + serde_json::from_slice(&std::fs::read(&path).expect("read persisted state")) + .expect("parse persisted state"); + assert_eq!( + persisted, + claude_blocked_state(), + "a ManualOnly blocked state must never be mutated by the repair dispatch" + ); + + std::fs::remove_dir_all(&repo).ok(); + } + + #[test] + fn finalize_mutation_scope_repair_results_ignores_an_immediate_post_repair_read_that_the_final_report_contradicts( + ) { + let repo = init_git_repo_with_claude_target("finalize-race-claude"); + write_claude_mutation_scope_state(&repo, &claude_autofixable_blocked_state()); + let git_dir = super::resolve_git_dir(&repo).expect("resolve git dir"); + + let repaired = super::repair_blocked_mutation_scope_target( + IntegrationTarget::ClaudeCode, + &git_dir, + &repo, + no_op_repair_seam(), + ); + assert_eq!(repaired, Some(IntegrationTarget::ClaudeCode)); + + let immediately_after_repair = claude_mutation_scope::health::classify_health(&git_dir); + assert_ne!( + immediately_after_repair.status, + MutationScopeHealthStatus::Blocked, + "the repair must have genuinely succeeded before the simulated concurrent mutation" + ); + + write_claude_mutation_scope_state(&repo, &claude_blocked_state()); + + let mut problems = Vec::new(); + let final_rows = inspect_mutation_scope_health(true, false, Some(&repo), &mut problems); + let final_status = final_rows + .iter() + .find(|row| row.target == IntegrationTarget::ClaudeCode) + .expect("claude row present in the final report") + .status; + assert_eq!(final_status, MutationScopeHealthStatus::Blocked); + + let fix_results = super::finalize_mutation_scope_repair_results( + &[IntegrationTarget::ClaudeCode], + &final_rows, + ); + + assert!( + !fix_results.iter().any(|result| { + result.category == super::ProblemCategory::MutationScopeHealth + && result.outcome == super::FixResult::Fixed + }), + "a target that regressed to Blocked before the final report is built must never be \ + reported Fixed: {fix_results:?}" + ); + + std::fs::remove_dir_all(&repo).ok(); + } + + fn failing_repair_seam() -> super::MutationScopeRepairSeam<'static> { + &|_root, _payload, _logger| Err(anyhow::anyhow!("simulated seam failure")) + } + + #[test] + fn finalize_mutation_scope_repair_results_reports_manual_when_an_attempted_autofixable_repair_stays_blocked( + ) { + let repo = init_git_repo_with_claude_target("finalize-failed-autofixable-claude"); + write_claude_mutation_scope_state(&repo, &claude_autofixable_blocked_state()); + let git_dir = super::resolve_git_dir(&repo).expect("resolve git dir"); + + let attempted = super::repair_blocked_mutation_scope_target( + IntegrationTarget::ClaudeCode, + &git_dir, + &repo, + failing_repair_seam(), + ); + assert_eq!( + attempted, + Some(IntegrationTarget::ClaudeCode), + "an autofixable blocked target must still be attempted even when the repair seam fails" + ); + + let health = claude_mutation_scope::health::classify_health(&git_dir); + assert_eq!( + health.status, + MutationScopeHealthStatus::Blocked, + "a failing repair seam must leave the target blocked" + ); + + let mut problems = Vec::new(); + let final_rows = inspect_mutation_scope_health(true, false, Some(&repo), &mut problems); + let final_row = final_rows + .iter() + .find(|row| row.target == IntegrationTarget::ClaudeCode) + .expect("claude row present in the final report"); + assert_eq!(final_row.status, MutationScopeHealthStatus::Blocked); + + let fix_results = super::finalize_mutation_scope_repair_results( + &[IntegrationTarget::ClaudeCode], + &final_rows, + ); + + assert!( + !fix_results.iter().any(|result| { + result.category == super::ProblemCategory::MutationScopeHealth + && result.outcome == super::FixResult::Fixed + }), + "an attempted but unresolved mutation-scope repair must never be reported Fixed: \ + {fix_results:?}" + ); + + let manual = fix_results + .iter() + .find(|result| { + result.category == super::ProblemCategory::MutationScopeHealth + && result.outcome == super::FixResult::Manual + }) + .expect( + "an attempted but unresolved mutation-scope repair must produce a manual result", + ); + + let state_path = claude_mutation_scope::state::state_path(&git_dir); + assert!( + manual.detail.contains(&state_path.display().to_string()), + "manual detail must name the exact persisted state path: {}", + manual.detail + ); + assert!( + !manual.detail.to_ascii_lowercase().contains("delete"), + "manual detail must not contain deletion wording: {}", + manual.detail + ); + + std::fs::remove_dir_all(&repo).ok(); + } + + fn minimal_report( + problems: Vec, + mutation_scope_health: Vec, + ) -> HookDoctorReport { + HookDoctorReport { + mode: DoctorMode::Fix, + readiness: compute_readiness(&problems), + state_root: None, + agent_trace_db: None, + repository_root: None, + hook_path_source: HookPathSource::Default, + hooks_directory: None, + post_commit_auto_sync: PostCommitAutoSyncHealth { + state: PostCommitAutoSyncState::NotApplicable, + enabled: false, + source: "test", + config_source: None, + }, + config_locations: Vec::new(), + hooks: Vec::new(), + integration_groups: Vec::new(), + integration_targets_absent: false, + mutation_scope_health, + problems, + } + } + + fn combined_mutation_scope_fix_results( + report: &HookDoctorReport, + attempted_targets: &[IntegrationTarget], + ) -> Vec { + let mut results = super::finalize_mutation_scope_repair_results( + attempted_targets, + &report.mutation_scope_health, + ); + results.extend(build_manual_fix_results(report, attempted_targets)); + results + .into_iter() + .filter(|result| result.category == super::ProblemCategory::MutationScopeHealth) + .collect() + } + + fn attempted_claude_final_rows_and_problems( + repo: &std::path::Path, + after_repair: impl FnOnce(&std::path::Path), + ) -> (Vec, Vec) { + let git_dir = super::resolve_git_dir(repo).expect("resolve git dir"); + + let attempted = super::repair_blocked_mutation_scope_target( + IntegrationTarget::ClaudeCode, + &git_dir, + repo, + no_op_repair_seam(), + ); + assert_eq!( + attempted, + Some(IntegrationTarget::ClaudeCode), + "the autofixable blocked fixture must be attempted" + ); + + after_repair(repo); + + let mut problems = Vec::new(); + let final_rows = inspect_mutation_scope_health(true, false, Some(repo), &mut problems); + (final_rows, problems) + } + + #[test] + fn attempted_target_final_healthy_produces_exactly_one_fixed_and_no_manual() { + let repo = init_git_repo_with_claude_target("aggregation-attempted-healthy"); + write_claude_mutation_scope_state(&repo, &claude_autofixable_blocked_state()); + + let (final_rows, problems) = attempted_claude_final_rows_and_problems(&repo, |_repo| {}); + let claude_row = final_rows + .iter() + .find(|row| row.target == IntegrationTarget::ClaudeCode) + .expect("claude row present"); + assert_eq!(claude_row.status, MutationScopeHealthStatus::Healthy); + + let report = minimal_report(problems, final_rows); + let attempted_targets = [IntegrationTarget::ClaudeCode]; + let mutation_results = combined_mutation_scope_fix_results(&report, &attempted_targets); + + assert_eq!(mutation_results.len(), 1, "{mutation_results:?}"); + assert_eq!(mutation_results[0].outcome, super::FixResult::Fixed); + + std::fs::remove_dir_all(&repo).ok(); + } + + #[test] + fn attempted_target_final_recovering_produces_exactly_one_fixed_and_no_manual() { + let repo = init_git_repo_with_claude_target("aggregation-attempted-recovering"); + write_claude_mutation_scope_state(&repo, &claude_autofixable_blocked_state()); + + let (final_rows, problems) = attempted_claude_final_rows_and_problems(&repo, |repo| { + write_claude_mutation_scope_state(repo, &claude_recovering_state()); + }); + let claude_row = final_rows + .iter() + .find(|row| row.target == IntegrationTarget::ClaudeCode) + .expect("claude row present"); + assert_eq!(claude_row.status, MutationScopeHealthStatus::Recovering); + + let report = minimal_report(problems, final_rows); + let attempted_targets = [IntegrationTarget::ClaudeCode]; + let mutation_results = combined_mutation_scope_fix_results(&report, &attempted_targets); + + assert_eq!(mutation_results.len(), 1, "{mutation_results:?}"); + assert_eq!(mutation_results[0].outcome, super::FixResult::Fixed); + + std::fs::remove_dir_all(&repo).ok(); + } + + #[test] + fn attempted_target_final_blocked_manual_only_produces_exactly_one_manual_result() { + let repo = init_git_repo_with_claude_target("aggregation-attempted-blocked-manual-only"); + write_claude_mutation_scope_state(&repo, &claude_autofixable_blocked_state()); + + let (final_rows, problems) = attempted_claude_final_rows_and_problems(&repo, |repo| { + write_claude_mutation_scope_state(repo, &claude_blocked_state()); + }); + let claude_row = final_rows + .iter() + .find(|row| row.target == IntegrationTarget::ClaudeCode) + .expect("claude row present"); + assert_eq!(claude_row.status, MutationScopeHealthStatus::Blocked); + let claude_problem = problems + .iter() + .find(|problem| problem.category == super::ProblemCategory::MutationScopeHealth) + .expect("claude problem present"); + assert_eq!(claude_problem.fixability, ProblemFixability::ManualOnly); + + let git_dir = super::resolve_git_dir(&repo).expect("resolve git dir"); + let state_path = claude_mutation_scope::state::state_path(&git_dir); + + let report = minimal_report(problems, final_rows); + let attempted_targets = [IntegrationTarget::ClaudeCode]; + let mutation_results = combined_mutation_scope_fix_results(&report, &attempted_targets); + + assert_eq!( + mutation_results.len(), + 1, + "an attempted mutation-scope target must produce exactly one fix result: \ + {mutation_results:?}" + ); + assert_eq!(mutation_results[0].outcome, super::FixResult::Manual); + assert!( + mutation_results[0] + .detail + .contains(&state_path.display().to_string()), + "manual detail must name the exact persisted state path: {}", + mutation_results[0].detail + ); + assert!( + !mutation_results[0] + .detail + .to_ascii_lowercase() + .contains("delete"), + "manual detail must not contain deletion wording: {}", + mutation_results[0].detail + ); + + std::fs::remove_dir_all(&repo).ok(); + } + + #[test] + fn attempted_target_final_invalid_produces_exactly_one_manual_result() { + let repo = init_git_repo_with_claude_target("aggregation-attempted-invalid"); + write_claude_mutation_scope_state(&repo, &claude_autofixable_blocked_state()); + + let (final_rows, problems) = attempted_claude_final_rows_and_problems(&repo, |repo| { + let git_dir = super::resolve_git_dir(repo).expect("resolve git dir"); + let path = claude_mutation_scope::state::state_path(&git_dir); + std::fs::write(&path, b"not json").expect("write malformed state file"); + }); + let claude_row = final_rows + .iter() + .find(|row| row.target == IntegrationTarget::ClaudeCode) + .expect("claude row present"); + assert_eq!(claude_row.status, MutationScopeHealthStatus::Invalid); + + let report = minimal_report(problems, final_rows); + let attempted_targets = [IntegrationTarget::ClaudeCode]; + let mutation_results = combined_mutation_scope_fix_results(&report, &attempted_targets); + + assert_eq!(mutation_results.len(), 1, "{mutation_results:?}"); + assert_eq!(mutation_results[0].outcome, super::FixResult::Manual); + + std::fs::remove_dir_all(&repo).ok(); + } + + #[test] + fn never_attempted_manual_only_target_still_produces_exactly_one_manual_result() { + let repo = init_git_repo_with_claude_target("aggregation-never-attempted-manual-only"); + write_claude_mutation_scope_state(&repo, &claude_blocked_state()); + + let mut problems = Vec::new(); + let final_rows = inspect_mutation_scope_health(true, false, Some(&repo), &mut problems); + let claude_row = final_rows + .iter() + .find(|row| row.target == IntegrationTarget::ClaudeCode) + .expect("claude row present"); + assert_eq!(claude_row.status, MutationScopeHealthStatus::Blocked); + + let report = minimal_report(problems, final_rows); + let attempted_targets: [IntegrationTarget; 0] = []; + let mutation_results = combined_mutation_scope_fix_results(&report, &attempted_targets); + + assert_eq!( + mutation_results.len(), + 1, + "a never-attempted ManualOnly target must still produce exactly one manual result: \ + {mutation_results:?}" + ); + assert_eq!(mutation_results[0].outcome, super::FixResult::Manual); + + std::fs::remove_dir_all(&repo).ok(); + } + + #[test] + fn full_report_fix_mode_leaves_a_manual_only_claude_blocked_state_untouched() { + with_isolated_global_state(|| { + let repo = init_git_repo_with_healthy_claude_target("full-report-fix-claude-manual"); + write_claude_mutation_scope_state(&repo, &claude_blocked_state()); + + let execution = run_full_doctor_report(&repo, super::DoctorMode::Fix); + + assert!( + execution.fix_results.iter().all(|result| { + result.category != super::ProblemCategory::MutationScopeHealth + || result.outcome != super::FixResult::Fixed + }), + "a ManualOnly blocked state must never be reported fixed: {:?}", + execution.fix_results + ); + + let git_dir = super::resolve_git_dir(&repo).expect("resolve git dir"); + let path = claude_mutation_scope::state::state_path(&git_dir); + let persisted: AdapterState = + serde_json::from_slice(&std::fs::read(&path).expect("read persisted state")) + .expect("parse persisted state"); + assert_eq!( + persisted, + claude_blocked_state(), + "'sce doctor --fix' must leave a ManualOnly blocked state completely untouched" + ); + + std::fs::remove_dir_all(&repo).ok(); + }); + } + + #[test] + fn full_report_fix_mode_human_text_shows_the_manual_detail_line() { + with_isolated_global_state(|| { + let manual_repo = + init_git_repo_with_healthy_claude_target("full-report-fix-detail-manual"); + write_claude_mutation_scope_state(&manual_repo, &claude_blocked_state()); + let manual_execution = run_full_doctor_report(&manual_repo, super::DoctorMode::Fix); + let manual_text = render_text(&manual_execution); + assert!( + manual_text.contains("[manual] Agent tracing remains blocked. Inspect '"), + "human fix results must show the '[manual] Agent tracing remains blocked. Inspect ...' line: {manual_text}" + ); + std::fs::remove_dir_all(&manual_repo).ok(); + }); + } + + fn init_git_repo_with_opencode_target(label: &str) -> PathBuf { + let repo = init_git_repo(label); + std::fs::create_dir_all(repo.join(".opencode")).expect("create .opencode directory"); + repo + } + + fn opencode_dead_owner() -> crate::services::hooks::mutation_scope_owner::ProcessOwner { + let mut child = std::process::Command::new("true") + .spawn() + .expect("spawning 'true' should succeed"); + let pid = i32::try_from(child.id()).expect("pid fits in i32"); + child.wait().expect("child should exit and be reaped"); + crate::services::hooks::mutation_scope_owner::ProcessOwner { + pid, + instance_token: None, + } + } + + #[test] + fn repair_blocked_mutation_scope_target_repairs_an_autofixable_opencode_state() { + let repo = init_git_repo_with_opencode_target("repair-target-opencode-auto"); + let git_dir = super::resolve_git_dir(&repo).expect("resolve git dir"); + + let attempt = + crate::services::hooks::opencode_mutation_scope::state::seed_attempt_for_tests( + &git_dir, + &crate::services::hooks::opencode_mutation_scope::AttemptKey { + session_id: "ses-main".to_string(), + call_id: "call-1".to_string(), + }, + "write", + crate::services::hooks::opencode_mutation_scope::state::AttemptPhase::PendingStart, + ); + crate::services::hooks::opencode_mutation_scope::state::set_attempt_owner_for_tests( + &git_dir, + &attempt.scope_id, + Some(opencode_dead_owner()), + ); + + let repaired = super::repair_blocked_mutation_scope_target( + IntegrationTarget::OpenCode, + &git_dir, + &repo, + no_op_repair_seam(), + ); + assert_eq!( + repaired, + Some(IntegrationTarget::OpenCode), + "an autofixable opencode blocked target must have a repair attempted" + ); + + let health = + crate::services::hooks::opencode_mutation_scope::health::classify_health(&git_dir); + assert_ne!(health.status, MutationScopeHealthStatus::Blocked); + assert_ne!(health.status, MutationScopeHealthStatus::Invalid); + + let mut problems = Vec::new(); + let final_rows = inspect_mutation_scope_health(true, false, Some(&repo), &mut problems); + let records = super::finalize_mutation_scope_repair_results( + &[IntegrationTarget::OpenCode], + &final_rows, + ); + assert_eq!(records.len(), 1, "{records:?}"); + assert_eq!( + records[0].category, + super::ProblemCategory::MutationScopeHealth + ); + assert_eq!(records[0].outcome, super::FixResult::Fixed); + + std::fs::remove_dir_all(&repo).ok(); + } + + #[test] + fn full_report_fix_mode_has_no_mutation_scope_effect_when_nothing_is_blocked() { + with_isolated_global_state(|| { + let repo = init_git_repo_with_healthy_claude_target("full-report-fix-no-problems"); + + let execution = run_full_doctor_report(&repo, super::DoctorMode::Fix); + + assert!( + execution + .fix_results + .iter() + .all(|result| result.category != super::ProblemCategory::MutationScopeHealth), + "a repository with no mutation-scope problems must produce no mutation-scope \ + fix result: {:?}", + execution.fix_results + ); + + std::fs::remove_dir_all(&repo).ok(); + }); + } + + fn init_git_repo_with_healthy_claude_and_opencode_targets(label: &str) -> PathBuf { + let repo = init_git_repo(label); + let remote_output = std::process::Command::new("git") + .args([ + "remote", + "add", + "origin", + &format!("https://example.invalid/{label}.git"), + ]) + .current_dir(&repo) + .output() + .expect("git remote add should spawn"); + assert!( + remote_output.status.success(), + "git remote add failed: {}", + String::from_utf8_lossy(&remote_output.stderr) + ); + crate::services::setup::install_required_git_hooks(&repo) + .expect("install canonical git hooks"); + crate::services::setup::run_setup_for_mode( + &repo, + crate::services::setup::SetupMode::NonInteractive( + crate::services::setup::SetupTarget::Claude, + ), + None, + None, + None, + ) + .expect("install canonical Claude integration assets"); + crate::services::setup::run_setup_for_mode( + &repo, + crate::services::setup::SetupMode::NonInteractive( + crate::services::setup::SetupTarget::OpenCode, + ), + None, + None, + None, + ) + .expect("install canonical OpenCode integration assets"); + run_full_doctor_report(&repo, super::DoctorMode::Fix); + repo + } + + fn seed_opencode_manual_only_blocked_state(git_dir: &std::path::Path) { + crate::services::hooks::opencode_mutation_scope::state::seed_attempt_for_tests( + git_dir, + &crate::services::hooks::opencode_mutation_scope::AttemptKey { + session_id: "ses-main".to_string(), + call_id: "call-1".to_string(), + }, + "write", + crate::services::hooks::opencode_mutation_scope::state::AttemptPhase::PendingStart, + ); + } + + #[test] + fn full_report_multi_adapter_diagnose_names_doctor_fix_for_one_target_and_the_real_path_for_the_other( + ) { + with_isolated_global_state(|| { + let repo = init_git_repo_with_healthy_claude_and_opencode_targets( + "full-report-multi-adapter-diagnose", + ); + write_claude_mutation_scope_state(&repo, &claude_autofixable_blocked_state()); + let git_dir = super::resolve_git_dir(&repo).expect("resolve git dir"); + seed_opencode_manual_only_blocked_state(&git_dir); + + let execution = run_full_doctor_report(&repo, super::DoctorMode::Diagnose); + assert_eq!(execution.report.readiness, Readiness::NotReady); + let mutation_scope_problems: Vec<_> = execution + .report + .problems + .iter() + .filter(|problem| problem.category == super::ProblemCategory::MutationScopeHealth) + .collect(); + assert_eq!( + mutation_scope_problems.len(), + 2, + "{mutation_scope_problems:#?}" + ); + + let claude_problem = mutation_scope_problems + .iter() + .find(|problem| { + problem.mutation_scope_target == Some(IntegrationTarget::ClaudeCode) + }) + .expect("claude mutation-scope problem present"); + assert_eq!(claude_problem.fixability, ProblemFixability::AutoFixable); + assert_eq!(claude_problem.next_action, "doctor_fix"); + assert!(claude_problem.remediation.contains("sce doctor --fix")); + + let opencode_problem = mutation_scope_problems + .iter() + .find(|problem| problem.mutation_scope_target == Some(IntegrationTarget::OpenCode)) + .expect("opencode mutation-scope problem present"); + assert_eq!(opencode_problem.fixability, ProblemFixability::ManualOnly); + assert_eq!(opencode_problem.next_action, "manual_steps"); + let opencode_state_path = + crate::services::hooks::opencode_mutation_scope::state::state_path(&git_dir); + assert!( + opencode_problem + .remediation + .contains(&opencode_state_path.display().to_string()), + "remediation must name the exact persisted opencode state path: {}", + opencode_problem.remediation + ); + assert!( + !opencode_problem + .remediation + .to_ascii_lowercase() + .contains("delete"), + "manual opencode remediation must not contain deletion wording: {}", + opencode_problem.remediation + ); + + let text = render_text(&execution); + assert!( + text.contains("sce doctor --fix"), + "human text must name 'sce doctor --fix' for the autofixable claude row: {text}" + ); + assert!( + text.contains(&opencode_state_path.display().to_string()), + "human text must name the real opencode state path: {text}" + ); + + let json = render_json(&execution); + assert_eq!(json["readiness"], "not_ready"); + let json_problems = json["problems"].as_array().expect("problems is an array"); + let json_mutation_scope_problems: Vec<_> = json_problems + .iter() + .filter(|problem| problem["category"] == "mutation_scope_health") + .collect(); + assert_eq!( + json_mutation_scope_problems.len(), + 2, + "{json_mutation_scope_problems:?}" + ); + let json_autofixable = json_mutation_scope_problems + .iter() + .find(|problem| problem["fixability"] == "auto_fixable") + .expect("json autofixable problem present"); + assert_eq!(json_autofixable["remediation"]["next_action"], "doctor_fix"); + assert!(json_autofixable["remediation"]["text"] + .as_str() + .expect("remediation text is a string") + .contains("sce doctor --fix")); + let json_manual_only = json_mutation_scope_problems + .iter() + .find(|problem| problem["fixability"] == "manual_only") + .expect("json manual-only problem present"); + assert_eq!( + json_manual_only["remediation"]["next_action"], + "manual_steps" + ); + assert!(json_manual_only["remediation"]["text"] + .as_str() + .expect("remediation text is a string") + .contains(&opencode_state_path.display().to_string())); + + std::fs::remove_dir_all(&repo).ok(); + }); + } + + #[test] + fn full_report_multi_adapter_fix_mode_resolves_one_target_and_leaves_the_other_manual() { + with_isolated_global_state(|| { + let repo = init_git_repo_with_healthy_claude_and_opencode_targets( + "full-report-multi-adapter-fix", + ); + write_claude_mutation_scope_state(&repo, &claude_autofixable_blocked_state()); + let git_dir = super::resolve_git_dir(&repo).expect("resolve git dir"); + seed_opencode_manual_only_blocked_state(&git_dir); + + let execution = run_full_doctor_report_with_seam( + &repo, + super::DoctorMode::Fix, + no_op_repair_seam(), + ); + + let mutation_scope_results: Vec<_> = execution + .fix_results + .iter() + .filter(|result| result.category == super::ProblemCategory::MutationScopeHealth) + .collect(); + assert_eq!( + mutation_scope_results.len(), + 2, + "{mutation_scope_results:?}" + ); + assert!( + mutation_scope_results.iter().any(|result| { + result.outcome == super::FixResult::Fixed + && result + .detail + .starts_with("Recovered Claude Code Agent tracing (") + }), + "expected a fixed claude result: {mutation_scope_results:?}" + ); + assert!( + mutation_scope_results.iter().any(|result| { + result.outcome == super::FixResult::Manual + && result + .detail + .contains("Agent tracing remains blocked. Inspect '") + }), + "expected a manual opencode result: {mutation_scope_results:?}" + ); + + assert_eq!(execution.report.readiness, Readiness::NotReady); + let final_mutation_scope_problems: Vec<_> = execution + .report + .problems + .iter() + .filter(|problem| problem.category == super::ProblemCategory::MutationScopeHealth) + .collect(); + assert_eq!( + final_mutation_scope_problems.len(), + 1, + "the repaired claude target must no longer appear as a problem in the final \ + report, leaving only the still-blocked opencode target: {final_mutation_scope_problems:#?}" + ); + assert_eq!( + final_mutation_scope_problems[0].mutation_scope_target, + Some(IntegrationTarget::OpenCode) + ); + assert_eq!( + final_mutation_scope_problems[0].fixability, + ProblemFixability::ManualOnly + ); + + let text = render_text(&execution); + assert!( + text.contains("[fixed] Recovered Claude Code Agent tracing ("), + "human fix results must show the claude '[fixed] Recovered ...' line: {text}" + ); + assert!( + text.contains("[manual] Agent tracing remains blocked. Inspect '"), + "human fix results must show the opencode '[manual] Agent tracing remains \ + blocked ...' line: {text}" + ); + + let opencode_health = + crate::services::hooks::opencode_mutation_scope::health::classify_health(&git_dir); + assert_eq!( + opencode_health.status, + MutationScopeHealthStatus::Blocked, + "a ManualOnly opencode target must remain untouched and still blocked" + ); + let claude_health = claude_mutation_scope::health::classify_health(&git_dir); + assert_ne!( + claude_health.status, + MutationScopeHealthStatus::Blocked, + "a reported fixed claude target must never leave the target still blocked" + ); + assert_ne!(claude_health.status, MutationScopeHealthStatus::Invalid); + + std::fs::remove_dir_all(&repo).ok(); + }); + } } diff --git a/cli/src/services/doctor/mod.rs b/cli/src/services/doctor/mod.rs index 768a9e356..4771c33eb 100644 --- a/cli/src/services/doctor/mod.rs +++ b/cli/src/services/doctor/mod.rs @@ -22,7 +22,11 @@ pub(crate) mod types; pub mod command; use fixes::build_manual_fix_results; -use inspect::{build_report_with_lifecycle_problems, repair_merge_target_configs}; +use inspect::{ + build_report_with_lifecycle_problems, finalize_mutation_scope_repair_results, + mutation_scope_repair_seam, repair_blocked_mutation_scope_targets_with_seam, + repair_merge_target_configs, MutationScopeRepairSeam, +}; use render::render_report; use types::{ DoctorFixResultRecord, DoctorProblem, FixResult, HookDoctorReport, ProblemCategory, @@ -85,7 +89,12 @@ where setup::ensure_git_repository(¤t_dir).unwrap_or(current_dir) }; let scoped_context = context.with_repo_root(&repository_root); - let execution = execute_doctor_with_context(request, &repository_root, &scoped_context); + let execution = execute_doctor_with_context( + request, + &repository_root, + &scoped_context, + &mutation_scope_repair_seam, + ); render_report(request, &execution) } @@ -93,6 +102,7 @@ fn execute_doctor_with_context( request: DoctorRequest, repository_root: &Path, context: &impl HasRepoRoot, + mutation_scope_seam: MutationScopeRepairSeam<'_>, ) -> DoctorExecution { execute_doctor_with_lifecycle_providers( request, @@ -108,6 +118,7 @@ fn execute_doctor_with_context( validate_config_file: &crate::services::config::validate_config_file, probe_codex_hook_policy: &codex_hook_policy::probe_default, }, + mutation_scope_seam, ) } @@ -116,6 +127,7 @@ fn execute_doctor_with_lifecycle_providers( repository_root: &Path, context: &impl HasRepoRoot, dependencies: &DoctorDependencies<'_>, + mutation_scope_seam: MutationScopeRepairSeam<'_>, ) -> DoctorExecution { // Probed exactly once per doctor invocation, then reused for every // Codex integration inspection below (initial report, `--fix`, and final @@ -148,6 +160,8 @@ fn execute_doctor_with_lifecycle_providers( repository_root, &policy_readiness, )); + let mutation_scope_repairs = + repair_blocked_mutation_scope_targets_with_seam(&initial_report, mutation_scope_seam); let final_problems = diagnose_lifecycle_providers(context, &providers); let final_doctor_problems = final_problems .into_iter() @@ -160,7 +174,14 @@ fn execute_doctor_with_lifecycle_providers( final_doctor_problems, &policy_readiness, ); - fix_results.extend(build_manual_fix_results(&final_report)); + fix_results.extend(finalize_mutation_scope_repair_results( + &mutation_scope_repairs, + &final_report.mutation_scope_health, + )); + fix_results.extend(build_manual_fix_results( + &final_report, + &mutation_scope_repairs, + )); DoctorExecution { report: final_report, @@ -216,6 +237,7 @@ fn doctor_problem_from_health(problem: HealthProblem) -> DoctorProblem { remediation: problem.remediation, next_action: problem.next_action, scope: None, + mutation_scope_target: None, } } diff --git a/cli/src/services/doctor/render.rs b/cli/src/services/doctor/render.rs index 9b08d5f0c..65fee4c25 100644 --- a/cli/src/services/doctor/render.rs +++ b/cli/src/services/doctor/render.rs @@ -631,11 +631,18 @@ fn render_display_detail(lines: &mut Vec, detail: &DoctorDisplayDetail, lines.push(format!("{prefix}Problem: {summary}")); lines.push(format!("{prefix}Remediation: {remediation}")); } - DoctorDisplayDetail::MutationScopeHealth { reason, detail } => { + DoctorDisplayDetail::MutationScopeHealth { + reason, + detail, + remediation, + } => { lines.push(format!("{prefix}Reason: {reason}")); if let Some(detail) = detail { lines.push(format!("{prefix}Detail: {detail}")); } + if let Some(remediation) = remediation { + lines.push(format!("{prefix}Remediation: {remediation}")); + } } } } @@ -668,6 +675,7 @@ fn mutation_scope_health_node(row: &MutationScopeHealthRow) -> DoctorDisplayNode vec![DoctorDisplayDetail::MutationScopeHealth { reason: row.reason.clone(), detail: row.detail.clone(), + remediation: row.remediation.clone(), }], Vec::new(), ) @@ -927,12 +935,23 @@ mod tests { status: MutationScopeHealthStatus, reason: &str, detail: Option<&str>, + ) -> MutationScopeHealthRow { + row_with_remediation(target, status, reason, detail, None) + } + + fn row_with_remediation( + target: IntegrationTarget, + status: MutationScopeHealthStatus, + reason: &str, + detail: Option<&str>, + remediation: Option<&str>, ) -> MutationScopeHealthRow { MutationScopeHealthRow { target, status, reason: reason.to_string(), detail: detail.map(str::to_string), + remediation: remediation.map(str::to_string), } } @@ -993,6 +1012,35 @@ mod tests { .any(|line| line.contains("Detail: 2 stale attempts"))); } + #[test] + fn blocked_row_renders_remediation_when_present() { + let row = row_with_remediation( + IntegrationTarget::ClaudeCode, + MutationScopeHealthStatus::Blocked, + "stale attempts remain after a failed abandon", + None, + Some("Run 'sce doctor --fix' to recover this state."), + ); + let lines = rendered_lines(&row); + + assert!(lines.iter().any( + |line| line.contains("Remediation: Run 'sce doctor --fix' to recover this state.") + )); + } + + #[test] + fn blocked_row_omits_remediation_line_when_absent() { + let row = row( + IntegrationTarget::ClaudeCode, + MutationScopeHealthStatus::Blocked, + "stale attempts remain after a failed abandon", + None, + ); + let lines = rendered_lines(&row); + + assert!(!lines.iter().any(|line| line.contains("Remediation:"))); + } + #[test] fn invalid_row_maps_to_fail() { let row = row( diff --git a/cli/src/services/doctor/types.rs b/cli/src/services/doctor/types.rs index e256a1f91..317b2e2f9 100644 --- a/cli/src/services/doctor/types.rs +++ b/cli/src/services/doctor/types.rs @@ -14,6 +14,7 @@ pub(super) struct MutationScopeHealthRow { pub(super) status: MutationScopeHealthStatus, pub(super) reason: String, pub(super) detail: Option, + pub(super) remediation: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -303,6 +304,7 @@ pub(super) enum DoctorDisplayDetail { MutationScopeHealth { reason: String, detail: Option, + remediation: Option, }, } @@ -529,6 +531,7 @@ pub(crate) struct DoctorProblem { pub(crate) remediation: String, pub(crate) next_action: &'static str, pub(super) scope: Option, + pub(super) mutation_scope_target: Option, } #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/cli/src/services/hooks/claude_bridge_session.rs b/cli/src/services/hooks/claude_bridge_session.rs index 73391a5ad..31731a67b 100644 --- a/cli/src/services/hooks/claude_bridge_session.rs +++ b/cli/src/services/hooks/claude_bridge_session.rs @@ -7,10 +7,6 @@ use serde_json::Value; const MAX_LEADING_RECORDS: usize = 16; const BRIDGE_SESSION_RECORD_TYPE: &str = "bridge-session"; -/// Extract Claude's bridge-session identifier from the leading JSONL records. -/// -/// Transcript access and parsing are fail-open. Only a bounded number of -/// records are read so discovery never scans a complete transcript. pub fn extract_claude_bridge_session_id(transcript_path: &Path) -> Option { extract_claude_bridge_session_id_from_reader(File::open(transcript_path).map(BufReader::new)) } diff --git a/cli/src/services/hooks/claude_mutation_scope/events.rs b/cli/src/services/hooks/claude_mutation_scope/events.rs new file mode 100644 index 000000000..226b8cd2e --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/events.rs @@ -0,0 +1,316 @@ +use anyhow::{anyhow, bail, Context, Result}; +use serde_json::{Map, Value}; + +pub(super) const HOOK_EVENT_NAME_FIELD: &str = "hook_event_name"; +pub(super) const SESSION_ID_FIELD: &str = "session_id"; +pub(super) const CWD_FIELD: &str = "cwd"; +pub(super) const AGENT_ID_FIELD: &str = "agent_id"; +pub(super) const TOOL_NAME_FIELD: &str = "tool_name"; +pub(super) const TOOL_USE_ID_FIELD: &str = "tool_use_id"; +pub(super) const TOOL_INPUT_FIELD: &str = "tool_input"; +pub(super) const RUN_IN_BACKGROUND_FIELD: &str = "run_in_background"; +pub(super) const PROMPT_ID_FIELD: &str = "prompt_id"; +pub(super) const AGENT_TYPE_FIELD: &str = "agent_type"; +pub(super) const WORKTREE_PATH_FIELD: &str = "worktree_path"; + +pub(super) const HOOK_EVENT_PRE_TOOL_USE: &str = "PreToolUse"; +pub(super) const HOOK_EVENT_POST_TOOL_USE: &str = "PostToolUse"; +pub(super) const HOOK_EVENT_POST_TOOL_USE_FAILURE: &str = "PostToolUseFailure"; +pub(super) const HOOK_EVENT_PERMISSION_DENIED: &str = "PermissionDenied"; +pub(super) const HOOK_EVENT_STOP: &str = "Stop"; +pub(super) const HOOK_EVENT_STOP_FAILURE: &str = "StopFailure"; +pub(super) const HOOK_EVENT_USER_PROMPT_SUBMIT: &str = "UserPromptSubmit"; +pub(super) const HOOK_EVENT_SUBAGENT_STOP: &str = "SubagentStop"; +pub(super) const HOOK_EVENT_SESSION_END: &str = "SessionEnd"; +pub(super) const HOOK_EVENT_WORKTREE_REMOVE: &str = "WorktreeRemove"; +pub(super) const HOOK_EVENT_SESSION_START: &str = "SessionStart"; +pub(super) const HOOK_EVENT_SUBAGENT_START: &str = "SubagentStart"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum ClaudeHookEvent { + PreToolUse(ClaudeToolExecution), + PostToolUse(ClaudeToolIdentity), + PostToolUseFailure(ClaudeToolIdentity), + PermissionDenied(ClaudeToolIdentity), + Stop(ClaudeSessionIdentity), + StopFailure(ClaudeSessionIdentity), + UserPromptSubmit(ClaudeSessionIdentity), + SubagentStop(ClaudeAgentIdentity), + SessionEnd(ClaudeSessionIdentity), + WorktreeRemove(ClaudeWorktreeRemove), + SessionStart, + SubagentStart, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ClaudeToolIdentity { + pub session_id: String, + pub cwd: String, + pub agent_id: Option, + pub tool_name: String, + pub tool_use_id: String, +} + +impl ClaudeToolIdentity { + pub(crate) fn attempt_key(&self) -> AttemptKey { + AttemptKey { + session_id: self.session_id.clone(), + agent_id: self.agent_id.clone(), + tool_use_id: self.tool_use_id.clone(), + } + } + + pub(crate) fn is_subagent(&self) -> bool { + self.agent_id.is_some() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ClaudeToolExecution { + pub identity: ClaudeToolIdentity, + pub prompt_id: Option, + pub agent_type: Option, + pub run_in_background: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ClaudeSessionIdentity { + pub session_id: String, + pub cwd: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ClaudeAgentIdentity { + pub session_id: String, + pub cwd: String, + pub agent_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ClaudeWorktreeRemove { + pub session_id: String, + pub worktree_path: String, +} + +#[allow(clippy::struct_field_names)] +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub(crate) struct AttemptKey { + pub session_id: String, + pub agent_id: Option, + pub tool_use_id: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ToolClassification { + MutationCapable, + ReadOnly, + Delegation, +} + +pub(super) const DELEGATION_TOOL_NAME: &str = "Agent"; +pub(super) const KNOWN_READ_ONLY_TOOL_NAMES: &[&str] = &[ + "Read", + "Glob", + "Grep", + "WebFetch", + "WebSearch", + "AskUserQuestion", +]; + +pub(crate) fn classify_tool(tool_name: &str) -> ToolClassification { + if tool_name == DELEGATION_TOOL_NAME { + return ToolClassification::Delegation; + } + if KNOWN_READ_ONLY_TOOL_NAMES.contains(&tool_name) { + return ToolClassification::ReadOnly; + } + ToolClassification::MutationCapable +} + +pub(super) const BASH_TOOL_NAME: &str = "Bash"; +pub(super) const POWERSHELL_TOOL_NAME: &str = "PowerShell"; + +pub(crate) fn is_explicit_background_shell(tool_name: &str, run_in_background: bool) -> bool { + run_in_background && (tool_name == BASH_TOOL_NAME || tool_name == POWERSHELL_TOOL_NAME) +} + +pub(super) const CLAUDE_SCOPE_ID_SCHEME: &str = "cc-tool-v1"; + +pub(crate) fn format_claude_scope_id(attempt_seq: u64, key: &AttemptKey) -> String { + let agent_id = key.agent_id.as_deref().unwrap_or(""); + format!( + "{CLAUDE_SCOPE_ID_SCHEME}|n={attempt_seq}|s={}:{}|a={}:{}|t={}:{}", + key.session_id.len(), + key.session_id, + agent_id.len(), + agent_id, + key.tool_use_id.len(), + key.tool_use_id, + ) +} + +pub(crate) fn claude_scope_start_event_id(scope_id: &str) -> String { + format!("{scope_id}|start") +} + +pub(crate) fn claude_scope_close_event_id(scope_id: &str) -> String { + format!("{scope_id}|close") +} + +pub(crate) fn parse_claude_hook_event(stdin_payload: &str) -> Result { + if stdin_payload.trim().is_empty() { + bail!(validation_error( + "expected a JSON object, got an empty payload" + )); + } + + let parsed: Value = serde_json::from_str(stdin_payload) + .with_context(|| validation_error("expected valid JSON"))?; + let object = parsed + .as_object() + .ok_or_else(|| anyhow!(validation_error("expected a JSON object")))?; + + let hook_event_name = required_non_blank_str(object, HOOK_EVENT_NAME_FIELD)?; + + match hook_event_name.as_str() { + HOOK_EVENT_PRE_TOOL_USE => parse_pre_tool_use(object).map(ClaudeHookEvent::PreToolUse), + HOOK_EVENT_POST_TOOL_USE => parse_tool_identity(object).map(ClaudeHookEvent::PostToolUse), + HOOK_EVENT_POST_TOOL_USE_FAILURE => { + parse_tool_identity(object).map(ClaudeHookEvent::PostToolUseFailure) + } + HOOK_EVENT_PERMISSION_DENIED => { + parse_tool_identity(object).map(ClaudeHookEvent::PermissionDenied) + } + HOOK_EVENT_STOP => parse_session_identity(object).map(ClaudeHookEvent::Stop), + HOOK_EVENT_STOP_FAILURE => parse_session_identity(object).map(ClaudeHookEvent::StopFailure), + HOOK_EVENT_USER_PROMPT_SUBMIT => { + parse_session_identity(object).map(ClaudeHookEvent::UserPromptSubmit) + } + HOOK_EVENT_SUBAGENT_STOP => parse_agent_identity(object).map(ClaudeHookEvent::SubagentStop), + HOOK_EVENT_SESSION_END => parse_session_identity(object).map(ClaudeHookEvent::SessionEnd), + HOOK_EVENT_WORKTREE_REMOVE => { + parse_worktree_remove(object).map(ClaudeHookEvent::WorktreeRemove) + } + HOOK_EVENT_SESSION_START => Ok(ClaudeHookEvent::SessionStart), + HOOK_EVENT_SUBAGENT_START => Ok(ClaudeHookEvent::SubagentStart), + other => bail!(validation_error(&format!( + "unsupported hook_event_name '{other}'" + ))), + } +} + +pub(super) fn parse_tool_identity(object: &Map) -> Result { + Ok(ClaudeToolIdentity { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + cwd: required_non_blank_str(object, CWD_FIELD)?, + tool_name: required_non_blank_str(object, TOOL_NAME_FIELD)?, + tool_use_id: required_non_blank_str(object, TOOL_USE_ID_FIELD)?, + agent_id: optional_non_blank_str(object, AGENT_ID_FIELD)?, + }) +} + +pub(super) fn parse_pre_tool_use(object: &Map) -> Result { + Ok(ClaudeToolExecution { + identity: parse_tool_identity(object)?, + prompt_id: optional_non_blank_str(object, PROMPT_ID_FIELD)?, + agent_type: optional_non_blank_str(object, AGENT_TYPE_FIELD)?, + run_in_background: parse_run_in_background(object)?, + }) +} + +pub(super) fn parse_run_in_background(object: &Map) -> Result { + let Some(tool_input) = object.get(TOOL_INPUT_FIELD) else { + return Ok(false); + }; + if tool_input.is_null() { + return Ok(false); + } + let tool_input = tool_input.as_object().ok_or_else(|| { + anyhow!(validation_error(&format!( + "field '{TOOL_INPUT_FIELD}' must be a JSON object" + ))) + })?; + + match tool_input.get(RUN_IN_BACKGROUND_FIELD) { + None | Some(Value::Null) => Ok(false), + Some(Value::Bool(value)) => Ok(*value), + Some(_) => bail!(validation_error(&format!( + "field '{TOOL_INPUT_FIELD}.{RUN_IN_BACKGROUND_FIELD}' must be a boolean" + ))), + } +} + +pub(super) fn parse_session_identity(object: &Map) -> Result { + Ok(ClaudeSessionIdentity { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + cwd: required_non_blank_str(object, CWD_FIELD)?, + }) +} + +pub(super) fn parse_agent_identity(object: &Map) -> Result { + Ok(ClaudeAgentIdentity { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + cwd: required_non_blank_str(object, CWD_FIELD)?, + agent_id: required_non_blank_str(object, AGENT_ID_FIELD)?, + }) +} + +pub(super) fn parse_worktree_remove(object: &Map) -> Result { + Ok(ClaudeWorktreeRemove { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + worktree_path: required_non_blank_str(object, WORKTREE_PATH_FIELD)?, + }) +} + +pub(super) fn required_field<'a>(object: &'a Map, field: &str) -> Result<&'a Value> { + object.get(field).ok_or_else(|| { + anyhow!(validation_error(&format!( + "missing required field '{field}'" + ))) + }) +} + +pub(super) fn required_str(object: &Map, field: &str) -> Result { + required_field(object, field)? + .as_str() + .map(str::to_owned) + .ok_or_else(|| { + anyhow!(validation_error(&format!( + "field '{field}' must be a string" + ))) + }) +} + +pub(super) fn required_non_blank_str(object: &Map, field: &str) -> Result { + let value = required_str(object, field)?; + if value.trim().is_empty() { + bail!(validation_error(&format!( + "field '{field}' must be a non-blank string" + ))); + } + Ok(value) +} + +pub(super) fn optional_non_blank_str( + object: &Map, + field: &str, +) -> Result> { + match object.get(field) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) => { + if value.trim().is_empty() { + bail!(validation_error(&format!( + "field '{field}' must be null, absent, or a non-blank string" + ))); + } + Ok(Some(value.clone())) + } + Some(_) => bail!(validation_error(&format!( + "field '{field}' must be null, absent, or a non-blank string" + ))), + } +} + +pub(super) fn validation_error(detail: &str) -> String { + format!("Invalid Claude hook event payload from STDIN: {detail}.") +} diff --git a/cli/src/services/hooks/claude_mutation_scope/health.rs b/cli/src/services/hooks/claude_mutation_scope/health.rs index 22f22a4d3..35e1a97fe 100644 --- a/cli/src/services/hooks/claude_mutation_scope/health.rs +++ b/cli/src/services/hooks/claude_mutation_scope/health.rs @@ -7,6 +7,33 @@ use crate::services::mutation_trace::types::ActorKind; use super::state; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Repairability { + AutoFixable, + ManualOnly, +} + +pub(crate) fn assess_repairability(git_dir: &Path) -> Repairability { + let Ok(state) = state::read_state(git_dir) else { + return Repairability::ManualOnly; + }; + + if !state.recovery_pending || state.attempts.is_empty() { + return Repairability::ManualOnly; + } + + let every_attempt_is_pending_abandon = state + .attempts + .iter() + .all(|attempt| attempt.phase == state::AttemptPhase::PendingAbandon); + + if every_attempt_is_pending_abandon { + Repairability::AutoFixable + } else { + Repairability::ManualOnly + } +} + pub(crate) fn classify_health(git_dir: &Path) -> MutationScopeAdapterHealth { let state = match state::read_state(git_dir) { Ok(state) => state, @@ -20,6 +47,19 @@ pub(crate) fn classify_health(git_dir: &Path) -> MutationScopeAdapterHealth { } }; + let has_pending_abandon = state + .attempts + .iter() + .any(|attempt| attempt.phase == state::AttemptPhase::PendingAbandon); + + if !state.recovery_pending && has_pending_abandon { + return MutationScopeAdapterHealth::new( + ActorKind::ClaudeCode, + MutationScopeHealthStatus::Invalid, + "PendingAbandon means terminal cleanup has been durably established, therefore the recovery barrier cannot legitimately already be clear.", + ); + } + if !state.recovery_pending { return MutationScopeAdapterHealth::new( ActorKind::ClaudeCode, @@ -50,7 +90,10 @@ mod tests { use anyhow::anyhow; - use super::super::{abandon_attempt, apply_recovery_barrier, AttemptKey, BarrierOutcome}; + use super::super::{ + abandon_attempt, apply_recovery_barrier, cleanup_attempts_matching, repair_blocked, + AttemptKey, BarrierOutcome, RepairOutcome, + }; use super::*; use crate::services::observability::traits::Logger; @@ -202,6 +245,11 @@ mod tests { 1, "remove_attempt must never have run because the seam call failed" ); + assert_eq!( + seeded_state.attempts[0].phase, + state::AttemptPhase::PendingAbandon, + "the attempt's abandon intent must be durably persisted before the seam call ran" + ); assert_eq!( classify_health(&git_dir).status, @@ -223,6 +271,559 @@ mod tests { "the classifier must still report Blocked after repeated denial" ); + assert_eq!( + assess_repairability(&git_dir), + Repairability::AutoFixable, + "a PendingAbandon attempt is an already-established abandon decision, safe to retry" + ); + + let healthy = + |_root: &Path, _payload: &str, _logger: Option<&dyn Logger>| Ok(String::new()); + let outcome = repair_blocked(&git_dir, repository_root, None, &healthy) + .expect("repair should not error"); + assert_eq!(outcome, RepairOutcome::Repaired); + + let final_status = classify_health(&git_dir).status; + assert!( + matches!( + final_status, + MutationScopeHealthStatus::Healthy | MutationScopeHealthStatus::Recovering + ), + "AC3/AC6: a reported repair must never leave the final health Blocked: {final_status:?}" + ); + assert!(state::read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn mark_active_losing_the_race_against_an_established_pending_abandon_leaves_repairable_terminal_evidence( + ) { + let git_dir = unique_test_git_dir("mark-active-race"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let repository_root = git_dir.as_path(); + + let allocated = state::allocate_attempt(&git_dir, &key("toolu_1"), "Write") + .expect("allocation should succeed"); + + state::mark_recovery_pending_and_pending_abandon( + &git_dir, + std::slice::from_ref(&allocated.attempt.scope_id), + ) + .expect("atomic establishment should succeed"); + + let error = state::mark_active(&git_dir, &allocated.attempt.scope_id) + .expect_err("mark_active must fail once abandonment has been established"); + assert!(error.to_string().contains("abandon")); + + let state_after = state::read_state(&git_dir).expect("state readable"); + assert!( + state_after.recovery_pending, + "recovery_pending must remain true after losing the activation race" + ); + assert_eq!( + state_after.attempts[0].phase, + state::AttemptPhase::PendingAbandon, + "losing the activation race must leave PendingAbandon intact, not resurrect Active" + ); + + assert_eq!( + classify_health(&git_dir).status, + MutationScopeHealthStatus::Blocked + ); + assert_eq!( + assess_repairability(&git_dir), + Repairability::AutoFixable, + "losing the activation race must leave repairable established terminal evidence, \ + not fall back to ManualOnly" + ); + + let healthy = + |_root: &Path, _payload: &str, _logger: Option<&dyn Logger>| Ok(String::new()); + let outcome = repair_blocked(&git_dir, repository_root, None, &healthy) + .expect("repair should not error"); + assert_eq!(outcome, RepairOutcome::Repaired); + + let resolved = state::read_state(&git_dir).expect("state readable"); + assert!(resolved.attempts.is_empty()); + assert!(!resolved.recovery_pending); + assert_eq!( + classify_health(&git_dir).status, + MutationScopeHealthStatus::Healthy + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn repair_blocked_removes_only_successfully_abandoned_attempts_and_keeps_recovery_pending_when_one_fails( + ) { + let git_dir = unique_test_git_dir("repair-partial-batch"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let repository_root = git_dir.as_path(); + + let first = state::allocate_attempt(&git_dir, &key("toolu-1"), "Write") + .expect("first allocation should succeed"); + let second = state::allocate_attempt(&git_dir, &key("toolu-2"), "Write") + .expect("second allocation should succeed"); + + state::mark_recovery_pending_and_pending_abandon( + &git_dir, + &[ + first.attempt.scope_id.clone(), + second.attempt.scope_id.clone(), + ], + ) + .expect("atomic establishment should succeed"); + + let fail_first = + |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> anyhow::Result { + if payload.contains(&first.attempt.scope_id) { + return Err(anyhow!( + "seam failure injected by test for the first attempt" + )); + } + Ok(String::new()) + }; + + let outcome = repair_blocked(&git_dir, repository_root, None, &fail_first) + .expect("repair should not error even though one seam call fails"); + assert_eq!(outcome, RepairOutcome::NoOp); + + let state = state::read_state(&git_dir).expect("state readable"); + assert_eq!( + state.attempts.len(), + 1, + "the successfully abandoned attempt must be removed" + ); + assert_eq!(state.attempts[0].scope_id, first.attempt.scope_id); + assert_eq!(state.attempts[0].phase, state::AttemptPhase::PendingAbandon); + assert!( + state.recovery_pending, + "the barrier must remain armed while any attempt is still unresolved" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn assess_repairability_is_manual_only_when_the_adapter_is_not_blocked() { + let git_dir = unique_test_git_dir("assess-not-blocked"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + assert_eq!(assess_repairability(&git_dir), Repairability::ManualOnly); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn assess_repairability_is_manual_only_for_a_legacy_attempt_never_marked_pending_abandon() { + let git_dir = unique_test_git_dir("assess-legacy-no-pending-abandon"); + 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"); + + assert_eq!( + classify_health(&git_dir).status, + MutationScopeHealthStatus::Blocked, + "a legacy attempt left PendingStart under a stale recovery flag must still classify Blocked" + ); + assert_eq!( + assess_repairability(&git_dir), + Repairability::ManualOnly, + "an attempt with no established abandon intent is never treated as repairable" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn assess_repairability_is_manual_only_when_one_of_several_attempts_has_no_established_abandon_intent( + ) { + let git_dir = unique_test_git_dir("assess-mixed-phase"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let repository_root = git_dir.as_path(); + + let doomed = state::allocate_attempt(&git_dir, &key("toolu-doomed"), "Write") + .expect("allocating the doomed attempt should succeed"); + abandon_attempt( + &git_dir, + repository_root, + &doomed.attempt, + None, + &failing_seam, + ) + .expect_err("the injected abandon seam failure must propagate"); + + state::allocate_attempt(&git_dir, &key("toolu-live"), "Write") + .expect("allocating the untouched live attempt should succeed"); + + assert_eq!( + classify_health(&git_dir).status, + MutationScopeHealthStatus::Blocked + ); + assert_eq!( + assess_repairability(&git_dir), + Repairability::ManualOnly, + "every attempt must be PendingAbandon before doctor may repair; a live attempt \ + with no established abandon intent must never be touched" + ); + + let outcome = repair_blocked(&git_dir, repository_root, None, &unreachable_seam) + .expect("repair should not error"); + assert_eq!( + outcome, + RepairOutcome::NoOp, + "a coexisting attempt with no established abandon intent must block the whole repair" + ); + let untouched = state::read_state(&git_dir).expect("state readable"); + assert_eq!( + untouched.attempts.len(), + 2, + "neither attempt may be touched while any one lacks PendingAbandon evidence" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn repair_blocked_is_a_safe_no_op_when_an_attempt_is_no_longer_pending_abandon_by_the_time_the_lock_is_acquired( + ) { + let git_dir = unique_test_git_dir("repair-concurrent-race"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let repository_root = git_dir.as_path(); + + let allocated = state::allocate_attempt(&git_dir, &key("toolu_1"), "Write") + .expect("allocation should succeed"); + abandon_attempt( + &git_dir, + repository_root, + &allocated.attempt, + None, + &failing_seam, + ) + .expect_err("the injected abandon seam failure must propagate"); + + assert_eq!(assess_repairability(&git_dir), Repairability::AutoFixable); + + state::set_attempt_phase_for_tests( + &git_dir, + &allocated.attempt.scope_id, + state::AttemptPhase::Active, + ); + + let outcome = repair_blocked(&git_dir, repository_root, None, &unreachable_seam) + .expect("repair should not error"); + + assert_eq!( + outcome, + RepairOutcome::NoOp, + "the fresh, lock-protected re-proof must refuse to act on state assessed before it changed" + ); + let state = state::read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].phase, state::AttemptPhase::Active); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn repair_blocked_interrupted_by_a_failing_seam_leaves_state_a_later_repair_completes_without_duplication( + ) { + let git_dir = unique_test_git_dir("repair-interrupted-resume"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let repository_root = git_dir.as_path(); + + let allocated = state::allocate_attempt(&git_dir, &key("toolu_1"), "Write") + .expect("allocation should succeed"); + abandon_attempt( + &git_dir, + repository_root, + &allocated.attempt, + None, + &failing_seam, + ) + .expect_err("the injected abandon seam failure must propagate"); + + let outcome = repair_blocked(&git_dir, repository_root, None, &failing_seam) + .expect("repair should not error even though the seam abandon call fails again"); + assert_eq!(outcome, RepairOutcome::NoOp); + + let interrupted = state::read_state(&git_dir).expect("state readable"); + assert_eq!(interrupted.attempts.len(), 1); + assert_eq!( + interrupted.attempts[0].phase, + state::AttemptPhase::PendingAbandon + ); + assert_eq!( + classify_health(&git_dir).status, + MutationScopeHealthStatus::Blocked, + "an interrupted repair must remain retryable, not resurrect or duplicate the attempt" + ); + + let healthy = + |_root: &Path, _payload: &str, _logger: Option<&dyn Logger>| Ok(String::new()); + let outcome = repair_blocked(&git_dir, repository_root, None, &healthy) + .expect("the later repair should complete without error"); + assert_eq!(outcome, RepairOutcome::Repaired); + + let resolved = state::read_state(&git_dir).expect("state readable"); + assert!(resolved.attempts.is_empty()); + assert!(!resolved.recovery_pending); + assert_eq!( + classify_health(&git_dir).status, + MutationScopeHealthStatus::Healthy + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn cleanup_attempts_matching_durably_marks_every_matched_attempt_pending_abandon_before_any_seam_call_even_when_the_first_fails( + ) { + let git_dir = unique_test_git_dir("cleanup-batch-mark"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let repository_root = git_dir.as_path(); + + let first = state::allocate_attempt(&git_dir, &key("toolu-1"), "Write") + .expect("first allocation should succeed"); + let second = state::allocate_attempt(&git_dir, &key("toolu-2"), "Write") + .expect("second allocation should succeed"); + + let failing_on_first = + |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> anyhow::Result { + if payload.contains(&first.attempt.scope_id) { + return Err(anyhow!( + "seam failure injected by test for the first attempt" + )); + } + Ok(String::new()) + }; + + let error = cleanup_attempts_matching( + &git_dir, + repository_root, + None, + &failing_on_first, + |_attempt| true, + ) + .expect_err("a failure abandoning one attempt must still surface an error"); + assert!(error.to_string().contains("seam failure")); + + let seeded = state::read_state(&git_dir).expect("state readable"); + let remaining_first = seeded + .attempts + .iter() + .find(|attempt| attempt.scope_id == first.attempt.scope_id) + .expect("the failed attempt must still be tracked, not lost"); + assert_eq!( + remaining_first.phase, + state::AttemptPhase::PendingAbandon, + "AC3/T04: every matched attempt must be durably marked before any seam call, \ + so the still-blocked first attempt keeps its retryable evidence" + ); + assert!( + seeded + .attempts + .iter() + .all(|attempt| attempt.scope_id != second.attempt.scope_id), + "the second attempt's own seam call must still have been attempted and succeeded" + ); + + assert_eq!( + classify_health(&git_dir).status, + MutationScopeHealthStatus::Blocked + ); + assert_eq!(assess_repairability(&git_dir), Repairability::AutoFixable); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn apply_recovery_barrier_fails_closed_when_a_new_obligation_is_established_while_the_flush_seam_is_in_flight( + ) { + let git_dir = unique_test_git_dir("barrier-race-new-obligation"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let repository_root = git_dir.as_path(); + + state::mark_recovery_pending(&git_dir).expect("arming the barrier should succeed"); + + let racing_seam = |_root: &Path, + _payload: &str, + _logger: Option<&dyn Logger>| + -> anyhow::Result { + let raced = state::allocate_attempt(&git_dir, &key("toolu-raced-in"), "Write") + .expect("the racing allocation should succeed"); + state::mark_recovery_pending_and_pending_abandon( + &git_dir, + std::slice::from_ref(&raced.attempt.scope_id), + ) + .expect("the racing establishment should succeed"); + Ok(String::new()) + }; + + let outcome = apply_recovery_barrier(&git_dir, repository_root, None, &racing_seam); + assert!( + matches!(outcome, BarrierOutcome::Deny), + "a fresh obligation established during the flush must fail the barrier closed, \ + not proceed on the stale pre-flush proof" + ); + + let after = state::read_state(&git_dir).expect("state readable"); + assert!( + after.recovery_pending, + "RecoveryNeverClearedWithUnresolvedAbandon: recovery must remain armed" + ); + assert_eq!(after.attempts.len(), 1); + assert_eq!(after.attempts[0].phase, state::AttemptPhase::PendingAbandon); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn repair_blocked_fails_closed_when_a_new_obligation_is_established_while_abandoning_another() { + let git_dir = unique_test_git_dir("repair-race-new-obligation"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let repository_root = git_dir.as_path(); + + let original = state::allocate_attempt(&git_dir, &key("toolu-original"), "Write") + .expect("allocation should succeed"); + state::mark_recovery_pending_and_pending_abandon( + &git_dir, + std::slice::from_ref(&original.attempt.scope_id), + ) + .expect("atomic establishment should succeed"); + + let racing_seam = |_root: &Path, + _payload: &str, + _logger: Option<&dyn Logger>| + -> anyhow::Result { + let raced = state::allocate_attempt(&git_dir, &key("toolu-raced-in"), "Write") + .expect("the racing allocation should succeed"); + state::mark_recovery_pending_and_pending_abandon( + &git_dir, + std::slice::from_ref(&raced.attempt.scope_id), + ) + .expect("the racing establishment should succeed"); + Ok(String::new()) + }; + + let outcome = repair_blocked(&git_dir, repository_root, None, &racing_seam) + .expect("repair should not error"); + assert_eq!( + outcome, + RepairOutcome::NoOp, + "AC6: repair must not report Repaired when the atomic clear discovers another \ + unresolved obligation, even though its own abandon seam call succeeded" + ); + + let after = state::read_state(&git_dir).expect("state readable"); + assert!( + after.recovery_pending, + "RecoveryNeverClearedWithUnresolvedAbandon: recovery must remain armed while the \ + raced-in obligation is unresolved" + ); + assert!( + after + .attempts + .iter() + .all(|attempt| attempt.scope_id != original.attempt.scope_id), + "the original attempt's own abandon must still have completed" + ); + assert_eq!(after.attempts.len(), 1); + assert_eq!(after.attempts[0].phase, state::AttemptPhase::PendingAbandon); + + remove_test_git_dir(&git_dir); + } + + fn hand_constructed_attempt( + attempt_seq: u64, + tool_use_id: &str, + phase: state::AttemptPhase, + ) -> state::AdapterAttempt { + state::AdapterAttempt { + attempt_seq, + scope_id: format!("claude|s=session-1:a|c={attempt_seq}:{tool_use_id}"), + session_id: "session-1".to_string(), + agent_id: None, + tool_use_id: tool_use_id.to_string(), + tool_name: "Edit".to_string(), + phase, + } + } + + fn write_hand_constructed_state(git_dir: &Path, state: &state::AdapterState) { + let path = state::state_path(git_dir); + std::fs::create_dir_all(path.parent().expect("state path has a parent")) + .expect("state dir should be created"); + std::fs::write( + &path, + serde_json::to_vec(state).expect("serialize adapter state"), + ) + .expect("write hand-constructed adapter state"); + } + + #[test] + fn clear_recovery_with_pending_abandon_is_invalid() { + let git_dir = unique_test_git_dir("invalid-clear-pending-abandon"); + write_hand_constructed_state( + &git_dir, + &state::AdapterState { + version: 1, + next_attempt_seq: 2, + recovery_pending: false, + attempts: vec![hand_constructed_attempt( + 1, + "toolu_1", + state::AttemptPhase::PendingAbandon, + )], + }, + ); + + 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, + "doctor must not auto-repair an impossible Claude state" + ); + + 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); } } diff --git a/cli/src/services/hooks/claude_mutation_scope/lifecycle.rs b/cli/src/services/hooks/claude_mutation_scope/lifecycle.rs new file mode 100644 index 000000000..1bd0be753 --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/lifecycle.rs @@ -0,0 +1,536 @@ +use std::path::{Path, PathBuf}; + +use anyhow::Result; + +use crate::services::hooks; +use crate::services::mutation_trace::runtime::resolve_git_dir; +use crate::services::observability::traits::Logger; + +use super::state; +use super::{ + abandon_payload, classify_tool, claude_scope_close_event_id, claude_scope_start_event_id, + flush_payload, is_explicit_background_shell, parse_claude_hook_event, pre_tool_use_deny_json, + scope_boundary_payload, scope_start_payload, AttemptKey, ClaudeHookEvent, ClaudeToolExecution, + ClaudeToolIdentity, ToolClassification, +}; + +pub(super) type GitDirResolver<'a> = &'a dyn Fn(&str) -> Result; + +pub(super) type IngressSeam<'a> = &'a dyn Fn(&Path, &str, Option<&dyn Logger>) -> Result; + +pub(super) const ACTOR_KIND_CLAUDE_CODE: &str = "claude_code"; + +pub(super) const FAIL_CLOSED_DENY_REASON: &str = + "SCE could not establish mutation attribution for this tool execution."; +pub(super) const EXPLICIT_BACKGROUND_SHELL_DENY_REASON: &str = + "SCE mutation attribution does not yet support detached background shell execution. Run this command in the foreground."; + +pub(super) const PRE_TOOL_USE_FAIL_CLOSED_EVENT: &str = + "sce.hooks.claude_mutation_scope.pre_tool_use_fail_closed"; +pub(super) const MODEL_STATE_UNAVAILABLE_EVENT: &str = + "sce.hooks.claude_mutation_scope.model_state_unavailable"; + +pub(super) type ClaudeModelStateResolver<'a> = + &'a dyn Fn(&Path, &str, &str) -> Result>; + +pub(super) fn log_pre_tool_use_fail_closed( + logger: Option<&dyn Logger>, + context: &str, + error: &anyhow::Error, +) { + if let Some(log) = logger { + log.warn( + PRE_TOOL_USE_FAIL_CLOSED_EVENT, + &error.to_string(), + &[("context", context)], + None, + ); + } +} + +pub(crate) fn run_claude_mutation_scope_subcommand(logger: Option<&dyn Logger>) -> Result { + let stdin_payload = hooks::read_hook_stdin()?; + run_claude_mutation_scope_from_payload(&stdin_payload, logger) +} + +pub(crate) fn run_claude_mutation_scope_from_payload( + stdin_payload: &str, + logger: Option<&dyn Logger>, +) -> Result { + let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); + let model_state_resolver = + |repository_root: &Path, session_id: &str, agent_id: &str| -> Result> { + let db = hooks::open_agent_trace_db_for_hook_runtime( + repository_root, + "Failed to open Agent Trace DB for Claude mutation-scope model resolution.", + )?; + Ok(db + .claude_model_state_by_session_and_agent(session_id, agent_id)? + .map(|state| state.model_id)) + }; + let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { + hooks::mutation_scope::run_mutation_scope_from_payload(repository_root, payload, logger) + }; + + run_claude_mutation_scope_from_payload_with_resolver( + stdin_payload, + logger, + &resolve_git_dir_fn, + &model_state_resolver, + &seam_fn, + ) +} + +#[cfg(test)] +pub(crate) fn run_claude_mutation_scope_from_payload_at_state_root( + state_root: &Path, + stdin_payload: &str, + logger: Option<&dyn Logger>, +) -> Result { + let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); + let model_state_root = state_root.to_path_buf(); + let seam_state_root = state_root.to_path_buf(); + let model_state_resolver = + move |repository_root: &Path, session_id: &str, agent_id: &str| -> Result> { + let db = hooks::open_agent_trace_db_for_hook_runtime_at_state_root( + repository_root, + &model_state_root, + "Failed to open Agent Trace DB for Claude mutation-scope model resolution.", + )?; + Ok(db + .claude_model_state_by_session_and_agent(session_id, agent_id)? + .map(|state| state.model_id)) + }; + let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { + hooks::mutation_scope::run_mutation_scope_from_payload_at_state_root( + repository_root, + &seam_state_root, + payload, + logger, + ) + }; + + run_claude_mutation_scope_from_payload_with_resolver( + stdin_payload, + logger, + &resolve_git_dir_fn, + &model_state_resolver, + &seam_fn, + ) +} + +#[cfg(test)] +pub(super) fn run_claude_mutation_scope_from_payload_with( + stdin_payload: &str, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, +) -> Result { + let unavailable_model_state = |_repository_root: &Path, + _session_id: &str, + _agent_id: &str| + -> Result> { Ok(None) }; + run_claude_mutation_scope_from_payload_with_resolver( + stdin_payload, + logger, + resolve_git_dir, + &unavailable_model_state, + seam, + ) +} + +pub(super) fn run_claude_mutation_scope_from_payload_with_resolver( + stdin_payload: &str, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + model_state_resolver: ClaudeModelStateResolver, + seam: IngressSeam, +) -> Result { + let event = parse_claude_hook_event(stdin_payload)?; + dispatch_claude_hook_event(event, logger, resolve_git_dir, model_state_resolver, seam) +} + +pub(super) fn dispatch_claude_hook_event( + event: ClaudeHookEvent, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + model_state_resolver: ClaudeModelStateResolver, + seam: IngressSeam, +) -> Result { + match event { + ClaudeHookEvent::PreToolUse(execution) => Ok(handle_pre_tool_use( + &execution, + logger, + resolve_git_dir, + model_state_resolver, + seam, + )), + ClaudeHookEvent::PostToolUse(identity) | ClaudeHookEvent::PostToolUseFailure(identity) => { + let git_dir = resolve_git_dir(&identity.cwd)?; + let repository_root = Path::new(&identity.cwd); + handle_close( + &git_dir, + repository_root, + &identity.attempt_key(), + logger, + seam, + ) + } + ClaudeHookEvent::PermissionDenied(identity) => { + let git_dir = resolve_git_dir(&identity.cwd)?; + let repository_root = Path::new(&identity.cwd); + handle_permission_denied( + &git_dir, + repository_root, + &identity.attempt_key(), + logger, + seam, + ) + } + ClaudeHookEvent::Stop(session) | ClaudeHookEvent::StopFailure(session) => { + let git_dir = resolve_git_dir(&session.cwd)?; + let repository_root = Path::new(&session.cwd); + let session_id = session.session_id.clone(); + cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { + attempt.session_id == session_id && attempt.agent_id.is_none() + }) + } + ClaudeHookEvent::UserPromptSubmit(session) => { + let git_dir = resolve_git_dir(&session.cwd)?; + let repository_root = Path::new(&session.cwd); + let session_id = session.session_id.clone(); + cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { + attempt.session_id == session_id && attempt.agent_id.is_none() + }) + } + ClaudeHookEvent::SubagentStop(agent) => { + let git_dir = resolve_git_dir(&agent.cwd)?; + let repository_root = Path::new(&agent.cwd); + let session_id = agent.session_id.clone(); + let agent_id = agent.agent_id.clone(); + cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { + attempt.session_id == session_id && attempt.agent_id.as_deref() == Some(&agent_id) + }) + } + ClaudeHookEvent::SessionEnd(session) => { + let git_dir = resolve_git_dir(&session.cwd)?; + let repository_root = Path::new(&session.cwd); + let session_id = session.session_id.clone(); + cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { + attempt.session_id == session_id + }) + } + ClaudeHookEvent::WorktreeRemove(worktree_remove) => { + let git_dir = resolve_git_dir(&worktree_remove.worktree_path)?; + let repository_root = Path::new(&worktree_remove.worktree_path); + cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |_attempt| true) + } + ClaudeHookEvent::SessionStart | ClaudeHookEvent::SubagentStart => Ok(String::new()), + } +} + +pub(super) fn handle_pre_tool_use( + execution: &ClaudeToolExecution, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + model_state_resolver: ClaudeModelStateResolver, + seam: IngressSeam, +) -> String { + let identity = &execution.identity; + + if matches!( + classify_tool(&identity.tool_name), + ToolClassification::ReadOnly | ToolClassification::Delegation + ) { + return String::new(); + } + + if is_explicit_background_shell(&identity.tool_name, execution.run_in_background) { + return pre_tool_use_deny_json(EXPLICIT_BACKGROUND_SHELL_DENY_REASON); + } + + let repository_root = Path::new(&identity.cwd); + let git_dir = match resolve_git_dir(&identity.cwd) { + Ok(git_dir) => git_dir, + Err(error) => { + log_pre_tool_use_fail_closed(logger, "resolve_git_dir", &error); + return pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON); + } + }; + + if matches!( + apply_recovery_barrier(&git_dir, repository_root, logger, seam), + BarrierOutcome::Deny + ) { + return pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON); + } + + match establish_start( + &git_dir, + repository_root, + identity, + logger, + model_state_resolver, + seam, + ) { + Ok(()) => String::new(), + Err(error) => { + log_pre_tool_use_fail_closed(logger, "establish_start", &error); + pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON) + } + } +} + +pub(super) enum BarrierOutcome { + Proceed, + Deny, +} + +pub(super) fn apply_recovery_barrier( + git_dir: &Path, + repository_root: &Path, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> BarrierOutcome { + let state = match state::read_state(git_dir) { + Ok(state) => state, + Err(error) => { + log_pre_tool_use_fail_closed(logger, "recovery_barrier.read_state", &error); + return BarrierOutcome::Deny; + } + }; + + if !state.recovery_pending { + return BarrierOutcome::Proceed; + } + + if !state.attempts.is_empty() { + return BarrierOutcome::Deny; + } + + match seam(repository_root, &flush_payload(), logger) { + Ok(_) => match state::clear_recovery_pending_if_quiescent(git_dir) { + Ok(state::ClearRecoveryOutcome::Cleared) => BarrierOutcome::Proceed, + Ok(state::ClearRecoveryOutcome::StillPending) => BarrierOutcome::Deny, + Err(error) => { + log_pre_tool_use_fail_closed( + logger, + "recovery_barrier.clear_recovery_pending_if_quiescent", + &error, + ); + BarrierOutcome::Deny + } + }, + Err(error) => { + log_pre_tool_use_fail_closed(logger, "recovery_barrier.flush", &error); + BarrierOutcome::Deny + } + } +} + +pub(super) fn establish_start( + git_dir: &Path, + repository_root: &Path, + identity: &ClaudeToolIdentity, + logger: Option<&dyn Logger>, + model_state_resolver: ClaudeModelStateResolver, + seam: IngressSeam, +) -> Result<()> { + let allocated = state::allocate_attempt(git_dir, &identity.attempt_key(), &identity.tool_name)?; + let scope_id = &allocated.attempt.scope_id; + let canonical_session_id = + hooks::prefixed_diff_trace_session_id(hooks::CLAUDE_TOOL_NAME, &identity.session_id); + let agent_id = identity.agent_id.as_deref().unwrap_or(""); + let model_id = match model_state_resolver(repository_root, &canonical_session_id, agent_id) { + Ok(model_id) => model_id.and_then(|model| hooks::normalize_claude_model_id(&model)), + Err(error) => { + if let Some(log) = logger { + log.warn( + MODEL_STATE_UNAVAILABLE_EVENT, + &error.to_string(), + &[("agent_id", agent_id)], + Some(&canonical_session_id), + ); + } + None + } + }; + let start_payload = scope_start_payload( + scope_id, + &claude_scope_start_event_id(scope_id), + &canonical_session_id, + model_id.as_deref(), + ); + + seam(repository_root, &start_payload, logger)?; + state::mark_active(git_dir, scope_id)?; + Ok(()) +} + +pub(super) fn handle_close( + git_dir: &Path, + repository_root: &Path, + key: &AttemptKey, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result { + let current = state::read_state(git_dir)?; + let Some(attempt) = current + .attempts + .iter() + .find(|attempt| attempt_matches_key(attempt, key)) + .cloned() + else { + return Ok(String::new()); + }; + + if attempt.phase == state::AttemptPhase::PendingStart { + abandon_attempt(git_dir, repository_root, &attempt, logger, seam)?; + return Ok(String::new()); + } + + let close_payload = scope_boundary_payload( + "close", + &attempt.scope_id, + &claude_scope_close_event_id(&attempt.scope_id), + ); + + if seam(repository_root, &close_payload, logger).is_ok() { + state::remove_attempt(git_dir, &attempt.scope_id)?; + } else { + abandon_attempt(git_dir, repository_root, &attempt, logger, seam)?; + } + Ok(String::new()) +} + +pub(super) fn handle_permission_denied( + git_dir: &Path, + repository_root: &Path, + key: &AttemptKey, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result { + let current = state::read_state(git_dir)?; + let Some(attempt) = current + .attempts + .iter() + .find(|attempt| attempt_matches_key(attempt, key)) + .cloned() + else { + return Ok(String::new()); + }; + + abandon_attempt(git_dir, repository_root, &attempt, logger, seam)?; + Ok(String::new()) +} + +pub(super) fn cleanup_attempts_matching( + git_dir: &Path, + repository_root: &Path, + logger: Option<&dyn Logger>, + seam: IngressSeam, + predicate: impl Fn(&state::AdapterAttempt) -> bool, +) -> Result { + let current = state::read_state(git_dir)?; + let stale: Vec = current + .attempts + .into_iter() + .filter(|attempt| predicate(attempt)) + .collect(); + + if stale.is_empty() { + return Ok(String::new()); + } + + let stale_scope_ids: Vec = stale + .iter() + .map(|attempt| attempt.scope_id.clone()) + .collect(); + state::mark_recovery_pending_and_pending_abandon(git_dir, &stale_scope_ids)?; + + let mut first_error: Option = None; + for attempt in &stale { + if let Err(error) = + abandon_marked_attempt(git_dir, repository_root, &attempt.scope_id, logger, seam) + { + if first_error.is_none() { + first_error = Some(error); + } + } + } + + match first_error { + Some(error) => Err(error), + None => Ok(String::new()), + } +} + +pub(super) fn attempt_matches_key(attempt: &state::AdapterAttempt, key: &AttemptKey) -> bool { + attempt.session_id == key.session_id + && attempt.agent_id == key.agent_id + && attempt.tool_use_id == key.tool_use_id +} + +pub(super) fn abandon_attempt( + git_dir: &Path, + repository_root: &Path, + attempt: &state::AdapterAttempt, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result<()> { + state::mark_recovery_pending_and_pending_abandon( + git_dir, + std::slice::from_ref(&attempt.scope_id), + )?; + + abandon_marked_attempt(git_dir, repository_root, &attempt.scope_id, logger, seam) +} + +fn abandon_marked_attempt( + git_dir: &Path, + repository_root: &Path, + scope_id: &str, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result<()> { + seam(repository_root, &abandon_payload(scope_id), logger)?; + state::remove_attempt(git_dir, scope_id)?; + Ok(()) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum RepairOutcome { + Repaired, + NoOp, +} + +pub(crate) fn repair_blocked( + git_dir: &Path, + repository_root: &Path, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result { + let Some(attempts) = state::reprove_pending_abandon(git_dir)? else { + return Ok(RepairOutcome::NoOp); + }; + + let mut any_failed = false; + for attempt in &attempts { + if abandon_marked_attempt(git_dir, repository_root, &attempt.scope_id, logger, seam) + .is_err() + { + any_failed = true; + } + } + + let cleared = matches!( + state::clear_recovery_pending_if_quiescent(git_dir)?, + state::ClearRecoveryOutcome::Cleared + ); + + if any_failed || !cleared { + Ok(RepairOutcome::NoOp) + } else { + Ok(RepairOutcome::Repaired) + } +} diff --git a/cli/src/services/hooks/claude_mutation_scope/mod.rs b/cli/src/services/hooks/claude_mutation_scope/mod.rs index aa4c72c04..2295e5fb3 100644 --- a/cli/src/services/hooks/claude_mutation_scope/mod.rs +++ b/cli/src/services/hooks/claude_mutation_scope/mod.rs @@ -1,4385 +1,69 @@ #![allow(dead_code)] +mod events; pub(crate) mod health; +mod lifecycle; +mod payload; pub(crate) mod state; -use std::path::{Path, PathBuf}; - -use anyhow::{anyhow, bail, Context, Result}; -use serde_json::{json, Map, Value}; - +#[allow(unused_imports)] use crate::services::mutation_trace::runtime::resolve_git_dir; +#[allow(unused_imports)] use crate::services::observability::traits::Logger; +#[allow(unused_imports)] +use anyhow::{anyhow, bail, Context, Result}; +#[allow(unused_imports)] +use std::path::{Path, PathBuf}; -const HOOK_EVENT_NAME_FIELD: &str = "hook_event_name"; -const SESSION_ID_FIELD: &str = "session_id"; -const CWD_FIELD: &str = "cwd"; -const AGENT_ID_FIELD: &str = "agent_id"; -const TOOL_NAME_FIELD: &str = "tool_name"; -const TOOL_USE_ID_FIELD: &str = "tool_use_id"; -const TOOL_INPUT_FIELD: &str = "tool_input"; -const RUN_IN_BACKGROUND_FIELD: &str = "run_in_background"; -const PROMPT_ID_FIELD: &str = "prompt_id"; -const AGENT_TYPE_FIELD: &str = "agent_type"; -const WORKTREE_PATH_FIELD: &str = "worktree_path"; - -const HOOK_EVENT_PRE_TOOL_USE: &str = "PreToolUse"; -const HOOK_EVENT_POST_TOOL_USE: &str = "PostToolUse"; -const HOOK_EVENT_POST_TOOL_USE_FAILURE: &str = "PostToolUseFailure"; -const HOOK_EVENT_PERMISSION_DENIED: &str = "PermissionDenied"; -const HOOK_EVENT_STOP: &str = "Stop"; -const HOOK_EVENT_STOP_FAILURE: &str = "StopFailure"; -const HOOK_EVENT_USER_PROMPT_SUBMIT: &str = "UserPromptSubmit"; -const HOOK_EVENT_SUBAGENT_STOP: &str = "SubagentStop"; -const HOOK_EVENT_SESSION_END: &str = "SessionEnd"; -const HOOK_EVENT_WORKTREE_REMOVE: &str = "WorktreeRemove"; -const HOOK_EVENT_SESSION_START: &str = "SessionStart"; -const HOOK_EVENT_SUBAGENT_START: &str = "SubagentStart"; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) enum ClaudeHookEvent { - PreToolUse(ClaudeToolExecution), - PostToolUse(ClaudeToolIdentity), - PostToolUseFailure(ClaudeToolIdentity), - PermissionDenied(ClaudeToolIdentity), - Stop(ClaudeSessionIdentity), - StopFailure(ClaudeSessionIdentity), - UserPromptSubmit(ClaudeSessionIdentity), - SubagentStop(ClaudeAgentIdentity), - SessionEnd(ClaudeSessionIdentity), - WorktreeRemove(ClaudeWorktreeRemove), - SessionStart, - SubagentStart, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct ClaudeToolIdentity { - pub session_id: String, - pub cwd: String, - pub agent_id: Option, - pub tool_name: String, - pub tool_use_id: String, -} - -impl ClaudeToolIdentity { - pub(crate) fn attempt_key(&self) -> AttemptKey { - AttemptKey { - session_id: self.session_id.clone(), - agent_id: self.agent_id.clone(), - tool_use_id: self.tool_use_id.clone(), - } - } - - pub(crate) fn is_subagent(&self) -> bool { - self.agent_id.is_some() - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct ClaudeToolExecution { - pub identity: ClaudeToolIdentity, - pub prompt_id: Option, - pub agent_type: Option, - pub run_in_background: bool, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct ClaudeSessionIdentity { - pub session_id: String, - pub cwd: String, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct ClaudeAgentIdentity { - pub session_id: String, - pub cwd: String, - pub agent_id: String, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct ClaudeWorktreeRemove { - pub session_id: String, - pub worktree_path: String, -} - -#[allow(clippy::struct_field_names)] -#[derive(Clone, Debug, Eq, PartialEq, Hash)] -pub(crate) struct AttemptKey { - pub session_id: String, - pub agent_id: Option, - pub tool_use_id: String, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum ToolClassification { - MutationCapable, - ReadOnly, - Delegation, -} - -const DELEGATION_TOOL_NAME: &str = "Agent"; -const KNOWN_READ_ONLY_TOOL_NAMES: &[&str] = &[ - "Read", - "Glob", - "Grep", - "WebFetch", - "WebSearch", - "AskUserQuestion", -]; - -pub(crate) fn classify_tool(tool_name: &str) -> ToolClassification { - if tool_name == DELEGATION_TOOL_NAME { - return ToolClassification::Delegation; - } - if KNOWN_READ_ONLY_TOOL_NAMES.contains(&tool_name) { - return ToolClassification::ReadOnly; - } - ToolClassification::MutationCapable -} - -const BASH_TOOL_NAME: &str = "Bash"; -const POWERSHELL_TOOL_NAME: &str = "PowerShell"; - -pub(crate) fn is_explicit_background_shell(tool_name: &str, run_in_background: bool) -> bool { - run_in_background && (tool_name == BASH_TOOL_NAME || tool_name == POWERSHELL_TOOL_NAME) -} - -const CLAUDE_SCOPE_ID_SCHEME: &str = "cc-tool-v1"; - -pub(crate) fn format_claude_scope_id(attempt_seq: u64, key: &AttemptKey) -> String { - let agent_id = key.agent_id.as_deref().unwrap_or(""); - format!( - "{CLAUDE_SCOPE_ID_SCHEME}|n={attempt_seq}|s={}:{}|a={}:{}|t={}:{}", - key.session_id.len(), - key.session_id, - agent_id.len(), - agent_id, - key.tool_use_id.len(), - key.tool_use_id, - ) -} - -pub(crate) fn claude_scope_start_event_id(scope_id: &str) -> String { - format!("{scope_id}|start") -} - -pub(crate) fn claude_scope_close_event_id(scope_id: &str) -> String { - format!("{scope_id}|close") -} - -pub(crate) fn parse_claude_hook_event(stdin_payload: &str) -> Result { - if stdin_payload.trim().is_empty() { - bail!(validation_error( - "expected a JSON object, got an empty payload" - )); - } - - let parsed: Value = serde_json::from_str(stdin_payload) - .with_context(|| validation_error("expected valid JSON"))?; - let object = parsed - .as_object() - .ok_or_else(|| anyhow!(validation_error("expected a JSON object")))?; - - let hook_event_name = required_non_blank_str(object, HOOK_EVENT_NAME_FIELD)?; - - match hook_event_name.as_str() { - HOOK_EVENT_PRE_TOOL_USE => parse_pre_tool_use(object).map(ClaudeHookEvent::PreToolUse), - HOOK_EVENT_POST_TOOL_USE => parse_tool_identity(object).map(ClaudeHookEvent::PostToolUse), - HOOK_EVENT_POST_TOOL_USE_FAILURE => { - parse_tool_identity(object).map(ClaudeHookEvent::PostToolUseFailure) - } - HOOK_EVENT_PERMISSION_DENIED => { - parse_tool_identity(object).map(ClaudeHookEvent::PermissionDenied) - } - HOOK_EVENT_STOP => parse_session_identity(object).map(ClaudeHookEvent::Stop), - HOOK_EVENT_STOP_FAILURE => parse_session_identity(object).map(ClaudeHookEvent::StopFailure), - HOOK_EVENT_USER_PROMPT_SUBMIT => { - parse_session_identity(object).map(ClaudeHookEvent::UserPromptSubmit) - } - HOOK_EVENT_SUBAGENT_STOP => parse_agent_identity(object).map(ClaudeHookEvent::SubagentStop), - HOOK_EVENT_SESSION_END => parse_session_identity(object).map(ClaudeHookEvent::SessionEnd), - HOOK_EVENT_WORKTREE_REMOVE => { - parse_worktree_remove(object).map(ClaudeHookEvent::WorktreeRemove) - } - HOOK_EVENT_SESSION_START => Ok(ClaudeHookEvent::SessionStart), - HOOK_EVENT_SUBAGENT_START => Ok(ClaudeHookEvent::SubagentStart), - other => bail!(validation_error(&format!( - "unsupported hook_event_name '{other}'" - ))), - } -} - -fn parse_tool_identity(object: &Map) -> Result { - Ok(ClaudeToolIdentity { - session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, - cwd: required_non_blank_str(object, CWD_FIELD)?, - tool_name: required_non_blank_str(object, TOOL_NAME_FIELD)?, - tool_use_id: required_non_blank_str(object, TOOL_USE_ID_FIELD)?, - agent_id: optional_non_blank_str(object, AGENT_ID_FIELD)?, - }) -} - -fn parse_pre_tool_use(object: &Map) -> Result { - Ok(ClaudeToolExecution { - identity: parse_tool_identity(object)?, - prompt_id: optional_non_blank_str(object, PROMPT_ID_FIELD)?, - agent_type: optional_non_blank_str(object, AGENT_TYPE_FIELD)?, - run_in_background: parse_run_in_background(object)?, - }) -} - -fn parse_run_in_background(object: &Map) -> Result { - let Some(tool_input) = object.get(TOOL_INPUT_FIELD) else { - return Ok(false); - }; - if tool_input.is_null() { - return Ok(false); - } - let tool_input = tool_input.as_object().ok_or_else(|| { - anyhow!(validation_error(&format!( - "field '{TOOL_INPUT_FIELD}' must be a JSON object" - ))) - })?; - - match tool_input.get(RUN_IN_BACKGROUND_FIELD) { - None | Some(Value::Null) => Ok(false), - Some(Value::Bool(value)) => Ok(*value), - Some(_) => bail!(validation_error(&format!( - "field '{TOOL_INPUT_FIELD}.{RUN_IN_BACKGROUND_FIELD}' must be a boolean" - ))), - } -} - -fn parse_session_identity(object: &Map) -> Result { - Ok(ClaudeSessionIdentity { - session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, - cwd: required_non_blank_str(object, CWD_FIELD)?, - }) -} - -fn parse_agent_identity(object: &Map) -> Result { - Ok(ClaudeAgentIdentity { - session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, - cwd: required_non_blank_str(object, CWD_FIELD)?, - agent_id: required_non_blank_str(object, AGENT_ID_FIELD)?, - }) -} - -fn parse_worktree_remove(object: &Map) -> Result { - Ok(ClaudeWorktreeRemove { - session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, - worktree_path: required_non_blank_str(object, WORKTREE_PATH_FIELD)?, - }) -} - -fn required_field<'a>(object: &'a Map, field: &str) -> Result<&'a Value> { - object.get(field).ok_or_else(|| { - anyhow!(validation_error(&format!( - "missing required field '{field}'" - ))) - }) -} - -fn required_str(object: &Map, field: &str) -> Result { - required_field(object, field)? - .as_str() - .map(str::to_owned) - .ok_or_else(|| { - anyhow!(validation_error(&format!( - "field '{field}' must be a string" - ))) - }) -} - -fn required_non_blank_str(object: &Map, field: &str) -> Result { - let value = required_str(object, field)?; - if value.trim().is_empty() { - bail!(validation_error(&format!( - "field '{field}' must be a non-blank string" - ))); - } - Ok(value) -} - -fn optional_non_blank_str(object: &Map, field: &str) -> Result> { - match object.get(field) { - None | Some(Value::Null) => Ok(None), - Some(Value::String(value)) => { - if value.trim().is_empty() { - bail!(validation_error(&format!( - "field '{field}' must be null, absent, or a non-blank string" - ))); - } - Ok(Some(value.clone())) - } - Some(_) => bail!(validation_error(&format!( - "field '{field}' must be null, absent, or a non-blank string" - ))), - } -} - -fn validation_error(detail: &str) -> String { - format!("Invalid Claude hook event payload from STDIN: {detail}.") -} - -type GitDirResolver<'a> = &'a dyn Fn(&str) -> Result; - -type IngressSeam<'a> = &'a dyn Fn(&Path, &str, Option<&dyn Logger>) -> Result; - -const ACTOR_KIND_CLAUDE_CODE: &str = "claude_code"; - -const FAIL_CLOSED_DENY_REASON: &str = - "SCE could not establish mutation attribution for this tool execution."; -const EXPLICIT_BACKGROUND_SHELL_DENY_REASON: &str = - "SCE mutation attribution does not yet support detached background shell execution. Run this command in the foreground."; - -const PRE_TOOL_USE_FAIL_CLOSED_EVENT: &str = - "sce.hooks.claude_mutation_scope.pre_tool_use_fail_closed"; -const MODEL_STATE_UNAVAILABLE_EVENT: &str = - "sce.hooks.claude_mutation_scope.model_state_unavailable"; - -type ClaudeModelStateResolver<'a> = &'a dyn Fn(&Path, &str, &str) -> Result>; - -fn log_pre_tool_use_fail_closed(logger: Option<&dyn Logger>, context: &str, error: &anyhow::Error) { - if let Some(log) = logger { - log.warn( - PRE_TOOL_USE_FAIL_CLOSED_EVENT, - &error.to_string(), - &[("context", context)], - None, - ); - } -} - -pub(crate) fn run_claude_mutation_scope_subcommand(logger: Option<&dyn Logger>) -> Result { - let stdin_payload = super::read_hook_stdin()?; - run_claude_mutation_scope_from_payload(&stdin_payload, logger) -} - -pub(crate) fn run_claude_mutation_scope_from_payload( - stdin_payload: &str, - logger: Option<&dyn Logger>, -) -> Result { - let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); - let model_state_resolver = - |repository_root: &Path, session_id: &str, agent_id: &str| -> Result> { - let db = super::open_agent_trace_db_for_hook_runtime( - repository_root, - "Failed to open Agent Trace DB for Claude mutation-scope model resolution.", - )?; - Ok(db - .claude_model_state_by_session_and_agent(session_id, agent_id)? - .map(|state| state.model_id)) - }; - let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { - super::mutation_scope::run_mutation_scope_from_payload(repository_root, payload, logger) - }; - - run_claude_mutation_scope_from_payload_with_resolver( - stdin_payload, - logger, - &resolve_git_dir_fn, - &model_state_resolver, - &seam_fn, - ) -} +#[allow(unused_imports)] +use serde_json::{json, Map, Value}; +#[allow(unused_imports)] +pub(crate) use events::{ + classify_tool, claude_scope_close_event_id, claude_scope_start_event_id, + format_claude_scope_id, is_explicit_background_shell, parse_claude_hook_event, AttemptKey, + ClaudeAgentIdentity, ClaudeHookEvent, ClaudeSessionIdentity, ClaudeToolExecution, + ClaudeToolIdentity, ClaudeWorktreeRemove, ToolClassification, +}; +#[allow(unused_imports)] +use events::{ + AGENT_ID_FIELD, AGENT_TYPE_FIELD, CWD_FIELD, HOOK_EVENT_NAME_FIELD, + HOOK_EVENT_PERMISSION_DENIED, HOOK_EVENT_POST_TOOL_USE, HOOK_EVENT_POST_TOOL_USE_FAILURE, + HOOK_EVENT_PRE_TOOL_USE, HOOK_EVENT_SESSION_END, HOOK_EVENT_SESSION_START, HOOK_EVENT_STOP, + HOOK_EVENT_STOP_FAILURE, HOOK_EVENT_SUBAGENT_START, HOOK_EVENT_SUBAGENT_STOP, + HOOK_EVENT_USER_PROMPT_SUBMIT, HOOK_EVENT_WORKTREE_REMOVE, PROMPT_ID_FIELD, + RUN_IN_BACKGROUND_FIELD, SESSION_ID_FIELD, TOOL_INPUT_FIELD, TOOL_NAME_FIELD, + TOOL_USE_ID_FIELD, WORKTREE_PATH_FIELD, +}; +#[allow(unused_imports)] +pub(crate) use health::{assess_repairability, Repairability}; #[cfg(test)] -pub(crate) fn run_claude_mutation_scope_from_payload_at_state_root( - state_root: &Path, - stdin_payload: &str, - logger: Option<&dyn Logger>, -) -> Result { - let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); - let model_state_root = state_root.to_path_buf(); - let seam_state_root = state_root.to_path_buf(); - let model_state_resolver = - move |repository_root: &Path, session_id: &str, agent_id: &str| -> Result> { - let db = super::open_agent_trace_db_for_hook_runtime_at_state_root( - repository_root, - &model_state_root, - "Failed to open Agent Trace DB for Claude mutation-scope model resolution.", - )?; - Ok(db - .claude_model_state_by_session_and_agent(session_id, agent_id)? - .map(|state| state.model_id)) - }; - let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { - super::mutation_scope::run_mutation_scope_from_payload_at_state_root( - repository_root, - &seam_state_root, - payload, - logger, - ) - }; - - run_claude_mutation_scope_from_payload_with_resolver( - stdin_payload, - logger, - &resolve_git_dir_fn, - &model_state_resolver, - &seam_fn, - ) -} - +pub(crate) use lifecycle::run_claude_mutation_scope_from_payload_at_state_root; #[cfg(test)] -fn run_claude_mutation_scope_from_payload_with( - stdin_payload: &str, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - seam: IngressSeam, -) -> Result { - let unavailable_model_state = |_repository_root: &Path, - _session_id: &str, - _agent_id: &str| - -> Result> { Ok(None) }; - run_claude_mutation_scope_from_payload_with_resolver( - stdin_payload, - logger, - resolve_git_dir, - &unavailable_model_state, - seam, - ) -} - -fn run_claude_mutation_scope_from_payload_with_resolver( - stdin_payload: &str, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - model_state_resolver: ClaudeModelStateResolver, - seam: IngressSeam, -) -> Result { - let event = parse_claude_hook_event(stdin_payload)?; - dispatch_claude_hook_event(event, logger, resolve_git_dir, model_state_resolver, seam) -} - -fn dispatch_claude_hook_event( - event: ClaudeHookEvent, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - model_state_resolver: ClaudeModelStateResolver, - seam: IngressSeam, -) -> Result { - match event { - ClaudeHookEvent::PreToolUse(execution) => Ok(handle_pre_tool_use( - &execution, - logger, - resolve_git_dir, - model_state_resolver, - seam, - )), - ClaudeHookEvent::PostToolUse(identity) | ClaudeHookEvent::PostToolUseFailure(identity) => { - let git_dir = resolve_git_dir(&identity.cwd)?; - let repository_root = Path::new(&identity.cwd); - handle_close( - &git_dir, - repository_root, - &identity.attempt_key(), - logger, - seam, - ) - } - ClaudeHookEvent::PermissionDenied(identity) => { - let git_dir = resolve_git_dir(&identity.cwd)?; - let repository_root = Path::new(&identity.cwd); - handle_permission_denied( - &git_dir, - repository_root, - &identity.attempt_key(), - logger, - seam, - ) - } - ClaudeHookEvent::Stop(session) | ClaudeHookEvent::StopFailure(session) => { - let git_dir = resolve_git_dir(&session.cwd)?; - let repository_root = Path::new(&session.cwd); - let session_id = session.session_id.clone(); - cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { - attempt.session_id == session_id && attempt.agent_id.is_none() - }) - } - ClaudeHookEvent::UserPromptSubmit(session) => { - let git_dir = resolve_git_dir(&session.cwd)?; - let repository_root = Path::new(&session.cwd); - let session_id = session.session_id.clone(); - cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { - attempt.session_id == session_id && attempt.agent_id.is_none() - }) - } - ClaudeHookEvent::SubagentStop(agent) => { - let git_dir = resolve_git_dir(&agent.cwd)?; - let repository_root = Path::new(&agent.cwd); - let session_id = agent.session_id.clone(); - let agent_id = agent.agent_id.clone(); - cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { - attempt.session_id == session_id && attempt.agent_id.as_deref() == Some(&agent_id) - }) - } - ClaudeHookEvent::SessionEnd(session) => { - let git_dir = resolve_git_dir(&session.cwd)?; - let repository_root = Path::new(&session.cwd); - let session_id = session.session_id.clone(); - cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { - attempt.session_id == session_id - }) - } - ClaudeHookEvent::WorktreeRemove(worktree_remove) => { - let git_dir = resolve_git_dir(&worktree_remove.worktree_path)?; - let repository_root = Path::new(&worktree_remove.worktree_path); - cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |_attempt| true) - } - ClaudeHookEvent::SessionStart | ClaudeHookEvent::SubagentStart => Ok(String::new()), - } -} - -fn handle_pre_tool_use( - execution: &ClaudeToolExecution, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - model_state_resolver: ClaudeModelStateResolver, - seam: IngressSeam, -) -> String { - let identity = &execution.identity; - - if matches!( - classify_tool(&identity.tool_name), - ToolClassification::ReadOnly | ToolClassification::Delegation - ) { - return String::new(); - } - - if is_explicit_background_shell(&identity.tool_name, execution.run_in_background) { - return pre_tool_use_deny_json(EXPLICIT_BACKGROUND_SHELL_DENY_REASON); - } - - let repository_root = Path::new(&identity.cwd); - let git_dir = match resolve_git_dir(&identity.cwd) { - Ok(git_dir) => git_dir, - Err(error) => { - log_pre_tool_use_fail_closed(logger, "resolve_git_dir", &error); - return pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON); - } - }; - - if matches!( - apply_recovery_barrier(&git_dir, repository_root, logger, seam), - BarrierOutcome::Deny - ) { - return pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON); - } - - match establish_start( - &git_dir, - repository_root, - identity, - logger, - model_state_resolver, - seam, - ) { - Ok(()) => String::new(), - Err(error) => { - log_pre_tool_use_fail_closed(logger, "establish_start", &error); - pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON) - } - } -} - -enum BarrierOutcome { - Proceed, - Deny, -} - -fn apply_recovery_barrier( - git_dir: &Path, - repository_root: &Path, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> BarrierOutcome { - let state = match state::read_state(git_dir) { - Ok(state) => state, - Err(error) => { - log_pre_tool_use_fail_closed(logger, "recovery_barrier.read_state", &error); - return BarrierOutcome::Deny; - } - }; - - if !state.recovery_pending { - return BarrierOutcome::Proceed; - } - - if !state.attempts.is_empty() { - return BarrierOutcome::Deny; - } - - match seam(repository_root, &flush_payload(), logger) { - Ok(_) => match state::clear_recovery_pending(git_dir) { - Ok(()) => BarrierOutcome::Proceed, - Err(error) => { - log_pre_tool_use_fail_closed( - logger, - "recovery_barrier.clear_recovery_pending", - &error, - ); - BarrierOutcome::Deny - } - }, - Err(error) => { - log_pre_tool_use_fail_closed(logger, "recovery_barrier.flush", &error); - BarrierOutcome::Deny - } - } -} - -fn establish_start( - git_dir: &Path, - repository_root: &Path, - identity: &ClaudeToolIdentity, - logger: Option<&dyn Logger>, - model_state_resolver: ClaudeModelStateResolver, - seam: IngressSeam, -) -> Result<()> { - let allocated = state::allocate_attempt(git_dir, &identity.attempt_key(), &identity.tool_name)?; - let scope_id = &allocated.attempt.scope_id; - let canonical_session_id = - super::prefixed_diff_trace_session_id(super::CLAUDE_TOOL_NAME, &identity.session_id); - let agent_id = identity.agent_id.as_deref().unwrap_or(""); - let model_id = match model_state_resolver(repository_root, &canonical_session_id, agent_id) { - Ok(model_id) => model_id.and_then(|model| super::normalize_claude_model_id(&model)), - Err(error) => { - if let Some(log) = logger { - log.warn( - MODEL_STATE_UNAVAILABLE_EVENT, - &error.to_string(), - &[("agent_id", agent_id)], - Some(&canonical_session_id), - ); - } - None - } - }; - let start_payload = scope_start_payload( - scope_id, - &claude_scope_start_event_id(scope_id), - &canonical_session_id, - model_id.as_deref(), - ); - - seam(repository_root, &start_payload, logger)?; - state::mark_active(git_dir, scope_id)?; - Ok(()) -} - -fn handle_close( - git_dir: &Path, - repository_root: &Path, - key: &AttemptKey, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result { - let current = state::read_state(git_dir)?; - let Some(attempt) = current - .attempts - .iter() - .find(|attempt| attempt_matches_key(attempt, key)) - .cloned() - else { - return Ok(String::new()); - }; - - if attempt.phase == state::AttemptPhase::PendingStart { - abandon_attempt(git_dir, repository_root, &attempt, logger, seam)?; - return Ok(String::new()); - } - - let close_payload = scope_boundary_payload( - "close", - &attempt.scope_id, - &claude_scope_close_event_id(&attempt.scope_id), - ); - - if seam(repository_root, &close_payload, logger).is_ok() { - state::remove_attempt(git_dir, &attempt.scope_id)?; - } else { - abandon_attempt(git_dir, repository_root, &attempt, logger, seam)?; - } - Ok(String::new()) -} - -fn handle_permission_denied( - git_dir: &Path, - repository_root: &Path, - key: &AttemptKey, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result { - let current = state::read_state(git_dir)?; - let Some(attempt) = current - .attempts - .iter() - .find(|attempt| attempt_matches_key(attempt, key)) - .cloned() - else { - return Ok(String::new()); - }; - - abandon_attempt(git_dir, repository_root, &attempt, logger, seam)?; - Ok(String::new()) -} - -fn cleanup_attempts_matching( - git_dir: &Path, - repository_root: &Path, - logger: Option<&dyn Logger>, - seam: IngressSeam, - predicate: impl Fn(&state::AdapterAttempt) -> bool, -) -> Result { - let current = state::read_state(git_dir)?; - let stale: Vec = current - .attempts - .into_iter() - .filter(|attempt| predicate(attempt)) - .collect(); - - for attempt in &stale { - abandon_attempt(git_dir, repository_root, attempt, logger, seam)?; - } - - Ok(String::new()) -} - -fn attempt_matches_key(attempt: &state::AdapterAttempt, key: &AttemptKey) -> bool { - attempt.session_id == key.session_id - && attempt.agent_id == key.agent_id - && attempt.tool_use_id == key.tool_use_id -} - -fn abandon_attempt( - git_dir: &Path, - repository_root: &Path, - attempt: &state::AdapterAttempt, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result<()> { - state::mark_recovery_pending(git_dir)?; - - seam(repository_root, &abandon_payload(&attempt.scope_id), logger)?; - state::remove_attempt(git_dir, &attempt.scope_id)?; - Ok(()) -} - -fn scope_boundary_payload(operation: &str, scope_id: &str, event_id: &str) -> String { - json!({ - "operation": operation, - "scope_id": scope_id, - "event_id": event_id, - "actor_kind": ACTOR_KIND_CLAUDE_CODE, - }) - .to_string() -} - -fn scope_start_payload( - scope_id: &str, - event_id: &str, - session_id: &str, - model_id: Option<&str>, -) -> String { - json!({ - "operation": "start", - "scope_id": scope_id, - "event_id": event_id, - "actor_kind": ACTOR_KIND_CLAUDE_CODE, - "provenance": { - "session_id": session_id, - "model_id": model_id, - }, - }) - .to_string() -} - -fn abandon_payload(scope_id: &str) -> String { - json!({ - "operation": "abandon", - "scope_id": scope_id, - }) - .to_string() -} - -fn flush_payload() -> String { - json!({ "operation": "flush" }).to_string() -} - -fn pre_tool_use_deny_json(reason: &str) -> String { - json!({ - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "deny", - "permissionDecisionReason": reason, - } - }) - .to_string() -} - +#[allow(unused_imports)] +use lifecycle::ClaudeModelStateResolver; +#[allow(unused_imports)] +use lifecycle::{ + abandon_attempt, apply_recovery_barrier, cleanup_attempts_matching, BarrierOutcome, + GitDirResolver, IngressSeam, EXPLICIT_BACKGROUND_SHELL_DENY_REASON, FAIL_CLOSED_DENY_REASON, +}; +#[allow(unused_imports)] +pub(crate) use lifecycle::{repair_blocked, RepairOutcome}; +#[allow(unused_imports)] +pub(crate) use lifecycle::{ + run_claude_mutation_scope_from_payload, run_claude_mutation_scope_subcommand, +}; #[cfg(test)] -mod tests { - use super::*; - - fn pre_tool_use_json(overrides: &[(&str, Value)]) -> String { - let mut object = serde_json::Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(HOOK_EVENT_PRE_TOOL_USE.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String("session-1".to_string()), - ); - object.insert( - CWD_FIELD.to_string(), - Value::String("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/repo/checkout".to_string()), - ); - object.insert( - TOOL_NAME_FIELD.to_string(), - Value::String("Write".to_string()), - ); - object.insert( - TOOL_USE_ID_FIELD.to_string(), - Value::String("toolu_1".to_string()), - ); - for (field, value) in overrides { - object.insert((*field).to_string(), value.clone()); - } - Value::Object(object).to_string() - } - - fn identity(session_id: &str, agent_id: Option<&str>, tool_use_id: &str) -> AttemptKey { - AttemptKey { - session_id: session_id.to_string(), - agent_id: agent_id.map(str::to_string), - tool_use_id: tool_use_id.to_string(), - } - } - - #[test] - fn pre_tool_use_parses_required_and_optional_fields() { - let payload = pre_tool_use_json(&[ - (AGENT_ID_FIELD, Value::String("agent-1".to_string())), - (PROMPT_ID_FIELD, Value::String("prompt-1".to_string())), - ( - AGENT_TYPE_FIELD, - Value::String("general-purpose".to_string()), - ), - ]); - - let event = parse_claude_hook_event(&payload).expect("valid PreToolUse parses"); - let ClaudeHookEvent::PreToolUse(execution) = event else { - panic!("expected PreToolUse"); - }; - - assert_eq!(execution.identity.session_id, "session-1"); - assert_eq!(execution.identity.cwd, "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/repo/checkout"); - assert_eq!(execution.identity.tool_name, "Write"); - assert_eq!(execution.identity.tool_use_id, "toolu_1"); - assert_eq!(execution.identity.agent_id.as_deref(), Some("agent-1")); - assert_eq!(execution.prompt_id.as_deref(), Some("prompt-1")); - assert_eq!(execution.agent_type.as_deref(), Some("general-purpose")); - assert!(!execution.run_in_background); - } - - #[test] - fn pre_tool_use_agent_id_absent_means_main_thread() { - let payload = pre_tool_use_json(&[]); - - let event = parse_claude_hook_event(&payload).unwrap(); - let ClaudeHookEvent::PreToolUse(execution) = event else { - panic!("expected PreToolUse"); - }; - - assert_eq!(execution.identity.agent_id, None); - assert!(!execution.identity.is_subagent()); - } - - #[test] - fn pre_tool_use_agent_id_present_means_subagent() { - let payload = pre_tool_use_json(&[(AGENT_ID_FIELD, Value::String("agent-1".to_string()))]); - - let event = parse_claude_hook_event(&payload).unwrap(); - let ClaudeHookEvent::PreToolUse(execution) = event else { - panic!("expected PreToolUse"); - }; - - assert!(execution.identity.is_subagent()); - } - - #[test] - fn pre_tool_use_prompt_id_and_agent_type_are_optional() { - let payload = pre_tool_use_json(&[]); - - let event = parse_claude_hook_event(&payload).unwrap(); - let ClaudeHookEvent::PreToolUse(execution) = event else { - panic!("expected PreToolUse"); - }; - - assert_eq!(execution.prompt_id, None); - assert_eq!(execution.agent_type, None); - } - - #[test] - fn missing_required_fields_are_rejected_without_fabricating_identity() { - for field in [ - SESSION_ID_FIELD, - CWD_FIELD, - TOOL_NAME_FIELD, - TOOL_USE_ID_FIELD, - ] { - let mut object: serde_json::Map = - serde_json::from_str(&pre_tool_use_json(&[])).unwrap(); - object.remove(field); - let payload = Value::Object(object).to_string(); - - let error = parse_claude_hook_event(&payload).unwrap_err(); - assert!( - error.to_string().contains(&format!("'{field}'")), - "expected missing-field error to name '{field}', got: {error}" - ); - } - } - - #[test] - fn wrong_type_required_field_is_rejected() { - let payload = pre_tool_use_json(&[(SESSION_ID_FIELD, Value::from(42))]); - - let error = parse_claude_hook_event(&payload).unwrap_err(); - assert!(error.to_string().contains("session_id")); - } - - #[test] - fn empty_string_required_field_is_rejected() { - let payload = pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String(String::new()))]); - - let error = parse_claude_hook_event(&payload).unwrap_err(); - assert!(error.to_string().contains("non-blank")); - } - - #[test] - fn wrong_type_optional_field_is_rejected() { - let payload = pre_tool_use_json(&[(PROMPT_ID_FIELD, Value::from(1))]); - - let error = parse_claude_hook_event(&payload).unwrap_err(); - assert!(error.to_string().contains("prompt_id")); - } - - #[test] - fn empty_optional_field_is_rejected() { - let payload = pre_tool_use_json(&[(AGENT_ID_FIELD, Value::String(String::new()))]); - - let error = parse_claude_hook_event(&payload).unwrap_err(); - assert!(error.to_string().contains("agent_id")); - } - - #[test] - fn null_optional_field_is_none() { - let payload = pre_tool_use_json(&[(AGENT_ID_FIELD, Value::Null)]); - - let event = parse_claude_hook_event(&payload).unwrap(); - let ClaudeHookEvent::PreToolUse(execution) = event else { - panic!("expected PreToolUse"); - }; - assert_eq!(execution.identity.agent_id, None); - } - - #[test] - fn empty_payload_is_rejected() { - let error = parse_claude_hook_event("").unwrap_err(); - assert!(error.to_string().contains("empty payload")); - - let error = parse_claude_hook_event(" ").unwrap_err(); - assert!(error.to_string().contains("empty payload")); - } - - #[test] - fn malformed_json_is_rejected() { - let error = parse_claude_hook_event("{not json").unwrap_err(); - assert!(error.to_string().contains("valid JSON")); - } - - #[test] - fn non_object_json_is_rejected() { - let error = parse_claude_hook_event("[1, 2, 3]").unwrap_err(); - assert!(error.to_string().contains("JSON object")); - } - - #[test] - fn unsupported_hook_event_name_is_rejected() { - let payload = pre_tool_use_json(&[( - HOOK_EVENT_NAME_FIELD, - Value::String("PostToolBatch".to_string()), - )]); - - let error = parse_claude_hook_event(&payload).unwrap_err(); - assert!(error.to_string().contains("unsupported hook_event_name")); - } - - #[test] - fn post_tool_use_and_failure_and_permission_denied_share_tool_identity_shape() { - for event_name in [ - HOOK_EVENT_POST_TOOL_USE, - HOOK_EVENT_POST_TOOL_USE_FAILURE, - HOOK_EVENT_PERMISSION_DENIED, - ] { - let payload = pre_tool_use_json(&[( - HOOK_EVENT_NAME_FIELD, - Value::String(event_name.to_string()), - )]); - - let event = parse_claude_hook_event(&payload).unwrap(); - let identity = match event { - ClaudeHookEvent::PostToolUse(identity) - | ClaudeHookEvent::PostToolUseFailure(identity) - | ClaudeHookEvent::PermissionDenied(identity) => identity, - other => panic!("expected a tool-identity event, got {other:?}"), - }; - assert_eq!(identity.session_id, "session-1"); - assert_eq!(identity.tool_use_id, "toolu_1"); - } - } - - #[test] - fn session_scoped_lifecycle_events_parse_session_identity() { - for event_name in [ - HOOK_EVENT_STOP, - HOOK_EVENT_STOP_FAILURE, - HOOK_EVENT_USER_PROMPT_SUBMIT, - HOOK_EVENT_SESSION_END, - ] { - let mut object = serde_json::Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(event_name.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String("session-1".to_string()), - ); - object.insert(CWD_FIELD.to_string(), Value::String("/repo".to_string())); - let payload = Value::Object(object).to_string(); - - let event = parse_claude_hook_event(&payload).unwrap(); - let identity = match event { - ClaudeHookEvent::Stop(identity) - | ClaudeHookEvent::StopFailure(identity) - | ClaudeHookEvent::UserPromptSubmit(identity) - | ClaudeHookEvent::SessionEnd(identity) => identity, - other => panic!("expected a session-identity event, got {other:?}"), - }; - assert_eq!(identity.session_id, "session-1"); - assert_eq!(identity.cwd, "/repo"); - } - } - - #[test] - fn subagent_stop_requires_agent_id() { - let mut object = serde_json::Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(HOOK_EVENT_SUBAGENT_STOP.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String("session-1".to_string()), - ); - object.insert(CWD_FIELD.to_string(), Value::String("/repo".to_string())); - let payload = Value::Object(object).to_string(); - - let error = parse_claude_hook_event(&payload).unwrap_err(); - assert!(error.to_string().contains("agent_id")); - } - - #[test] - fn subagent_stop_parses_agent_identity() { - let mut object = serde_json::Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(HOOK_EVENT_SUBAGENT_STOP.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String("session-1".to_string()), - ); - object.insert(CWD_FIELD.to_string(), Value::String("/repo".to_string())); - object.insert( - AGENT_ID_FIELD.to_string(), - Value::String("agent-1".to_string()), - ); - let payload = Value::Object(object).to_string(); - - let event = parse_claude_hook_event(&payload).unwrap(); - let ClaudeHookEvent::SubagentStop(identity) = event else { - panic!("expected SubagentStop"); - }; - assert_eq!(identity.agent_id, "agent-1"); - } - - #[test] - fn worktree_remove_requires_worktree_path_not_cwd() { - let mut object = serde_json::Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(HOOK_EVENT_WORKTREE_REMOVE.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String("session-1".to_string()), - ); - object.insert( - WORKTREE_PATH_FIELD.to_string(), - Value::String("/repo/.claude/worktrees/agent-1".to_string()), - ); - let payload = Value::Object(object).to_string(); - - let event = parse_claude_hook_event(&payload).unwrap(); - let ClaudeHookEvent::WorktreeRemove(worktree_remove) = event else { - panic!("expected WorktreeRemove"); - }; - assert_eq!(worktree_remove.session_id, "session-1"); - assert_eq!( - worktree_remove.worktree_path, - "/repo/.claude/worktrees/agent-1" - ); - } - - #[test] - fn session_start_and_subagent_start_establish_no_scope_payload() { - for event_name in [HOOK_EVENT_SESSION_START, HOOK_EVENT_SUBAGENT_START] { - let mut object = serde_json::Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(event_name.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String("session-1".to_string()), - ); - let payload = Value::Object(object).to_string(); - - let event = parse_claude_hook_event(&payload).unwrap(); - assert!(matches!( - event, - ClaudeHookEvent::SessionStart | ClaudeHookEvent::SubagentStart - )); - } - } - - #[test] - fn run_in_background_true_is_parsed() { - let payload = pre_tool_use_json(&[( - TOOL_INPUT_FIELD, - serde_json::json!({ "run_in_background": true }), - )]); - - let event = parse_claude_hook_event(&payload).unwrap(); - let ClaudeHookEvent::PreToolUse(execution) = event else { - panic!("expected PreToolUse"); - }; - assert!(execution.run_in_background); - } - - #[test] - fn run_in_background_false_is_parsed() { - let payload = pre_tool_use_json(&[( - TOOL_INPUT_FIELD, - serde_json::json!({ "run_in_background": false }), - )]); - - let event = parse_claude_hook_event(&payload).unwrap(); - let ClaudeHookEvent::PreToolUse(execution) = event else { - panic!("expected PreToolUse"); - }; - assert!(!execution.run_in_background); - } - - #[test] - fn run_in_background_absent_defaults_to_false() { - let payload = pre_tool_use_json(&[( - TOOL_INPUT_FIELD, - serde_json::json!({ "command": "echo hi" }), - )]); - - let event = parse_claude_hook_event(&payload).unwrap(); - let ClaudeHookEvent::PreToolUse(execution) = event else { - panic!("expected PreToolUse"); - }; - assert!(!execution.run_in_background); - } - - #[test] - fn tool_input_absent_defaults_run_in_background_to_false() { - let mut object: serde_json::Map = - serde_json::from_str(&pre_tool_use_json(&[])).unwrap(); - object.remove(TOOL_INPUT_FIELD); - let payload = Value::Object(object).to_string(); - - let event = parse_claude_hook_event(&payload).unwrap(); - let ClaudeHookEvent::PreToolUse(execution) = event else { - panic!("expected PreToolUse"); - }; - assert!(!execution.run_in_background); - } - - #[test] - fn run_in_background_wrong_type_is_rejected() { - let payload = pre_tool_use_json(&[( - TOOL_INPUT_FIELD, - serde_json::json!({ "run_in_background": "yes" }), - )]); - - let error = parse_claude_hook_event(&payload).unwrap_err(); - assert!(error.to_string().contains("run_in_background")); - } - - #[test] - fn tool_input_wrong_type_is_rejected() { - let payload = pre_tool_use_json(&[(TOOL_INPUT_FIELD, Value::String("nope".to_string()))]); - - let error = parse_claude_hook_event(&payload).unwrap_err(); - assert!(error.to_string().contains("tool_input")); - } - - #[test] - fn known_mutation_capable_tools_are_classified_mutation_capable() { - for tool_name in [ - "Bash", - "PowerShell", - "Write", - "Edit", - "NotebookEdit", - "MultiEdit", - ] { - assert_eq!( - classify_tool(tool_name), - ToolClassification::MutationCapable, - "expected {tool_name} to be MutationCapable" - ); - } - } - - #[test] - fn known_read_only_tools_are_classified_read_only() { - for tool_name in [ - "Read", - "Glob", - "Grep", - "WebFetch", - "WebSearch", - "AskUserQuestion", - ] { - assert_eq!( - classify_tool(tool_name), - ToolClassification::ReadOnly, - "expected {tool_name} to be ReadOnly" - ); - } - } - - #[test] - fn agent_is_classified_delegation() { - assert_eq!(classify_tool("Agent"), ToolClassification::Delegation); - } - - #[test] - fn mcp_tools_are_classified_mutation_capable() { - assert_eq!( - classify_tool("mcp__claude-in-chrome__navigate"), - ToolClassification::MutationCapable - ); - } - - #[test] - fn unknown_tool_names_are_conservatively_mutation_capable() { - assert_eq!( - classify_tool("SomeBrandNewTool"), - ToolClassification::MutationCapable - ); - } - - const PROBE14_BASH_RUN_IN_BACKGROUND_TRUE: &str = - include_str!("fixtures/probe14-run-in-background-true.pre_tool_use.json"); - const PROBE15_BASH_RUN_IN_BACKGROUND_FALSE: &str = - include_str!("fixtures/probe15-run-in-background-false-hard-gate.pre_tool_use.json"); - - fn parsed_pre_tool_use(payload: &str) -> ClaudeToolExecution { - let event = parse_claude_hook_event(payload).unwrap(); - let ClaudeHookEvent::PreToolUse(execution) = event else { - panic!("expected PreToolUse"); - }; - execution - } - - #[test] - fn real_bash_run_in_background_true_fixture_is_explicit_background_shell() { - let execution = parsed_pre_tool_use(PROBE14_BASH_RUN_IN_BACKGROUND_TRUE); - - assert_eq!(execution.identity.tool_name, "Bash"); - assert!(execution.run_in_background); - assert!(is_explicit_background_shell( - &execution.identity.tool_name, - execution.run_in_background - )); - } - - #[test] - fn real_bash_run_in_background_false_fixture_is_not_explicit_background_shell() { - let execution = parsed_pre_tool_use(PROBE15_BASH_RUN_IN_BACKGROUND_FALSE); - - assert_eq!(execution.identity.tool_name, "Bash"); - assert!(!execution.run_in_background); - assert!(!is_explicit_background_shell( - &execution.identity.tool_name, - execution.run_in_background - )); - } - - #[test] - fn powershell_with_run_in_background_true_is_explicit_background_shell() { - assert!(is_explicit_background_shell("PowerShell", true)); - } - - #[test] - fn powershell_with_run_in_background_false_is_not_explicit_background_shell() { - assert!(!is_explicit_background_shell("PowerShell", false)); - } - - #[test] - fn write_with_run_in_background_true_is_not_explicit_background_shell() { - assert!(!is_explicit_background_shell("Write", true)); - } - - #[test] - fn same_attempt_seq_and_key_is_deterministic() { - let key = identity("session-1", Some("agent-1"), "toolu_1"); - - let first = format_claude_scope_id(3, &key); - let second = format_claude_scope_id(3, &key); - - assert_eq!( - first, second, - "AC4: duplicate delivery must reuse the same ScopeId" - ); - assert_eq!( - claude_scope_start_event_id(&first), - claude_scope_start_event_id(&second) - ); - } - - #[test] - fn fresh_attempt_seq_yields_a_new_scope_id() { - let key = identity("session-1", None, "toolu_1"); - - let first = format_claude_scope_id(1, &key); - let second = format_claude_scope_id(2, &key); - - assert_ne!( - first, second, - "AC5: a fresh attempt_seq for the same tool_use_id must get a new ScopeId" - ); - } - - #[test] - fn main_and_distinct_agents_produce_distinct_scope_ids() { - let main = identity("session-1", None, "toolu_1"); - let agent_a = identity("session-1", Some("A"), "toolu_1"); - let agent_b = identity("session-1", Some("B"), "toolu_1"); - - let main_scope = format_claude_scope_id(1, &main); - let scope_for_a = format_claude_scope_id(1, &agent_a); - let scope_for_b = format_claude_scope_id(1, &agent_b); - - assert_ne!( - main_scope, scope_for_a, - "AC6: main vs agent_id=A must differ" - ); - assert_ne!( - main_scope, scope_for_b, - "AC6: main vs agent_id=B must differ" - ); - assert_ne!( - scope_for_a, scope_for_b, - "AC6: agent_id=A vs agent_id=B must differ" - ); - } - - #[test] - fn event_id_derivation_is_a_pure_function_of_scope_id() { - let scope_id = format_claude_scope_id(7, &identity("session-1", None, "toolu_1")); - - assert_eq!( - claude_scope_start_event_id(&scope_id), - format!("{scope_id}|start") - ); - assert_eq!( - claude_scope_close_event_id(&scope_id), - format!("{scope_id}|close") - ); - assert_ne!( - claude_scope_start_event_id(&scope_id), - claude_scope_close_event_id(&scope_id) - ); - } - - #[test] - fn length_prefixing_disambiguates_delimiter_characters_inside_fields() { - let tricky = identity( - "sess|a=0:x|t=1:y", - Some("agent|with|pipes"), - "tool:with:colons", - ); - - let scope_id = format_claude_scope_id(1, &tricky); - - let agent_id = tricky.agent_id.as_deref().unwrap(); - let expected = format!( - "cc-tool-v1|n=1|s={}:{}|a={}:{}|t={}:{}", - tricky.session_id.len(), - tricky.session_id, - agent_id.len(), - agent_id, - tricky.tool_use_id.len(), - tricky.tool_use_id, - ); - - assert_eq!(scope_id, expected); - } - - #[test] - fn attempt_key_projects_only_the_execution_key_fields() { - let identity_a = ClaudeToolIdentity { - session_id: "session-1".to_string(), - cwd: "/repo".to_string(), - agent_id: Some("agent-1".to_string()), - tool_name: "Write".to_string(), - tool_use_id: "toolu_1".to_string(), - }; - let identity_b = ClaudeToolIdentity { - tool_name: "Bash".to_string(), - cwd: "/other".to_string(), - ..identity_a.clone() - }; - - assert_eq!( - identity_a.attempt_key(), - identity_b.attempt_key(), - "attempt_key must depend only on (session_id, agent_id, tool_use_id)" - ); - } - - mod driver { - use std::cell::{Cell, RefCell}; - use std::path::PathBuf; - use std::sync::atomic::{AtomicU64, Ordering}; - use std::sync::{Arc, Mutex}; - - use anyhow::anyhow; - - use super::*; - - static NEXT_TEST_GIT_DIR_ID: AtomicU64 = AtomicU64::new(0); - - fn unique_test_git_dir(label: &str) -> PathBuf { - let id = NEXT_TEST_GIT_DIR_ID.fetch_add(1, Ordering::Relaxed); - std::env::temp_dir().join(format!( - "sce-claude-mutation-scope-driver-{label}-{}-{id}", - std::process::id() - )) - } - - fn remove_test_git_dir(git_dir: &Path) { - let _ = std::fs::remove_dir_all(git_dir); - } - - #[allow(clippy::unnecessary_wraps)] - fn ok_seam(_root: &Path, _payload: &str, _logger: Option<&dyn Logger>) -> Result { - Ok(String::new()) - } - - fn unreachable_seam( - _root: &Path, - payload: &str, - _logger: Option<&dyn Logger>, - ) -> Result { - panic!("the ingress seam must not be called for this payload: {payload}"); - } - - fn seam_failing_on( - operation: &'static str, - ) -> impl Fn(&Path, &str, Option<&dyn Logger>) -> Result { - seam_failing_on_any(vec![operation]) - } - - fn seam_failing_on_any( - operations: Vec<&'static str>, - ) -> impl Fn(&Path, &str, Option<&dyn Logger>) -> Result { - move |_root, payload, _logger| { - if operations - .iter() - .any(|operation| payload.contains(&format!(r#""operation":"{operation}""#))) - { - Err(anyhow!( - "seam failure injected by test for one of {operations:?}" - )) - } else { - Ok(String::new()) - } - } - } - - fn fixed_resolver(git_dir: PathBuf) -> impl Fn(&str) -> Result { - move |_cwd| Ok(git_dir.clone()) - } - - fn start_with_model_resolver( - payload: &str, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - model_state_resolver: ClaudeModelStateResolver, - seam: IngressSeam, - ) -> Result { - run_claude_mutation_scope_from_payload_with_resolver( - payload, - logger, - resolve_git_dir, - model_state_resolver, - seam, - ) - } - - #[derive(Clone, Default)] - struct RecordingLogger { - warnings: Arc>>, - } - - impl RecordingLogger { - fn warnings(&self) -> Vec<(String, String)> { - self.warnings - .lock() - .expect("recording logger mutex must not be poisoned") - .clone() - } - } - - impl Logger for RecordingLogger { - fn info(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} - fn debug(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} - - fn warn(&self, event_id: &str, message: &str, _: &[(&str, &str)], _: Option<&str>) { - self.warnings - .lock() - .expect("recording logger mutex must not be poisoned") - .push((event_id.to_string(), message.to_string())); - } - - fn error(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} - - fn log_cli_error(&self, _: &crate::services::error::CliError, _: Option<&str>) {} - } - - fn session_scoped_payload(event_name: &str, session_id: &str, cwd: &str) -> String { - let mut object = serde_json::Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(event_name.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String(session_id.to_string()), - ); - object.insert(CWD_FIELD.to_string(), Value::String(cwd.to_string())); - Value::Object(object).to_string() - } - - #[test] - fn pre_tool_use_resolves_main_and_subagent_model_state_exactly_at_admission() { - let git_dir = unique_test_git_dir("model-state-admission"); - let git_dir_resolver = fixed_resolver(git_dir.clone()); - let resolver_calls: RefCell> = RefCell::new(Vec::new()); - let model_state_resolver = |_: &Path, session_id: &str, agent_id: &str| { - resolver_calls - .borrow_mut() - .push((session_id.to_string(), agent_id.to_string())); - Ok(Some(if agent_id.is_empty() { - "claude/sonnet".to_string() - } else { - "claude/opus".to_string() - })) - }; - let starts: RefCell> = RefCell::new(Vec::new()); - let seam = - |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { - let value: Value = serde_json::from_str(payload).expect("payload is JSON"); - if value.get("operation") == Some(&Value::String("start".to_string())) { - starts.borrow_mut().push(value); - } - Ok(String::new()) - }; - - let main_payload = pre_tool_use_json(&[]); - let subagent_payload = pre_tool_use_json(&[ - ( - TOOL_USE_ID_FIELD, - Value::String("toolu_subagent".to_string()), - ), - (AGENT_ID_FIELD, Value::String("agent-1".to_string())), - ]); - start_with_model_resolver( - &main_payload, - None, - &git_dir_resolver, - &model_state_resolver, - &seam, - ) - .expect("main-agent Start should succeed"); - start_with_model_resolver( - &subagent_payload, - None, - &git_dir_resolver, - &model_state_resolver, - &seam, - ) - .expect("subagent Start should succeed"); - - assert_eq!( - resolver_calls.into_inner(), - vec![ - ("cc_session-1".to_string(), String::new()), - ("cc_session-1".to_string(), "agent-1".to_string()), - ] - ); - let starts = starts.into_inner(); - assert_eq!(starts.len(), 2); - assert_eq!( - starts[0]["provenance"], - json!({"session_id": "cc_session-1", "model_id": "claude/sonnet"}) - ); - assert_eq!( - starts[1]["provenance"], - json!({"session_id": "cc_session-1", "model_id": "claude/opus"}) - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn subagent_without_exact_model_state_does_not_inherit_main_model() { - let git_dir = unique_test_git_dir("subagent-model-state-missing"); - let git_dir_resolver = fixed_resolver(git_dir.clone()); - let starts: RefCell> = RefCell::new(Vec::new()); - let model_state_resolver = |_: &Path, _: &str, agent_id: &str| { - Ok(if agent_id.is_empty() { - Some("claude/sonnet".to_string()) - } else { - None - }) - }; - let seam = - |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { - starts - .borrow_mut() - .push(serde_json::from_str(payload).expect("payload is JSON")); - Ok(String::new()) - }; - - for (tool_use_id, agent_id) in - [("toolu_main", None), ("toolu_subagent", Some("agent-1"))] - { - let overrides = agent_id - .map(|agent_id| vec![(AGENT_ID_FIELD, Value::String(agent_id.to_string()))]) - .unwrap_or_default(); - let mut overrides = overrides; - overrides.push((TOOL_USE_ID_FIELD, Value::String(tool_use_id.to_string()))); - let payload = pre_tool_use_json(&overrides); - start_with_model_resolver( - &payload, - None, - &git_dir_resolver, - &model_state_resolver, - &seam, - ) - .expect("both Starts should succeed"); - } - - let starts = starts.into_inner(); - assert_eq!(starts.len(), 2); - assert_eq!(starts[0]["provenance"]["model_id"], "claude/sonnet"); - assert_eq!(starts[1]["provenance"]["session_id"], "cc_session-1"); - assert_eq!(starts[1]["provenance"]["model_id"], Value::Null); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn missing_or_failed_model_resolution_keeps_session_provenance_and_allows_start() { - let git_dir = unique_test_git_dir("model-state-unavailable"); - let git_dir_resolver = fixed_resolver(git_dir.clone()); - let starts: RefCell> = RefCell::new(Vec::new()); - let seam = - |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { - starts - .borrow_mut() - .push(serde_json::from_str(payload).expect("payload is JSON")); - Ok(String::new()) - }; - let response_index = Cell::new(0); - let model_state_resolver = |_: &Path, _: &str, _: &str| -> Result> { - let index = response_index.get(); - response_index.set(index + 1); - if index == 0 { - Ok(None) - } else { - Err(anyhow!("local model-state DB is unavailable")) - } - }; - - for tool_use_id in ["toolu_missing", "toolu_failed"] { - let payload = pre_tool_use_json(&[( - TOOL_USE_ID_FIELD, - Value::String(tool_use_id.to_string()), - )]); - let output = start_with_model_resolver( - &payload, - None, - &git_dir_resolver, - &model_state_resolver, - &seam, - ) - .expect("model unavailability must not fail Start"); - assert_eq!(output, ""); - } - - let starts = starts.into_inner(); - assert_eq!(starts.len(), 2); - for start in starts { - assert_eq!( - start["provenance"], - json!({"session_id": "cc_session-1", "model_id": null}) - ); - } - - remove_test_git_dir(&git_dir); - } - - #[test] - fn read_only_tool_creates_no_scope_and_never_touches_the_seam_or_git_dir() { - let resolver = |_: &str| -> Result { - panic!("a read-only tool must never resolve a git dir") - }; - let payload = - pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String("Read".to_string()))]); - - let output = run_claude_mutation_scope_from_payload_with( - &payload, - None, - &resolver, - &unreachable_seam, - ) - .expect("read-only PreToolUse should succeed"); - - assert_eq!(output, ""); - } - - #[test] - fn delegation_tool_creates_no_scope_ac3() { - let resolver = |_: &str| -> Result { - panic!("Agent delegation must never resolve a git dir") - }; - let payload = - pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String("Agent".to_string()))]); - - let output = run_claude_mutation_scope_from_payload_with( - &payload, - None, - &resolver, - &unreachable_seam, - ) - .expect("Agent delegation PreToolUse should succeed"); - - assert_eq!(output, ""); - } - - #[test] - fn session_start_and_subagent_start_establish_no_scope_ac3() { - let resolver = |_: &str| -> Result { - panic!("a lifecycle-only event must never resolve a git dir") - }; - - for event_name in [HOOK_EVENT_SESSION_START, HOOK_EVENT_SUBAGENT_START] { - let mut object = serde_json::Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(event_name.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String("session-1".to_string()), - ); - let payload = Value::Object(object).to_string(); - - let output = run_claude_mutation_scope_from_payload_with( - &payload, - None, - &resolver, - &unreachable_seam, - ) - .expect("a lifecycle-only event should succeed with no scope"); - assert_eq!(output, ""); - } - } - - #[test] - fn explicit_background_bash_is_denied_with_the_exact_reason_d20() { - let git_dir = unique_test_git_dir("explicit-background-bash"); - let resolver = fixed_resolver(git_dir.clone()); - let payload = pre_tool_use_json(&[ - (TOOL_NAME_FIELD, Value::String("Bash".to_string())), - ( - TOOL_INPUT_FIELD, - serde_json::json!({ "run_in_background": true }), - ), - ]); - - let output = run_claude_mutation_scope_from_payload_with( - &payload, - None, - &resolver, - &unreachable_seam, - ) - .expect("an explicit background shell should still return Ok with a deny payload"); - - assert_eq!( - output, - pre_tool_use_deny_json(EXPLICIT_BACKGROUND_SHELL_DENY_REASON) - ); - assert!( - !git_dir.exists(), - "D20: denial must precede any adapter-state I/O" - ); - } - - #[test] - fn explicit_background_powershell_is_denied_ac21() { - let git_dir = unique_test_git_dir("explicit-background-powershell"); - let resolver = fixed_resolver(git_dir.clone()); - let payload = pre_tool_use_json(&[ - (TOOL_NAME_FIELD, Value::String("PowerShell".to_string())), - ( - TOOL_INPUT_FIELD, - serde_json::json!({ "run_in_background": true }), - ), - ]); - - let output = run_claude_mutation_scope_from_payload_with( - &payload, - None, - &resolver, - &unreachable_seam, - ) - .expect("explicit background PowerShell should be denied"); - - assert_eq!( - output, - pre_tool_use_deny_json(EXPLICIT_BACKGROUND_SHELL_DENY_REASON) - ); - } - - #[test] - fn write_ahead_pending_start_persists_before_the_seam_start_call_ac7() { - let git_dir = unique_test_git_dir("write-ahead"); - let resolver = fixed_resolver(git_dir.clone()); - let git_dir_for_seam = git_dir.clone(); - let phase_seen_before_start: RefCell> = RefCell::new(None); - let observed_roots: RefCell> = RefCell::new(Vec::new()); - let seam = - |root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { - if payload.contains(r#""operation":"start""#) { - observed_roots.borrow_mut().push(root.to_path_buf()); - let observed = state::read_state(&git_dir_for_seam) - .expect("state should be readable under git_dir inside the seam call"); - *phase_seen_before_start.borrow_mut() = - observed.attempts.first().map(|attempt| attempt.phase); - } - Ok(String::new()) - }; - - let payload = pre_tool_use_json(&[]); - let output = - run_claude_mutation_scope_from_payload_with(&payload, None, &resolver, &seam) - .expect("mutation-capable PreToolUse should succeed"); - - assert_eq!(output, ""); - assert_eq!( - phase_seen_before_start.into_inner(), - Some(state::AttemptPhase::PendingStart), - "AC7: the attempt must be durably pending_start (under git_dir) before the seam Start call" - ); - assert_eq!( - observed_roots.into_inner(), - vec![PathBuf::from("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/repo/checkout")], - "the seam must receive the raw Claude cwd as repository_root, never the resolved git_dir" - ); - - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert_eq!(final_state.attempts.len(), 1); - assert_eq!( - final_state.attempts[0].phase, - state::AttemptPhase::Active, - "phase must become active after a successful Start" - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn linked_worktree_cwd_and_git_dir_are_never_conflated_for_pre_tool_use_start() { - let git_dir = unique_test_git_dir("cwd-vs-git-dir-start"); - let raw_cwd = "/repo/.claude/worktrees/agent-123"; - let resolver = fixed_resolver(git_dir.clone()); - - let observed_roots: RefCell> = RefCell::new(Vec::new()); - let seam = - |root: &Path, _payload: &str, _logger: Option<&dyn Logger>| -> Result { - observed_roots.borrow_mut().push(root.to_path_buf()); - Ok(String::new()) - }; - - let payload = pre_tool_use_json(&[(CWD_FIELD, Value::String(raw_cwd.to_string()))]); - run_claude_mutation_scope_from_payload_with(&payload, None, &resolver, &seam) - .expect("PreToolUse Start should succeed"); - - assert_ne!( - PathBuf::from(raw_cwd), - git_dir, - "test sanity: the raw checkout path and the resolved git_dir must be deliberately distinct" - ); - assert_eq!( - observed_roots.into_inner(), - vec![PathBuf::from(raw_cwd)], - "the ingress seam must receive the raw Claude cwd, never git_dir" - ); - - let state = - state::read_state(&git_dir).expect("state should be readable under git_dir"); - assert_eq!( - state.attempts.len(), - 1, - "adapter bookkeeping must be written under the resolved git_dir" - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn post_tool_use_close_uses_git_dir_for_state_and_raw_cwd_for_the_seam() { - let git_dir = unique_test_git_dir("cwd-vs-git-dir-close"); - let raw_cwd = "/repo/.claude/worktrees/agent-123"; - let resolver = fixed_resolver(git_dir.clone()); - - let pre_payload = pre_tool_use_json(&[(CWD_FIELD, Value::String(raw_cwd.to_string()))]); - run_claude_mutation_scope_from_payload_with(&pre_payload, None, &resolver, &ok_seam) - .expect("PreToolUse should establish an active attempt"); - - let observed_roots: RefCell> = RefCell::new(Vec::new()); - let seam = - |root: &Path, _payload: &str, _logger: Option<&dyn Logger>| -> Result { - observed_roots.borrow_mut().push(root.to_path_buf()); - Ok(String::new()) - }; - let post_payload = pre_tool_use_json(&[ - (CWD_FIELD, Value::String(raw_cwd.to_string())), - ( - HOOK_EVENT_NAME_FIELD, - Value::String(HOOK_EVENT_POST_TOOL_USE.to_string()), - ), - ]); - run_claude_mutation_scope_from_payload_with(&post_payload, None, &resolver, &seam) - .expect("PostToolUse Close should succeed"); - - assert_eq!( - observed_roots.into_inner(), - vec![PathBuf::from(raw_cwd)], - "Close must invoke the seam with the raw Claude cwd, never git_dir" - ); - - let state = - state::read_state(&git_dir).expect("state should be readable under git_dir"); - assert!( - state.attempts.is_empty(), - "the closed attempt must be removed from git_dir bookkeeping" - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn abandon_via_permission_denied_uses_git_dir_for_state_and_raw_cwd_for_the_seam() { - let git_dir = unique_test_git_dir("cwd-vs-git-dir-abandon"); - let raw_cwd = "/repo/.claude/worktrees/agent-123"; - let resolver = fixed_resolver(git_dir.clone()); - - let pre_payload = pre_tool_use_json(&[(CWD_FIELD, Value::String(raw_cwd.to_string()))]); - run_claude_mutation_scope_from_payload_with(&pre_payload, None, &resolver, &ok_seam) - .expect("PreToolUse should establish an active attempt"); - - let observed_roots: RefCell> = RefCell::new(Vec::new()); - let seam = - |root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { - if payload.contains(r#""operation":"abandon""#) { - observed_roots.borrow_mut().push(root.to_path_buf()); - } - Ok(String::new()) - }; - let denied_payload = pre_tool_use_json(&[ - (CWD_FIELD, Value::String(raw_cwd.to_string())), - ( - HOOK_EVENT_NAME_FIELD, - Value::String(HOOK_EVENT_PERMISSION_DENIED.to_string()), - ), - ]); - run_claude_mutation_scope_from_payload_with(&denied_payload, None, &resolver, &seam) - .expect("PermissionDenied should succeed"); - - assert_eq!( - observed_roots.into_inner(), - vec![PathBuf::from(raw_cwd)], - "Abandon must invoke the seam with the raw Claude cwd, never git_dir" - ); - - let state = - state::read_state(&git_dir).expect("state should be readable under git_dir"); - assert!(state.attempts.is_empty()); - assert!(state.recovery_pending); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn recovery_barrier_flush_uses_raw_cwd_for_the_seam_and_git_dir_for_state() { - let git_dir = unique_test_git_dir("cwd-vs-git-dir-flush"); - let raw_cwd = "/repo/.claude/worktrees/agent-123"; - let resolver = fixed_resolver(git_dir.clone()); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - - let seeded = state::allocate_attempt( - &git_dir, - &AttemptKey { - session_id: "session-1".to_string(), - agent_id: None, - tool_use_id: "toolu_seed".to_string(), - }, - "Write", - ) - .expect("seed allocation should succeed"); - state::mark_recovery_pending(&git_dir).expect("arming the barrier should succeed"); - state::remove_attempt(&git_dir, &seeded.attempt.scope_id) - .expect("removing the seed attempt should succeed"); - - let observed_roots: RefCell> = RefCell::new(Vec::new()); - let seam = - |root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { - if payload.contains(r#""operation":"flush""#) { - observed_roots.borrow_mut().push(root.to_path_buf()); - } - Ok(String::new()) - }; - - let new_pre = pre_tool_use_json(&[ - (CWD_FIELD, Value::String(raw_cwd.to_string())), - (TOOL_USE_ID_FIELD, Value::String("toolu_new".to_string())), - ]); - run_claude_mutation_scope_from_payload_with(&new_pre, None, &resolver, &seam) - .expect("quiescent recovery should flush against the raw checkout path"); - - assert_eq!( - observed_roots.into_inner(), - vec![PathBuf::from(raw_cwd)], - "flush must run against the raw Claude cwd, not git_dir" - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn duplicate_live_pre_tool_use_reuses_the_same_scope_and_start_event_id_ac4() { - let git_dir = unique_test_git_dir("duplicate-delivery"); - let resolver = fixed_resolver(git_dir.clone()); - let start_event_ids: RefCell> = RefCell::new(Vec::new()); - let seam = - |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { - if payload.contains(r#""operation":"start""#) { - let value: Value = serde_json::from_str(payload).unwrap(); - start_event_ids - .borrow_mut() - .push(value["event_id"].as_str().unwrap().to_string()); - } - Ok(String::new()) - }; - - let payload = pre_tool_use_json(&[]); - run_claude_mutation_scope_from_payload_with(&payload, None, &resolver, &seam) - .expect("first delivery should succeed"); - run_claude_mutation_scope_from_payload_with(&payload, None, &resolver, &seam) - .expect("duplicate delivery should succeed"); - - let ids = start_event_ids.into_inner(); - assert_eq!(ids.len(), 2); - assert_eq!( - ids[0], ids[1], - "AC4: duplicate delivery must reuse the same Start EventId" - ); - - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert_eq!( - final_state.attempts.len(), - 1, - "duplicate delivery must not create a second bookkeeping entry" - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn seam_start_failure_denies_and_leaves_the_attempt_pending_start_d8_d11() { - let git_dir = unique_test_git_dir("start-failure"); - let resolver = fixed_resolver(git_dir.clone()); - let seam = seam_failing_on("start"); +#[allow(unused_imports)] +use lifecycle::{ + run_claude_mutation_scope_from_payload_with, + run_claude_mutation_scope_from_payload_with_resolver, +}; +#[allow(unused_imports)] +use payload::{ + abandon_payload, flush_payload, pre_tool_use_deny_json, scope_boundary_payload, + scope_start_payload, +}; - let payload = pre_tool_use_json(&[]); - let output = - run_claude_mutation_scope_from_payload_with(&payload, None, &resolver, &seam) - .expect( - "a Start failure must still return Ok with a deny payload, not propagate", - ); - - assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); - - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert_eq!(final_state.attempts.len(), 1); - assert_eq!( - final_state.attempts[0].phase, - state::AttemptPhase::PendingStart, - "D11: a failed Start must not be marked active nor removed" - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn pending_start_attempt_is_abandoned_not_late_started_on_a_terminal_signal_d11() { - let git_dir = unique_test_git_dir("pending-start-then-terminal"); - let resolver = fixed_resolver(git_dir.clone()); - - let start_failing_seam = seam_failing_on("start"); - let pre_payload = pre_tool_use_json(&[]); - run_claude_mutation_scope_from_payload_with( - &pre_payload, - None, - &resolver, - &start_failing_seam, - ) - .expect("the failed Start must still return Ok with a deny payload"); - - let seen_operations: RefCell> = RefCell::new(Vec::new()); - let recording = - |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { - seen_operations.borrow_mut().push(payload.to_string()); - Ok(String::new()) - }; - let post_payload = pre_tool_use_json(&[( - HOOK_EVENT_NAME_FIELD, - Value::String(HOOK_EVENT_POST_TOOL_USE.to_string()), - )]); - let output = run_claude_mutation_scope_from_payload_with( - &post_payload, - None, - &resolver, - &recording, - ) - .expect("PostToolUse for a pending_start attempt should succeed"); - - assert_eq!(output, ""); - let operations = seen_operations.into_inner(); - assert_eq!(operations.len(), 1); - assert!( - operations[0].contains(r#""operation":"abandon""#), - "D11: a pending_start attempt must be abandoned, not late-started, got: {operations:?}" - ); - - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert!(final_state.attempts.is_empty()); - assert!(final_state.recovery_pending); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn close_seam_failure_is_retired_through_abandonment_not_a_replayed_close_d12() { - let git_dir = unique_test_git_dir("close-failure"); - let resolver = fixed_resolver(git_dir.clone()); - - let pre_payload = pre_tool_use_json(&[]); - run_claude_mutation_scope_from_payload_with(&pre_payload, None, &resolver, &ok_seam) - .expect("PreToolUse should establish an active attempt"); - - let close_failing_seam = seam_failing_on("close"); - let post_payload = pre_tool_use_json(&[( - HOOK_EVENT_NAME_FIELD, - Value::String(HOOK_EVENT_POST_TOOL_USE.to_string()), - )]); - let output = run_claude_mutation_scope_from_payload_with( - &post_payload, - None, - &resolver, - &close_failing_seam, - ) - .expect("a Close failure must still succeed via abandonment"); - - assert_eq!(output, ""); - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert!( - final_state.attempts.is_empty(), - "D12: after abandonment the attempt must be retired" - ); - assert!(final_state.recovery_pending); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn post_tool_use_failure_also_closes_the_scope_d10() { - let git_dir = unique_test_git_dir("post-tool-use-failure"); - let resolver = fixed_resolver(git_dir.clone()); - - let pre_payload = pre_tool_use_json(&[]); - run_claude_mutation_scope_from_payload_with(&pre_payload, None, &resolver, &ok_seam) - .expect("PreToolUse should establish an active attempt"); - - let seen_operations: RefCell> = RefCell::new(Vec::new()); - let recording = - |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { - seen_operations.borrow_mut().push(payload.to_string()); - Ok(String::new()) - }; - let failure_payload = pre_tool_use_json(&[( - HOOK_EVENT_NAME_FIELD, - Value::String(HOOK_EVENT_POST_TOOL_USE_FAILURE.to_string()), - )]); - let output = run_claude_mutation_scope_from_payload_with( - &failure_payload, - None, - &resolver, - &recording, - ) - .expect("PostToolUseFailure should close the scope"); - - assert_eq!(output, ""); - let operations = seen_operations.into_inner(); - assert_eq!(operations.len(), 1); - assert!(operations[0].contains(r#""operation":"close""#)); - - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert!(final_state.attempts.is_empty()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn post_tool_use_with_no_live_attempt_is_a_safe_no_op_d9() { - let payload = pre_tool_use_json(&[( - HOOK_EVENT_NAME_FIELD, - Value::String(HOOK_EVENT_POST_TOOL_USE.to_string()), - )]); - let resolver = - fixed_resolver(std::env::temp_dir().join("sce-unused-nonexistent-git-dir")); - - let output = run_claude_mutation_scope_from_payload_with( - &payload, - None, - &resolver, - &unreachable_seam, - ) - .expect("a PostToolUse with no live attempt must be a safe no-op"); - - assert_eq!(output, ""); - } - - #[test] - fn permission_denied_abandons_a_live_attempt_d13() { - let git_dir = unique_test_git_dir("permission-denied"); - let resolver = fixed_resolver(git_dir.clone()); - - let pre_payload = pre_tool_use_json(&[]); - run_claude_mutation_scope_from_payload_with(&pre_payload, None, &resolver, &ok_seam) - .expect("PreToolUse should establish an active attempt"); - - let seen_operations: RefCell> = RefCell::new(Vec::new()); - let recording = - |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { - seen_operations.borrow_mut().push(payload.to_string()); - Ok(String::new()) - }; - let denied_payload = pre_tool_use_json(&[( - HOOK_EVENT_NAME_FIELD, - Value::String(HOOK_EVENT_PERMISSION_DENIED.to_string()), - )]); - let output = run_claude_mutation_scope_from_payload_with( - &denied_payload, - None, - &resolver, - &recording, - ) - .expect("PermissionDenied should succeed"); - - assert_eq!(output, ""); - let operations = seen_operations.into_inner(); - assert_eq!(operations.len(), 1); - assert!(operations[0].contains(r#""operation":"abandon""#)); - - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert!(final_state.attempts.is_empty()); - assert!(final_state.recovery_pending); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn stop_abandons_only_stale_main_thread_attempts_d14() { - let git_dir = unique_test_git_dir("stop-cleanup"); - let resolver = fixed_resolver(git_dir.clone()); - - let main_pre = - pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_main".to_string()))]); - let subagent_pre = pre_tool_use_json(&[ - (TOOL_USE_ID_FIELD, Value::String("toolu_agent".to_string())), - (AGENT_ID_FIELD, Value::String("agent-1".to_string())), - ]); - run_claude_mutation_scope_from_payload_with(&main_pre, None, &resolver, &ok_seam) - .expect("main-thread PreToolUse should succeed"); - run_claude_mutation_scope_from_payload_with(&subagent_pre, None, &resolver, &ok_seam) - .expect("subagent PreToolUse should succeed"); - - let stop_payload = - session_scoped_payload(HOOK_EVENT_STOP, "session-1", "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/repo/checkout"); - let output = run_claude_mutation_scope_from_payload_with( - &stop_payload, - None, - &resolver, - &ok_seam, - ) - .expect("Stop cleanup should succeed"); - assert_eq!(output, ""); - - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert_eq!( - final_state.attempts.len(), - 1, - "only the subagent attempt should remain" - ); - assert_eq!(final_state.attempts[0].tool_use_id, "toolu_agent"); - assert!( - final_state.recovery_pending, - "abandoning the stale main attempt must arm the barrier" - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn stop_failure_abandons_stale_main_thread_attempts_the_same_way_d15() { - let git_dir = unique_test_git_dir("stop-failure-cleanup"); - let resolver = fixed_resolver(git_dir.clone()); - - let main_pre = - pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_main".to_string()))]); - run_claude_mutation_scope_from_payload_with(&main_pre, None, &resolver, &ok_seam) - .expect("main-thread PreToolUse should succeed"); - - let stop_failure_payload = - session_scoped_payload(HOOK_EVENT_STOP_FAILURE, "session-1", "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/repo/checkout"); - run_claude_mutation_scope_from_payload_with( - &stop_failure_payload, - None, - &resolver, - &ok_seam, - ) - .expect("StopFailure cleanup should succeed"); - - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert!(final_state.attempts.is_empty()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn user_prompt_submit_abandons_only_stale_main_thread_attempts_d16() { - let git_dir = unique_test_git_dir("user-prompt-submit-cleanup"); - let resolver = fixed_resolver(git_dir.clone()); - - let main_pre = - pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_main".to_string()))]); - let subagent_pre = pre_tool_use_json(&[ - (TOOL_USE_ID_FIELD, Value::String("toolu_agent".to_string())), - (AGENT_ID_FIELD, Value::String("agent-1".to_string())), - ]); - run_claude_mutation_scope_from_payload_with(&main_pre, None, &resolver, &ok_seam) - .expect("main-thread PreToolUse should succeed"); - run_claude_mutation_scope_from_payload_with(&subagent_pre, None, &resolver, &ok_seam) - .expect("subagent PreToolUse should succeed"); - - let prompt_payload = session_scoped_payload( - HOOK_EVENT_USER_PROMPT_SUBMIT, - "session-1", - "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/repo/checkout", - ); - run_claude_mutation_scope_from_payload_with(&prompt_payload, None, &resolver, &ok_seam) - .expect("UserPromptSubmit cleanup should succeed"); - - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert_eq!( - final_state.attempts.len(), - 1, - "the subagent attempt must survive" - ); - assert_eq!(final_state.attempts[0].tool_use_id, "toolu_agent"); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn subagent_stop_abandons_only_the_matching_agent_id_attempts_d17() { - let git_dir = unique_test_git_dir("subagent-stop-cleanup"); - let resolver = fixed_resolver(git_dir.clone()); - - let first_agent_payload = pre_tool_use_json(&[ - (TOOL_USE_ID_FIELD, Value::String("toolu_a".to_string())), - (AGENT_ID_FIELD, Value::String("agent-a".to_string())), - ]); - let second_agent_payload = pre_tool_use_json(&[ - (TOOL_USE_ID_FIELD, Value::String("toolu_b".to_string())), - (AGENT_ID_FIELD, Value::String("agent-b".to_string())), - ]); - run_claude_mutation_scope_from_payload_with( - &first_agent_payload, - None, - &resolver, - &ok_seam, - ) - .expect("agent-a PreToolUse should succeed"); - run_claude_mutation_scope_from_payload_with( - &second_agent_payload, - None, - &resolver, - &ok_seam, - ) - .expect("agent-b PreToolUse should succeed"); - - let mut object = serde_json::Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(HOOK_EVENT_SUBAGENT_STOP.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String("session-1".to_string()), - ); - object.insert( - CWD_FIELD.to_string(), - Value::String("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/repo/checkout".to_string()), - ); - object.insert( - AGENT_ID_FIELD.to_string(), - Value::String("agent-a".to_string()), - ); - let subagent_stop_payload = Value::Object(object).to_string(); - - run_claude_mutation_scope_from_payload_with( - &subagent_stop_payload, - None, - &resolver, - &ok_seam, - ) - .expect("SubagentStop cleanup should succeed"); - - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert_eq!(final_state.attempts.len(), 1); - assert_eq!(final_state.attempts[0].tool_use_id, "toolu_b"); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn session_end_abandons_every_attempt_regardless_of_agent_id_d18() { - let git_dir = unique_test_git_dir("session-end-cleanup"); - let resolver = fixed_resolver(git_dir.clone()); - - let main_pre = - pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_main".to_string()))]); - let subagent_pre = pre_tool_use_json(&[ - (TOOL_USE_ID_FIELD, Value::String("toolu_agent".to_string())), - (AGENT_ID_FIELD, Value::String("agent-1".to_string())), - ]); - run_claude_mutation_scope_from_payload_with(&main_pre, None, &resolver, &ok_seam) - .expect("main-thread PreToolUse should succeed"); - run_claude_mutation_scope_from_payload_with(&subagent_pre, None, &resolver, &ok_seam) - .expect("subagent PreToolUse should succeed"); - - let session_end_payload = - session_scoped_payload(HOOK_EVENT_SESSION_END, "session-1", "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/repo/checkout"); - run_claude_mutation_scope_from_payload_with( - &session_end_payload, - None, - &resolver, - &ok_seam, - ) - .expect("SessionEnd cleanup should succeed"); - - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert!(final_state.attempts.is_empty()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn worktree_remove_resolves_git_dir_from_worktree_path_not_cwd_d22() { - let main_git_dir = unique_test_git_dir("worktree-remove-main"); - let worktree_git_dir = unique_test_git_dir("worktree-remove-isolated"); - let main_git_dir_for_resolver = main_git_dir.clone(); - let worktree_git_dir_for_resolver = worktree_git_dir.clone(); - - let resolver = move |cwd: &str| -> Result { - if cwd == "/repo/.claude/worktrees/agent-1" { - Ok(worktree_git_dir_for_resolver.clone()) - } else { - Ok(main_git_dir_for_resolver.clone()) - } - }; - - let subagent_pre = pre_tool_use_json(&[ - ( - CWD_FIELD, - Value::String("/repo/.claude/worktrees/agent-1".to_string()), - ), - (AGENT_ID_FIELD, Value::String("agent-1".to_string())), - ]); - run_claude_mutation_scope_from_payload_with(&subagent_pre, None, &resolver, &ok_seam) - .expect("isolated-worktree PreToolUse should succeed"); - - let mut object = serde_json::Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(HOOK_EVENT_WORKTREE_REMOVE.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String("session-1".to_string()), - ); - object.insert( - WORKTREE_PATH_FIELD.to_string(), - Value::String("/repo/.claude/worktrees/agent-1".to_string()), - ); - let worktree_remove_payload = Value::Object(object).to_string(); - - run_claude_mutation_scope_from_payload_with( - &worktree_remove_payload, - None, - &resolver, - &ok_seam, - ) - .expect("WorktreeRemove cleanup should succeed"); - - let worktree_state = - state::read_state(&worktree_git_dir).expect("worktree state should be readable"); - assert!( - worktree_state.attempts.is_empty(), - "D22: WorktreeRemove must retire attempts under the worktree_path's git dir" - ); - - remove_test_git_dir(&main_git_dir); - remove_test_git_dir(&worktree_git_dir); - } - - #[test] - fn recovery_barrier_denies_new_mutation_capable_pre_tool_use_while_attempts_remain_d19() { - let git_dir = unique_test_git_dir("barrier-attempts-remain"); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - let resolver = fixed_resolver(git_dir.clone()); - - let surviving = state::allocate_attempt( - &git_dir, - &AttemptKey { - session_id: "session-1".to_string(), - agent_id: None, - tool_use_id: "toolu_surviving".to_string(), - }, - "Write", - ) - .expect("seeding a surviving attempt should succeed"); - let retiring = state::allocate_attempt( - &git_dir, - &AttemptKey { - session_id: "session-1".to_string(), - agent_id: None, - tool_use_id: "toolu_retiring".to_string(), - }, - "Write", - ) - .expect("seeding a retiring attempt should succeed"); - state::mark_recovery_pending(&git_dir).expect("arming the barrier should succeed"); - state::remove_attempt(&git_dir, &retiring.attempt.scope_id) - .expect("removing the retiring attempt should succeed"); - let _ = surviving; - - let new_pre = - pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_new".to_string()))]); - let output = run_claude_mutation_scope_from_payload_with( - &new_pre, - None, - &resolver, - &unreachable_seam, - ) - .expect("D19: barrier denial must still return Ok with a deny payload"); - - assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn recovery_barrier_flushes_once_quiescent_and_clears_before_starting_d19() { - let git_dir = unique_test_git_dir("barrier-flush-success"); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - let resolver = fixed_resolver(git_dir.clone()); - - let seeded = state::allocate_attempt( - &git_dir, - &AttemptKey { - session_id: "session-1".to_string(), - agent_id: None, - tool_use_id: "toolu_seed".to_string(), - }, - "Write", - ) - .expect("seeding the retired attempt should succeed"); - state::mark_recovery_pending(&git_dir).expect("arming the barrier should succeed"); - state::remove_attempt(&git_dir, &seeded.attempt.scope_id) - .expect("removing the seeded attempt should succeed"); - - let seen_operations: RefCell> = RefCell::new(Vec::new()); - let seam = - |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { - seen_operations.borrow_mut().push(payload.to_string()); - Ok(String::new()) - }; - - let new_pre = - pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_new".to_string()))]); - let output = - run_claude_mutation_scope_from_payload_with(&new_pre, None, &resolver, &seam) - .expect("D19: a quiescent recovery should flush then proceed"); - - assert_eq!(output, ""); - let operations = seen_operations.into_inner(); - assert_eq!( - operations.len(), - 2, - "expected flush then start, got: {operations:?}" - ); - assert!(operations[0].contains(r#""operation":"flush""#)); - assert!(operations[1].contains(r#""operation":"start""#)); - - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert!( - !final_state.recovery_pending, - "a successful flush must clear the barrier" - ); - assert_eq!(final_state.attempts.len(), 1); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn recovery_barrier_stays_fail_closed_when_flush_fails_d19() { - let git_dir = unique_test_git_dir("barrier-flush-failure"); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - let resolver = fixed_resolver(git_dir.clone()); - - let seeded = state::allocate_attempt( - &git_dir, - &AttemptKey { - session_id: "session-1".to_string(), - agent_id: None, - tool_use_id: "toolu_seed".to_string(), - }, - "Write", - ) - .expect("seeding the retired attempt should succeed"); - state::mark_recovery_pending(&git_dir).expect("arming the barrier should succeed"); - state::remove_attempt(&git_dir, &seeded.attempt.scope_id) - .expect("removing the seeded attempt should succeed"); - - let seam = seam_failing_on("flush"); - let new_pre = - pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_new".to_string()))]); - let output = - run_claude_mutation_scope_from_payload_with(&new_pre, None, &resolver, &seam) - .expect("D19: a failed flush must still return Ok with a deny payload"); - - assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); - - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert!( - final_state.recovery_pending, - "a failed flush must keep the barrier armed" - ); - assert!( - final_state.attempts.is_empty(), - "a denied PreToolUse must not allocate a new attempt" - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn failed_close_and_failed_abandon_keep_recovery_armed_and_the_attempt_tracked_d12_d19() { - let git_dir = unique_test_git_dir("close-and-abandon-failure"); - let resolver = fixed_resolver(git_dir.clone()); - - let pre_payload = pre_tool_use_json(&[]); - run_claude_mutation_scope_from_payload_with(&pre_payload, None, &resolver, &ok_seam) - .expect("PreToolUse should establish an active attempt"); - - let failing_seam = seam_failing_on_any(vec!["close", "abandon"]); - let post_payload = pre_tool_use_json(&[( - HOOK_EVENT_NAME_FIELD, - Value::String(HOOK_EVENT_POST_TOOL_USE.to_string()), - )]); - let error = run_claude_mutation_scope_from_payload_with( - &post_payload, - None, - &resolver, - &failing_seam, - ) - .expect_err( - "a failed Close followed by a failed Abandon must propagate, not silently succeed", - ); - assert!(error.to_string().contains("abandon")); - - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert_eq!( - final_state.attempts.len(), - 1, - "D12: an attempt whose abandonment failed must remain tracked" - ); - assert!( - final_state.recovery_pending, - "D19: recovery must be armed even though abandonment itself failed" - ); - - let new_pre = - pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_new".to_string()))]); - let output = run_claude_mutation_scope_from_payload_with( - &new_pre, - None, - &resolver, - &unreachable_seam, - ) - .expect("the barrier denial must still return Ok with a deny payload"); - assert_eq!( - output, - pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), - "the next mutation-capable PreToolUse must be denied, and no new Start may occur" - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn lifecycle_cleanup_with_a_failed_abandon_keeps_recovery_armed_and_the_attempt_tracked() { - let git_dir = unique_test_git_dir("lifecycle-cleanup-abandon-failure"); - let resolver = fixed_resolver(git_dir.clone()); - - let main_pre = - pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_main".to_string()))]); - run_claude_mutation_scope_from_payload_with(&main_pre, None, &resolver, &ok_seam) - .expect("main-thread PreToolUse should succeed"); - - let abandon_failing_seam = seam_failing_on("abandon"); - let stop_payload = - session_scoped_payload(HOOK_EVENT_STOP, "session-1", "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/repo/checkout"); - let error = run_claude_mutation_scope_from_payload_with( - &stop_payload, - None, - &resolver, - &abandon_failing_seam, - ) - .expect_err("a failed abandonment during Stop cleanup must propagate"); - assert!(error.to_string().contains("abandon")); - - let final_state = state::read_state(&git_dir).expect("state should be readable"); - assert_eq!( - final_state.attempts.len(), - 1, - "the attempt whose abandonment failed must remain tracked" - ); - assert!( - final_state.recovery_pending, - "the barrier must remain armed even though cleanup abandonment failed" - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn resolver_failure_logs_the_detailed_error_and_denies_with_the_stable_reason_ac8_d8() { - let logger = RecordingLogger::default(); - let resolver = |_: &str| -> Result { - Err(anyhow!("boom: git rev-parse --git-dir failed")) - }; - - let payload = pre_tool_use_json(&[]); - let output = run_claude_mutation_scope_from_payload_with( - &payload, - Some(&logger), - &resolver, - &unreachable_seam, - ) - .expect("a resolver failure must still return Ok with a deny payload"); - - assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); - assert!( - !output.contains("boom"), - "the detailed internal error must never leak into Claude's deny reason" - ); - assert!( - !output.contains("allow"), - "a fail-closed PreToolUse must never emit an allow decision" - ); - - let warnings = logger.warnings(); - assert_eq!(warnings.len(), 1); - assert!( - warnings[0].1.contains("boom"), - "the detailed error must be logged for operators, got: {warnings:?}" - ); - } - - #[test] - fn start_seam_failure_logs_the_detailed_error_and_denies_with_the_stable_reason() { - let git_dir = unique_test_git_dir("start-failure-logged"); - let resolver = fixed_resolver(git_dir.clone()); - let logger = RecordingLogger::default(); - let seam = seam_failing_on("start"); - - let payload = pre_tool_use_json(&[]); - let output = run_claude_mutation_scope_from_payload_with( - &payload, - Some(&logger), - &resolver, - &seam, - ) - .expect("a Start failure must still return Ok with a deny payload"); - - assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); - - let warnings = logger.warnings(); - assert!(!warnings.is_empty(), "the Start failure must be logged"); - assert!(warnings - .iter() - .any(|(_, message)| message.to_lowercase().contains("start"))); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn recovery_barrier_flush_failure_logs_the_detailed_error() { - let git_dir = unique_test_git_dir("barrier-flush-failure-logged"); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - let resolver = fixed_resolver(git_dir.clone()); - let logger = RecordingLogger::default(); - - let seeded = state::allocate_attempt( - &git_dir, - &AttemptKey { - session_id: "session-1".to_string(), - agent_id: None, - tool_use_id: "toolu_seed".to_string(), - }, - "Write", - ) - .expect("seed allocation should succeed"); - state::mark_recovery_pending(&git_dir).expect("arming the barrier should succeed"); - state::remove_attempt(&git_dir, &seeded.attempt.scope_id) - .expect("removing the seeded attempt should succeed"); - - let seam = seam_failing_on("flush"); - let new_pre = - pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_new".to_string()))]); - let output = run_claude_mutation_scope_from_payload_with( - &new_pre, - Some(&logger), - &resolver, - &seam, - ) - .expect("a failed flush must still return Ok with a deny payload"); - - assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); - - let warnings = logger.warnings(); - assert!(!warnings.is_empty(), "the flush failure must be logged"); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn malformed_payload_propagates_as_a_real_error_not_fail_open() { - let error = run_claude_mutation_scope_from_payload("not json", None).unwrap_err(); - assert!(error.to_string().contains("valid JSON")); - } - } - - mod production_regressions { - use std::fs; - use std::path::{Path, PathBuf}; - use std::process::Command; - - use super::*; - use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; - use crate::services::agent_trace_db::{ClaudeModelStateObservation, ObservationKind}; - use crate::services::agent_trace_storage::{ - resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, - }; - use crate::services::mutation_trace::runtime::{resolve_git_dir, resolve_worktree_id}; - use crate::services::mutation_trace::store::decode_revision; - - fn git(dir: &Path, args: &[&str]) -> String { - let output = Command::new("git") - .args(args) - .current_dir(dir) - .output() - .expect("git should spawn"); - assert!( - output.status.success(), - "git {args:?} failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - String::from_utf8(output.stdout).expect("git output should be UTF-8") - } - - struct ClaudeRepo { - temp: tempfile::TempDir, - root: PathBuf, - state_root: PathBuf, - } - - impl ClaudeRepo { - fn new(label: &str) -> Self { - let temp = tempfile::Builder::new() - .prefix(&format!("sce-claude-mutation-scope-regression-{label}-")) - .tempdir() - .expect("temp dir should be created"); - let root = temp.path().join("repo"); - fs::create_dir_all(&root).expect("repo dir should be created"); - git(&root, &["init", "-q"]); - git(&root, &["config", "user.email", "test@example.invalid"]); - git(&root, &["config", "user.name", "SCE Test"]); - git( - &root, - &["remote", "add", "origin", "git@github.com:acme/widgets.git"], - ); - fs::write(root.join("file.txt"), "one\n").expect("seed file should write"); - git(&root, &["add", "-A"]); - git(&root, &["commit", "-qm", "base"]); - - let state_root = temp.path().join("state"); - fs::create_dir_all(&state_root).expect("state root should be created"); - resolve_agent_trace_storage_at_state_root( - &AgentTraceStorageContext { - repository_root: &root, - explicit_repository_id: None, - repository_remote: "origin", - }, - &state_root, - ) - .expect("state-root storage should initialize the repository DB"); - - Self { - temp, - root, - state_root, - } - } - - fn drive(&self, payload: &str) -> Result { - run_claude_mutation_scope_from_payload_at_state_root( - &self.state_root, - payload, - None, - ) - } - - fn drive_generic(&self, payload: &str) -> Result { - crate::services::hooks::mutation_scope::run_mutation_scope_from_payload_at_state_root( - &self.root, - &self.state_root, - payload, - None, - ) - } - - fn drive_flush(&self) -> Result { - self.drive_generic(&flush_payload()) - } - - fn db(&self) -> RepositoryAgentTraceDb { - crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( - &self.root, - &self.state_root, - "claude mutation-scope regression test assertions", - ) - .expect("assertion DB should open") - } - - fn cwd(&self) -> String { - self.root.to_string_lossy().into_owned() - } - - fn cwd_at(root: &Path) -> String { - root.to_string_lossy().into_owned() - } - - fn working_tree_at(root: &Path) -> String { - git(root, &["add", "-A"]); - git(root, &["write-tree"]).trim().to_owned() - } - - fn working_tree(&self) -> String { - Self::working_tree_at(&self.root) - } - - fn git_dir_at(root: &Path) -> PathBuf { - resolve_git_dir(root).expect("git dir should resolve") - } - - fn git_dir(&self) -> PathBuf { - Self::git_dir_at(&self.root) - } - - fn adapter_state_at(root: &Path) -> state::AdapterState { - state::read_state(&Self::git_dir_at(root)) - .expect("adapter state should be readable") - } - - fn adapter_state(&self) -> state::AdapterState { - Self::adapter_state_at(&self.root) - } - - fn worktree_id_at(root: &Path) -> String { - resolve_worktree_id(root) - .expect("worktree id should resolve") - .0 - } - - fn worktree_id(&self) -> String { - Self::worktree_id_at(&self.root) - } - - fn add_worktree(&self, name: &str) -> PathBuf { - let worktree_path = self.temp.path().join(name); - git( - &self.root, - &[ - "worktree", - "add", - "-q", - worktree_path.to_str().expect("utf-8 worktree path"), - ], - ); - worktree_path - } - } - - fn count(db: &RepositoryAgentTraceDb, table: &str) -> i64 { - db.query_map(&format!("SELECT COUNT(*) FROM {table}"), (), |row| { - row.get::(0).map_err(anyhow::Error::from) - }) - .expect("count query should succeed") - .into_iter() - .next() - .expect("a count row should exist") - } - - fn assert_raw_agent_trace_tables_untouched(db: &RepositoryAgentTraceDb) { - assert_eq!(count(db, "diff_traces"), 0); - assert_eq!(count(db, "post_commit_patch_intersections"), 0); - assert_eq!(count(db, "agent_traces"), 0); - } - - fn worktree_row( - db: &RepositoryAgentTraceDb, - worktree_id: &str, - ) -> Option<(u64, String, bool)> { - db.query_map( - "SELECT revision, cursor_tree, needs_rebaseline FROM mutation_trace_worktrees \ - WHERE worktree_id = ?1", - (worktree_id,), - |row| { - let blob: Vec = row.get(0).map_err(anyhow::Error::from)?; - let revision = decode_revision(&blob)?; - let cursor_tree = row.get::(1).map_err(anyhow::Error::from)?; - let needs_rebaseline = row.get::(2).map_err(anyhow::Error::from)? != 0; - Ok((revision, cursor_tree, needs_rebaseline)) - }, - ) - .expect("worktree-row query should succeed") - .into_iter() - .next() - } - - fn processed_events(db: &RepositoryAgentTraceDb) -> Vec<(String, String)> { - db.query_map( - "SELECT scope_id, event_id FROM mutation_trace_processed_events \ - ORDER BY scope_id, event_id", - (), - |row| { - let scope_id = row.get::(0).map_err(anyhow::Error::from)?; - let event_id = row.get::(1).map_err(anyhow::Error::from)?; - Ok((scope_id, event_id)) - }, - ) - .expect("processed-events query should succeed") - } - - fn scope_status(db: &RepositoryAgentTraceDb, scope_id: &str) -> Option<(String, String)> { - db.query_map( - "SELECT actor_kind, status FROM mutation_trace_scopes WHERE scope_id = ?1", - (scope_id,), - |row| { - let actor_kind = row.get::(0).map_err(anyhow::Error::from)?; - let status = row.get::(1).map_err(anyhow::Error::from)?; - Ok((actor_kind, status)) - }, - ) - .expect("scope query should succeed") - .into_iter() - .next() - } - - fn scope_provenance( - db: &RepositoryAgentTraceDb, - scope_id: &str, - ) -> Option<(String, Option)> { - db.query_map( - "SELECT session_id, model_id FROM mutation_trace_scope_provenance \ - WHERE scope_id = ?1", - (scope_id,), - |row| { - let session_id = row.get::(0).map_err(anyhow::Error::from)?; - let model_id = row.get::>(1).map_err(anyhow::Error::from)?; - Ok((session_id, model_id)) - }, - ) - .expect("scope-provenance query should succeed") - .into_iter() - .next() - } - - fn mutation_events_for( - db: &RepositoryAgentTraceDb, - worktree_id: &str, - ) -> Vec<(String, Option, String)> { - db.query_map( - "SELECT attribution_kind, attribution_scope_id, boundary_kind \ - FROM mutation_trace_events WHERE worktree_id = ?1 ORDER BY revision", - (worktree_id,), - |row| { - let attribution_kind = row.get::(0).map_err(anyhow::Error::from)?; - let attribution_scope_id = - row.get::>(1).map_err(anyhow::Error::from)?; - let boundary_kind = row.get::(2).map_err(anyhow::Error::from)?; - Ok((attribution_kind, attribution_scope_id, boundary_kind)) - }, - ) - .expect("mutation-events query should succeed") - } - - fn tool_identity_json( - event_name: &str, - cwd: &str, - session_id: &str, - tool_name: &str, - tool_use_id: &str, - agent_id: Option<&str>, - ) -> String { - let mut object = serde_json::Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(event_name.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String(session_id.to_string()), - ); - object.insert(CWD_FIELD.to_string(), Value::String(cwd.to_string())); - object.insert( - TOOL_NAME_FIELD.to_string(), - Value::String(tool_name.to_string()), - ); - object.insert( - TOOL_USE_ID_FIELD.to_string(), - Value::String(tool_use_id.to_string()), - ); - if let Some(agent_id) = agent_id { - object.insert( - AGENT_ID_FIELD.to_string(), - Value::String(agent_id.to_string()), - ); - } - Value::Object(object).to_string() - } - - fn pre_tool_use_for( - cwd: &str, - session_id: &str, - tool_name: &str, - tool_use_id: &str, - agent_id: Option<&str>, - ) -> String { - tool_identity_json( - HOOK_EVENT_PRE_TOOL_USE, - cwd, - session_id, - tool_name, - tool_use_id, - agent_id, - ) - } - - fn background_pre_tool_use_for( - cwd: &str, - session_id: &str, - tool_use_id: &str, - agent_id: Option<&str>, - ) -> String { - let mut object: serde_json::Map = serde_json::from_str( - &pre_tool_use_for(cwd, session_id, "Bash", tool_use_id, agent_id), - ) - .expect("base PreToolUse payload should parse"); - object.insert( - TOOL_INPUT_FIELD.to_string(), - json!({ "run_in_background": true }), - ); - Value::Object(object).to_string() - } - - fn post_tool_use_for( - cwd: &str, - session_id: &str, - tool_name: &str, - tool_use_id: &str, - agent_id: Option<&str>, - ) -> String { - tool_identity_json( - HOOK_EVENT_POST_TOOL_USE, - cwd, - session_id, - tool_name, - tool_use_id, - agent_id, - ) - } - - fn post_tool_use_failure_for( - cwd: &str, - session_id: &str, - tool_name: &str, - tool_use_id: &str, - agent_id: Option<&str>, - ) -> String { - tool_identity_json( - HOOK_EVENT_POST_TOOL_USE_FAILURE, - cwd, - session_id, - tool_name, - tool_use_id, - agent_id, - ) - } - - fn permission_denied_for( - cwd: &str, - session_id: &str, - tool_name: &str, - tool_use_id: &str, - agent_id: Option<&str>, - ) -> String { - tool_identity_json( - HOOK_EVENT_PERMISSION_DENIED, - cwd, - session_id, - tool_name, - tool_use_id, - agent_id, - ) - } - - fn session_json(event_name: &str, cwd: &str, session_id: &str) -> String { - let mut object = serde_json::Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(event_name.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String(session_id.to_string()), - ); - object.insert(CWD_FIELD.to_string(), Value::String(cwd.to_string())); - Value::Object(object).to_string() - } - - fn agent_json(event_name: &str, cwd: &str, session_id: &str, agent_id: &str) -> String { - let mut object = serde_json::Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(event_name.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String(session_id.to_string()), - ); - object.insert(CWD_FIELD.to_string(), Value::String(cwd.to_string())); - object.insert( - AGENT_ID_FIELD.to_string(), - Value::String(agent_id.to_string()), - ); - Value::Object(object).to_string() - } - - fn worktree_remove_json(session_id: &str, worktree_path: &str) -> String { - let mut object = serde_json::Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(HOOK_EVENT_WORKTREE_REMOVE.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String(session_id.to_string()), - ); - object.insert( - WORKTREE_PATH_FIELD.to_string(), - Value::String(worktree_path.to_string()), - ); - Value::Object(object).to_string() - } - - #[test] - fn test1_foreground_write_closes_ai_exclusive() { - let repo = ClaudeRepo::new("test1-foreground-write"); - let cwd = repo.cwd(); - - assert_eq!( - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_1", - None - )) - .expect("PreToolUse should succeed"), - "" - ); - let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); - - fs::write(repo.root.join("file.txt"), "one\ntwo\n") - .expect("the tool's own edit should write"); - - assert_eq!( - repo.drive(&post_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_1", - None - )) - .expect("PostToolUse should succeed"), - "" - ); - - assert!( - repo.adapter_state().attempts.is_empty(), - "the closed attempt must be removed from adapter bookkeeping" - ); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &scope_id), - Some(("claude_code".to_string(), "closed".to_string())) - ); - let worktree_id = repo.worktree_id(); - assert_eq!( - mutation_events_for(&db, &worktree_id), - vec![( - "ai_exclusive".to_string(), - Some(scope_id.clone()), - "close".to_string(), - )] - ); - assert_eq!( - worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), - Some(repo.working_tree()) - ); - assert_eq!( - processed_events(&db), - vec![ - (scope_id.clone(), claude_scope_close_event_id(&scope_id)), - (scope_id.clone(), claude_scope_start_event_id(&scope_id)), - ], - "rows are ordered by (scope_id, event_id), and 'close' sorts before 'start'" - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test2_failed_bash_partial_write_still_closes_ai_exclusive() { - let repo = ClaudeRepo::new("test2-failed-bash"); - let cwd = repo.cwd(); - - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Bash", - "toolu_1", - None, - )) - .expect("PreToolUse should succeed"); - let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); - - fs::write(repo.root.join("file.txt"), "one\npartial\n") - .expect("the failed tool's partial edit should write"); - - assert_eq!( - repo.drive(&post_tool_use_failure_for( - &cwd, - "session-1", - "Bash", - "toolu_1", - None - )) - .expect("PostToolUseFailure should succeed"), - "" - ); - - assert!(repo.adapter_state().attempts.is_empty()); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &scope_id), - Some(("claude_code".to_string(), "closed".to_string())) - ); - let worktree_id = repo.worktree_id(); - assert_eq!( - mutation_events_for(&db, &worktree_id), - vec![( - "ai_exclusive".to_string(), - Some(scope_id.clone()), - "close".to_string(), - )] - ); - assert_eq!( - worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), - Some(repo.working_tree()) - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test4_duplicate_pre_and_post_replay_has_no_duplicate_transition() { - let repo = ClaudeRepo::new("test4-duplicate-replay"); - let cwd = repo.cwd(); - let pre = pre_tool_use_for(&cwd, "session-1", "Write", "toolu_1", None); - - repo.drive(&pre).expect("first PreToolUse should succeed"); - assert_eq!( - repo.drive(&pre) - .expect("duplicate PreToolUse should be idempotent"), - "" - ); - assert_eq!( - repo.adapter_state().attempts.len(), - 1, - "AC4: duplicate PreToolUse delivery must reuse the same attempt" - ); - let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); - - fs::write(repo.root.join("file.txt"), "one\ntwo\n").expect("the edit should write"); - - let post = post_tool_use_for(&cwd, "session-1", "Write", "toolu_1", None); - repo.drive(&post).expect("first PostToolUse should succeed"); - - let db = repo.db(); - let (revision_before, events_before, processed_before) = ( - worktree_row(&db, &repo.worktree_id()) - .map(|(revision, _, _)| revision) - .expect("a worktree row should exist"), - count(&db, "mutation_trace_events"), - count(&db, "mutation_trace_processed_events"), - ); - - assert_eq!( - repo.drive(&post) - .expect("duplicate PostToolUse delivery must be a safe no-op"), - "" - ); - - let db = repo.db(); - assert_eq!( - worktree_row(&db, &repo.worktree_id()).map(|(revision, _, _)| revision), - Some(revision_before) - ); - assert_eq!(count(&db, "mutation_trace_events"), events_before); - assert_eq!( - count(&db, "mutation_trace_processed_events"), - processed_before - ); - assert_eq!( - processed_events(&db) - .into_iter() - .filter(|(scope, event)| scope == &scope_id - && event == &claude_scope_close_event_id(&scope_id)) - .count(), - 1 - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test5_auto_permission_denied_abandons_and_requires_rebaseline() { - let repo = ClaudeRepo::new("test5-permission-denied"); - let cwd = repo.cwd(); - - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_1", - None, - )) - .expect("PreToolUse should succeed"); - let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); - - assert_eq!( - repo.drive(&permission_denied_for( - &cwd, - "session-1", - "Write", - "toolu_1", - None - )) - .expect("PermissionDenied should succeed"), - "" - ); - - assert!( - repo.adapter_state().attempts.is_empty(), - "the denied attempt must be retired from adapter bookkeeping" - ); - assert!( - repo.adapter_state().recovery_pending, - "D19: abandonment must arm the recovery barrier" - ); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &scope_id), - Some(("claude_code".to_string(), "abandoned".to_string())) - ); - let worktree_id = repo.worktree_id(); - assert!( - worktree_row(&db, &worktree_id) - .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline), - "AC12: a denied execution must leave the worktree needing rebaseline" - ); - assert_eq!(count(&db, "mutation_trace_events"), 0); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test3_two_parallel_subagent_tools_produce_ai_contended() { - let repo = ClaudeRepo::new("test3-parallel-subagents"); - let cwd = repo.cwd(); - - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_a", - Some("agent-a"), - )) - .expect("agent-a PreToolUse should succeed"); - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_b", - Some("agent-b"), - )) - .expect("agent-b PreToolUse should succeed"); - assert_eq!(repo.adapter_state().attempts.len(), 2); - - fs::write(repo.root.join("file.txt"), "one\ncontended\n") - .expect("the racing edit should write"); - - repo.drive(&post_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_a", - Some("agent-a"), - )) - .expect("agent-a PostToolUse (closing while agent-b is still active) should succeed"); - repo.drive(&post_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_b", - Some("agent-b"), - )) - .expect("agent-b PostToolUse should succeed"); - - assert!(repo.adapter_state().attempts.is_empty()); - - let db = repo.db(); - let worktree_id = repo.worktree_id(); - assert_eq!( - mutation_events_for(&db, &worktree_id), - vec![("ai_contended".to_string(), None, "close".to_string())], - "AC11: a tree transition observed while two scopes are live must be AiContended" - ); - assert_eq!( - worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), - Some(repo.working_tree()) - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test6_other_hook_denial_is_retired_by_stop_cleanup() { - let repo = ClaudeRepo::new("test6-stop-cleanup"); - let cwd = repo.cwd(); - - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Bash", - "toolu_1", - None, - )) - .expect("PreToolUse should succeed"); - let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); - - assert_eq!( - repo.drive(&session_json(HOOK_EVENT_STOP, &cwd, "session-1")) - .expect("Stop should succeed"), - "" - ); - - assert!( - repo.adapter_state().attempts.is_empty(), - "AC13: Stop must retire the stale main-thread attempt" - ); - assert!(repo.adapter_state().recovery_pending); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &scope_id), - Some(("claude_code".to_string(), "abandoned".to_string())) - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test7_interrupted_main_turn_is_retired_by_next_user_prompt_submit() { - let repo = ClaudeRepo::new("test7-user-prompt-submit-cleanup"); - let cwd = repo.cwd(); - - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_1", - None, - )) - .expect("PreToolUse should succeed"); - let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); - - fs::write(repo.root.join("file.txt"), "one\ninterrupted\n") - .expect("the interrupted edit should write"); - - assert_eq!( - repo.drive(&session_json( - HOOK_EVENT_USER_PROMPT_SUBMIT, - &cwd, - "session-1" - )) - .expect("UserPromptSubmit should succeed"), - "" - ); - - assert!( - repo.adapter_state().attempts.is_empty(), - "AC14: UserPromptSubmit must retire the stale main-thread attempt \ - before another mutation-capable tool can start" - ); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &scope_id), - Some(("claude_code".to_string(), "abandoned".to_string())) - ); - let worktree_id = repo.worktree_id(); - assert!(worktree_row(&db, &worktree_id) - .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline)); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test8_resumed_subagent_tool_use_id_gets_a_fresh_scope_id() { - let repo = ClaudeRepo::new("test8-resumed-subagent"); - let cwd = repo.cwd(); - - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_resumed", - Some("agent-a"), - )) - .expect("first PreToolUse should succeed"); - let first_scope_id = repo.adapter_state().attempts[0].scope_id.clone(); - - fs::write(repo.root.join("file.txt"), "one\nfirst\n").expect("first edit should write"); - repo.drive(&post_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_resumed", - Some("agent-a"), - )) - .expect("first PostToolUse should succeed"); - assert!(repo.adapter_state().attempts.is_empty()); - - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_resumed", - Some("agent-a"), - )) - .expect("resumed PreToolUse should succeed"); - let second_scope_id = repo.adapter_state().attempts[0].scope_id.clone(); - - assert_ne!( - first_scope_id, second_scope_id, - "AC15: a resumed subagent's new tool attempt must receive a fresh ScopeId" - ); - - fs::write(repo.root.join("file.txt"), "one\nfirst\nsecond\n") - .expect("second edit should write"); - repo.drive(&post_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_resumed", - Some("agent-a"), - )) - .expect("second PostToolUse should succeed"); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &first_scope_id).map(|(_, status)| status), - Some("closed".to_string()) - ); - assert_eq!( - scope_status(&db, &second_scope_id).map(|(_, status)| status), - Some("closed".to_string()) - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test9_main_and_subagent_concurrent_mutation_is_ai_contended() { - let repo = ClaudeRepo::new("test9-main-plus-subagent"); - let cwd = repo.cwd(); - - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_main", - None, - )) - .expect("main-thread PreToolUse should succeed"); - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_sub", - Some("agent-a"), - )) - .expect("subagent PreToolUse should succeed"); - assert_eq!(repo.adapter_state().attempts.len(), 2); - - fs::write(repo.root.join("file.txt"), "one\nboth-writing\n") - .expect("the racing edit should write"); - - repo.drive(&post_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_main", - None, - )) - .expect("main-thread PostToolUse should succeed"); - repo.drive(&post_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_sub", - Some("agent-a"), - )) - .expect("subagent PostToolUse should succeed"); - - let db = repo.db(); - let worktree_id = repo.worktree_id(); - assert_eq!( - mutation_events_for(&db, &worktree_id), - vec![("ai_contended".to_string(), None, "close".to_string())], - "AC11: main + subagent concurrent mutation must be AiContended" - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test10_isolated_subagent_worktree_advances_only_its_own_cursor() { - let repo = ClaudeRepo::new("test10-isolated-worktree"); - let worktree_path = repo.add_worktree("subagent-worktree"); - let worktree_cwd = ClaudeRepo::cwd_at(&worktree_path); - - let main_worktree_id = repo.worktree_id(); - let sub_worktree_id = ClaudeRepo::worktree_id_at(&worktree_path); - assert_ne!( - main_worktree_id, sub_worktree_id, - "a linked worktree must resolve to a distinct WorktreeId" - ); - - repo.drive_flush() - .expect("main-checkout baseline flush should succeed"); - let main_cursor_before = worktree_row(&repo.db(), &main_worktree_id) - .map(|(_, cursor_tree, _)| cursor_tree) - .expect("main checkout should have a baseline worktree row"); - - repo.drive(&pre_tool_use_for( - &worktree_cwd, - "session-1", - "Write", - "toolu_sub", - Some("agent-a"), - )) - .expect("subagent PreToolUse in the isolated worktree should succeed"); - fs::write(worktree_path.join("file.txt"), "one\nisolated\n") - .expect("the isolated worktree's own edit should write"); - repo.drive(&post_tool_use_for( - &worktree_cwd, - "session-1", - "Write", - "toolu_sub", - Some("agent-a"), - )) - .expect("subagent PostToolUse in the isolated worktree should succeed"); - - let db = repo.db(); - assert_eq!( - worktree_row(&db, &main_worktree_id).map(|(_, cursor_tree, _)| cursor_tree), - Some(main_cursor_before), - "AC17: the main checkout's mutation cursor must be unchanged" - ); - let sub_row = worktree_row(&db, &sub_worktree_id).expect("subagent worktree row"); - assert_eq!( - sub_row.1, - ClaudeRepo::working_tree_at(&worktree_path), - "AC16/AC17: the isolated worktree's own cursor must advance" - ); - assert_eq!( - mutation_events_for(&db, &sub_worktree_id) - .into_iter() - .map(|(attribution, _, boundary)| (attribution, boundary)) - .collect::>(), - vec![("ai_exclusive".to_string(), "close".to_string())] - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test11_worktree_remove_cleans_only_that_worktrees_outstanding_attempt() { - let repo = ClaudeRepo::new("test11-worktree-remove"); - let worktree_path = repo.add_worktree("removed-worktree"); - let worktree_cwd = ClaudeRepo::cwd_at(&worktree_path); - let main_cwd = repo.cwd(); - - repo.drive(&pre_tool_use_for( - &main_cwd, - "session-1", - "Write", - "toolu_main", - None, - )) - .expect("main-thread PreToolUse should succeed"); - repo.drive(&pre_tool_use_for( - &worktree_cwd, - "session-1", - "Write", - "toolu_sub", - Some("agent-a"), - )) - .expect("subagent PreToolUse in the isolated worktree should succeed"); - - assert_eq!(ClaudeRepo::adapter_state_at(&repo.root).attempts.len(), 1); - assert_eq!( - ClaudeRepo::adapter_state_at(&worktree_path).attempts.len(), - 1 - ); - - assert_eq!( - repo.drive(&worktree_remove_json("session-1", &worktree_cwd)) - .expect("WorktreeRemove should succeed"), - "" - ); - - assert!( - ClaudeRepo::adapter_state_at(&worktree_path) - .attempts - .is_empty(), - "AC13/D22: WorktreeRemove must retire the outstanding attempt for that worktree" - ); - assert_eq!( - ClaudeRepo::adapter_state_at(&repo.root).attempts.len(), - 1, - "WorktreeRemove for one worktree must not touch the main checkout's attempts" - ); - - assert_raw_agent_trace_tables_untouched(&repo.db()); - } - - #[test] - fn test12_pending_start_crash_before_start_is_recovered_conservatively() { - let repo = ClaudeRepo::new("test12-pending-start-crash"); - let cwd = repo.cwd(); - let git_dir = repo.git_dir(); - - let key = AttemptKey { - session_id: "session-1".to_string(), - agent_id: None, - tool_use_id: "toolu_crashed".to_string(), - }; - let allocated = state::allocate_attempt(&git_dir, &key, "Write") - .expect("allocation should succeed"); - assert_eq!(allocated.attempt.phase, state::AttemptPhase::PendingStart); - - assert_eq!( - repo.drive(&post_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_crashed", - None - )) - .expect("D11: PostToolUse on a pending_start attempt must abandon, not late-start"), - "" - ); - - assert!( - repo.adapter_state().attempts.is_empty(), - "the never-started attempt must be retired" - ); - assert!(repo.adapter_state().recovery_pending); - let db = repo.db(); - assert_eq!( - scope_status(&db, &allocated.attempt.scope_id), - None, - "a Start that never committed must never appear as a real scope" - ); - assert_eq!(count(&db, "mutation_trace_events"), 0); - - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_fresh", - None, - )) - .expect("the next PreToolUse should proceed after the quiescent flush"); - assert!(!repo.adapter_state().recovery_pending); - assert_eq!(repo.adapter_state().attempts.len(), 1); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test13_start_committed_before_state_settlement_is_recovered_by_abandonment() { - let repo = ClaudeRepo::new("test13-start-committed-crash"); - let cwd = repo.cwd(); - let git_dir = repo.git_dir(); - - let key = AttemptKey { - session_id: "session-1".to_string(), - agent_id: None, - tool_use_id: "toolu_crashed".to_string(), - }; - let allocated = state::allocate_attempt(&git_dir, &key, "Write") - .expect("allocation should succeed"); - let scope_id = allocated.attempt.scope_id.clone(); - - repo.drive_generic(&scope_boundary_payload( - "start", - &scope_id, - &claude_scope_start_event_id(&scope_id), - )) - .expect("the runtime Start should commit durably"); - assert_eq!( - state::read_state(&git_dir).unwrap().attempts[0].phase, - state::AttemptPhase::PendingStart - ); - - assert_eq!( - repo.drive(&post_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_crashed", - None - )) - .expect("D11: a pending_start attempt with a committed Start must be abandoned"), - "" - ); - - assert!(repo.adapter_state().attempts.is_empty()); - assert!(repo.adapter_state().recovery_pending); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &scope_id), - Some(("claude_code".to_string(), "abandoned".to_string())), - "the runtime's own committed Start must settle as a real abandonment" - ); - let worktree_id = repo.worktree_id(); - assert!(worktree_row(&db, &worktree_id) - .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline)); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test14_terminal_runtime_success_before_state_cleanup_is_replay_safe() { - let repo = ClaudeRepo::new("test14-close-committed-crash"); - let cwd = repo.cwd(); - let git_dir = repo.git_dir(); - - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_1", - None, - )) - .expect("PreToolUse should succeed"); - let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); - fs::write(repo.root.join("file.txt"), "one\ntwo\n").expect("the edit should write"); - - repo.drive_generic(&scope_boundary_payload( - "close", - &scope_id, - &claude_scope_close_event_id(&scope_id), - )) - .expect("the runtime Close should commit durably"); - assert_eq!(state::read_state(&git_dir).unwrap().attempts.len(), 1); - - let db = repo.db(); - let (revision_before, events_before) = ( - worktree_row(&db, &repo.worktree_id()) - .map(|(revision, _, _)| revision) - .expect("a worktree row should exist"), - count(&db, "mutation_trace_events"), - ); - - assert_eq!( - repo.drive(&post_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_1", - None - )) - .expect("a replayed Close against an already-durable commit must be safe"), - "" - ); - - assert!( - repo.adapter_state().attempts.is_empty(), - "the stale bookkeeping must finally be cleared" - ); - - let db = repo.db(); - assert_eq!( - worktree_row(&db, &repo.worktree_id()).map(|(revision, _, _)| revision), - Some(revision_before), - "a durably completed Close must never be re-applied as a second transition" - ); - assert_eq!(count(&db, "mutation_trace_events"), events_before); - assert_eq!( - scope_status(&db, &scope_id).map(|(_, status)| status), - Some("closed".to_string()) - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test15_explicit_background_bash_is_denied_with_no_scope() { - let repo = ClaudeRepo::new("test15-explicit-background-bash"); - let cwd = repo.cwd(); - - let output = repo - .drive(&background_pre_tool_use_for( - &cwd, - "session-1", - "toolu_1", - None, - )) - .expect("an explicit background shell must still return Ok with a deny payload"); - - assert_eq!( - output, - pre_tool_use_deny_json(EXPLICIT_BACKGROUND_SHELL_DENY_REASON) - ); - assert!( - repo.adapter_state().attempts.is_empty(), - "AC21: an explicit background shell must create no scope" - ); - - let db = repo.db(); - assert_eq!(count(&db, "mutation_trace_scopes"), 0); - assert_eq!(count(&db, "mutation_trace_events"), 0); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test18_model_switch_does_not_rewrite_scope_provenance() { - let repo = ClaudeRepo::new("model-switch-provenance"); - let session_id = "session-model-switch"; - let db = repo.db(); - db.upsert_claude_model_state(ClaudeModelStateObservation { - session_id: "cc_session-model-switch".to_string(), - agent_id: String::new(), - model_id: "claude/sonnet".to_string(), - observation_kind: ObservationKind::SessionStart, - source: "test".to_string(), - observed_at_ms: 1, - }) - .expect("initial Claude model state should persist"); - - let pre_payload = - pre_tool_use_for(&repo.cwd(), session_id, "Bash", "toolu_model_switch", None); - repo.drive(&pre_payload) - .expect("initial PreToolUse should establish a scope"); - let scope_id = repo - .adapter_state() - .attempts - .first() - .expect("the scope should remain live") - .scope_id - .clone(); - let before = scope_provenance(&repo.db(), &scope_id); - assert_eq!( - before, - Some(( - "cc_session-model-switch".to_string(), - Some("claude/sonnet".to_string()), - )) - ); - - repo.db() - .upsert_claude_model_state(ClaudeModelStateObservation { - session_id: "cc_session-model-switch".to_string(), - agent_id: String::new(), - model_id: "claude/opus".to_string(), - observation_kind: ObservationKind::PostModelSwitch, - source: "test".to_string(), - observed_at_ms: 2, - }) - .expect("model switch should persist"); - repo.drive(&pre_payload) - .expect("replayed PreToolUse should remain idempotent"); - - assert_eq!(scope_provenance(&repo.db(), &scope_id), before); - } - - #[test] - fn test16_regression_matrix_leaves_raw_agent_trace_tables_untouched() { - let repo = ClaudeRepo::new("test16-raw-tables-untouched"); - let cwd = repo.cwd(); - - let before = { - let db = repo.db(); - ( - count(&db, "diff_traces"), - count(&db, "post_commit_patch_intersections"), - count(&db, "agent_traces"), - ) - }; - assert_eq!(before, (0, 0, 0)); - - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_1", - None, - )) - .expect("PreToolUse should succeed"); - fs::write(repo.root.join("file.txt"), "one\ntwo\n").expect("the edit should write"); - repo.drive(&post_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_1", - None, - )) - .expect("PostToolUse should succeed"); - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Write", - "toolu_2", - None, - )) - .expect("second PreToolUse should succeed"); - repo.drive(&permission_denied_for( - &cwd, - "session-1", - "Write", - "toolu_2", - None, - )) - .expect("PermissionDenied should succeed"); - - let db = repo.db(); - let after = ( - count(&db, "diff_traces"), - count(&db, "post_commit_patch_intersections"), - count(&db, "agent_traces"), - ); - assert_eq!( - after, - (0, 0, 0), - "AC20: Claude mutation-scope-only regressions must leave the raw \ - Agent Trace tables unchanged" - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test17_detached_descendant_write_after_post_tool_use_is_not_folded_into_the_closed_scope( - ) { - let repo = ClaudeRepo::new("test17-detached-descendant"); - let cwd = repo.cwd(); - - repo.drive(&pre_tool_use_for( - &cwd, - "session-1", - "Bash", - "toolu_1", - None, - )) - .expect("PreToolUse should succeed"); - let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); - - fs::write(repo.root.join("file.txt"), "one\nforeground-output\n") - .expect("the tool's own foreground write should write"); - let tree_at_close = repo.working_tree(); - - repo.drive(&post_tool_use_for( - &cwd, - "session-1", - "Bash", - "toolu_1", - None, - )) - .expect("PostToolUse should succeed"); - - let db = repo.db(); - let worktree_id = repo.worktree_id(); - assert_eq!( - scope_status(&db, &scope_id).map(|(_, status)| status), - Some("closed".to_string()) - ); - assert_eq!( - worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), - Some(tree_at_close.clone()), - "the scope must close at the tool's own observed tree" - ); - - fs::write( - repo.root.join("file.txt"), - "one\nforeground-output\ndetached-descendant\n", - ) - .expect("the detached descendant's later write should write"); - let tree_after_descendant = repo.working_tree(); - assert_ne!(tree_after_descendant, tree_at_close); - - let events_before_flush = mutation_events_for(&db, &worktree_id); - - repo.drive_flush() - .expect("a later recovery/diagnostic flush should succeed"); - - let db = repo.db(); - let events_after_flush = mutation_events_for(&db, &worktree_id); - assert_eq!( - events_after_flush.len(), - events_before_flush.len() + 1, - "the detached descendant's mutation must surface as its own event" - ); - let (attribution_kind, attribution_scope_id, _) = events_after_flush - .last() - .expect("a flush event should exist"); - assert_ne!( - attribution_scope_id.as_deref(), - Some(scope_id.as_str()), - "the detached descendant's mutation must never be attributed to the \ - already-closed tool scope" - ); - assert_eq!(attribution_kind, "ineligible_unscoped"); - assert_eq!( - worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), - Some(tree_after_descendant) - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - } -} +#[cfg(test)] +mod tests; diff --git a/cli/src/services/hooks/claude_mutation_scope/payload.rs b/cli/src/services/hooks/claude_mutation_scope/payload.rs new file mode 100644 index 000000000..3bf7605e8 --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/payload.rs @@ -0,0 +1,55 @@ +use serde_json::json; + +use super::lifecycle::ACTOR_KIND_CLAUDE_CODE; + +pub(super) fn scope_boundary_payload(operation: &str, scope_id: &str, event_id: &str) -> String { + json!({ + "operation": operation, + "scope_id": scope_id, + "event_id": event_id, + "actor_kind": ACTOR_KIND_CLAUDE_CODE, + }) + .to_string() +} + +pub(super) fn scope_start_payload( + scope_id: &str, + event_id: &str, + session_id: &str, + model_id: Option<&str>, +) -> String { + json!({ + "operation": "start", + "scope_id": scope_id, + "event_id": event_id, + "actor_kind": ACTOR_KIND_CLAUDE_CODE, + "provenance": { + "session_id": session_id, + "model_id": model_id, + }, + }) + .to_string() +} + +pub(super) fn abandon_payload(scope_id: &str) -> String { + json!({ + "operation": "abandon", + "scope_id": scope_id, + }) + .to_string() +} + +pub(super) fn flush_payload() -> String { + json!({ "operation": "flush" }).to_string() +} + +pub(super) fn pre_tool_use_deny_json(reason: &str) -> String { + json!({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } + }) + .to_string() +} diff --git a/cli/src/services/hooks/claude_mutation_scope/state.rs b/cli/src/services/hooks/claude_mutation_scope/state.rs index ad19ef48a..08cc668c0 100644 --- a/cli/src/services/hooks/claude_mutation_scope/state.rs +++ b/cli/src/services/hooks/claude_mutation_scope/state.rs @@ -22,6 +22,7 @@ const ADAPTER_STATE_VERSION: u32 = 1; pub(crate) enum AttemptPhase { PendingStart, Active, + PendingAbandon, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] @@ -313,10 +314,72 @@ pub(crate) fn mark_active(git_dir: &Path, scope_id: &str) -> Result<()> { .iter_mut() .find(|attempt| attempt.scope_id == scope_id) .ok_or_else(|| anyhow!("No adapter-state attempt found for scope_id '{scope_id}'"))?; - attempt.phase = AttemptPhase::Active; + + match attempt.phase { + AttemptPhase::PendingStart => { + attempt.phase = AttemptPhase::Active; + } + AttemptPhase::Active => return Ok(()), + AttemptPhase::PendingAbandon => { + return Err(anyhow!( + "Cannot mark mutation-scope attempt '{scope_id}' active after abandonment was established" + )); + } + } + + write_state_durably(git_dir, &state) +} + +fn transition_to_pending_abandon(state: &mut AdapterState, scope_ids: &[String]) { + for attempt in &mut state.attempts { + if scope_ids + .iter() + .any(|scope_id| scope_id == &attempt.scope_id) + { + attempt.phase = AttemptPhase::PendingAbandon; + } + } +} + +pub(crate) fn mark_recovery_pending_and_pending_abandon( + git_dir: &Path, + scope_ids: &[String], +) -> Result<()> { + if scope_ids.is_empty() { + return Ok(()); + } + + let _lock = AdapterStateLock::acquire(git_dir, DEFAULT_LOCK_TIMEOUT) + .map_err(|err| anyhow!("Failed to acquire adapter-state lock: {err}"))?; + + let mut state = read_state(git_dir)?; + state.recovery_pending = true; + transition_to_pending_abandon(&mut state, scope_ids); write_state_durably(git_dir, &state) } +pub(crate) fn reprove_pending_abandon(git_dir: &Path) -> Result>> { + let _lock = AdapterStateLock::acquire(git_dir, DEFAULT_LOCK_TIMEOUT) + .map_err(|err| anyhow!("Failed to acquire adapter-state lock: {err}"))?; + + let state = read_state(git_dir)?; + + if !state.recovery_pending || state.attempts.is_empty() { + return Ok(None); + } + + let every_attempt_is_pending_abandon = state + .attempts + .iter() + .all(|attempt| attempt.phase == AttemptPhase::PendingAbandon); + + if !every_attempt_is_pending_abandon { + return Ok(None); + } + + Ok(Some(state.attempts)) +} + pub(crate) fn remove_attempt(git_dir: &Path, scope_id: &str) -> Result<()> { let _lock = AdapterStateLock::acquire(git_dir, DEFAULT_LOCK_TIMEOUT) .map_err(|err| anyhow!("Failed to acquire adapter-state lock: {err}"))?; @@ -341,13 +404,59 @@ pub(crate) fn mark_recovery_pending(git_dir: &Path) -> Result<()> { write_state_durably(git_dir, &state) } -pub(crate) fn clear_recovery_pending(git_dir: &Path) -> Result<()> { +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ClearRecoveryOutcome { + Cleared, + StillPending, +} + +pub(crate) fn clear_recovery_pending_if_quiescent(git_dir: &Path) -> Result { + clear_recovery_pending_if_quiescent_inner(git_dir, |_, _| Ok(())) +} + +fn clear_recovery_pending_if_quiescent_inner( + git_dir: &Path, + before_rename: F, +) -> Result +where + F: FnOnce(&Path, &Path) -> Result<()>, +{ let _lock = AdapterStateLock::acquire(git_dir, DEFAULT_LOCK_TIMEOUT) .map_err(|err| anyhow!("Failed to acquire adapter-state lock: {err}"))?; let mut state = read_state(git_dir)?; + + if !state.recovery_pending { + return Ok(ClearRecoveryOutcome::Cleared); + } + + if !state.attempts.is_empty() { + return Ok(ClearRecoveryOutcome::StillPending); + } + state.recovery_pending = false; - write_state_durably(git_dir, &state) + write_state_durably_inner(git_dir, &state, before_rename)?; + Ok(ClearRecoveryOutcome::Cleared) +} + +#[cfg(test)] +pub(crate) fn set_attempt_phase_for_tests( + git_dir: &Path, + scope_id: &str, + phase: AttemptPhase, +) -> AdapterAttempt { + let _lock = + AdapterStateLock::acquire(git_dir, DEFAULT_LOCK_TIMEOUT).expect("test phase-override lock"); + let mut state = read_state(git_dir).expect("test phase-override read"); + let attempt = state + .attempts + .iter_mut() + .find(|attempt| attempt.scope_id == scope_id) + .expect("attempt to override must already exist"); + attempt.phase = phase; + let updated = attempt.clone(); + write_state_durably(git_dir, &state).expect("test phase-override write"); + updated } #[cfg(test)] @@ -510,6 +619,117 @@ mod tests { remove_test_git_dir(&git_dir); } + #[test] + fn mark_active_is_idempotent_when_already_active() { + let git_dir = unique_test_git_dir("mark-active-idempotent"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let allocated = allocate_attempt(&git_dir, &key("session-1", None, "toolu_1"), "Write") + .expect("allocation should succeed"); + mark_active(&git_dir, &allocated.attempt.scope_id) + .expect("first activation should succeed"); + + mark_active(&git_dir, &allocated.attempt.scope_id) + .expect("re-delivery of PreToolUse after activation must be a safe idempotent no-op"); + + let state = read_state(&git_dir).expect("state should be readable"); + assert_eq!(state.attempts[0].phase, AttemptPhase::Active); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn mark_active_is_forbidden_once_pending_abandon_is_established() { + let git_dir = unique_test_git_dir("mark-active-forbidden-pending-abandon"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let allocated = allocate_attempt(&git_dir, &key("session-1", None, "toolu_1"), "Write") + .expect("allocation should succeed"); + + mark_recovery_pending_and_pending_abandon( + &git_dir, + std::slice::from_ref(&allocated.attempt.scope_id), + ) + .expect("atomic establishment should succeed"); + + let error = mark_active(&git_dir, &allocated.attempt.scope_id) + .expect_err("PendingAbandon -> Active must be forbidden"); + assert!(error.to_string().contains("abandon")); + + let state = read_state(&git_dir).expect("state should be readable"); + assert_eq!( + state.attempts[0].phase, + AttemptPhase::PendingAbandon, + "a rejected activation must not mutate the persisted phase" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn mark_recovery_pending_and_pending_abandon_establishes_both_facts_in_one_durable_write() { + let git_dir = unique_test_git_dir("atomic-recovery-and-pending-abandon"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let allocated = allocate_attempt(&git_dir, &key("session-1", None, "toolu_1"), "Write") + .expect("allocation should succeed"); + mark_active(&git_dir, &allocated.attempt.scope_id).expect("attempt should become active"); + + mark_recovery_pending_and_pending_abandon( + &git_dir, + std::slice::from_ref(&allocated.attempt.scope_id), + ) + .expect("atomic establishment should succeed"); + + let state = read_state(&git_dir).expect("state should be readable"); + assert!( + state.recovery_pending, + "T04: the recovery barrier must be armed by the atomic operation" + ); + assert_eq!( + state.attempts[0].phase, + AttemptPhase::PendingAbandon, + "T04: abandonment evidence must be established alongside the barrier in the same write" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn mark_recovery_pending_and_pending_abandon_failed_write_leaves_no_partial_invariant() { + let git_dir = unique_test_git_dir("atomic-injected-failure"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let allocated = allocate_attempt(&git_dir, &key("session-1", None, "toolu_1"), "Write") + .expect("allocation should succeed"); + mark_active(&git_dir, &allocated.attempt.scope_id).expect("attempt should become active"); + + let mut state = read_state(&git_dir).expect("state should be readable"); + state.recovery_pending = true; + transition_to_pending_abandon( + &mut state, + std::slice::from_ref(&allocated.attempt.scope_id), + ); + + let result = write_state_durably_inner(&git_dir, &state, |_, _| { + Err(anyhow!("injected interruption before rename")) + }); + assert!( + result.is_err(), + "write_state_durably_inner should surface the injected interruption" + ); + + let after = + read_state(&git_dir).expect("state should be readable after the injected failure"); + assert!( + !after.recovery_pending, + "an interrupted write must not leave the barrier half-armed" + ); + assert_eq!( + after.attempts[0].phase, + AttemptPhase::Active, + "an interrupted write must not leave the attempt half-transitioned to PendingAbandon" + ); + + remove_test_git_dir(&git_dir); + } + #[test] fn removing_an_already_removed_attempt_is_a_safe_no_op() { let git_dir = unique_test_git_dir("remove-idempotent"); @@ -738,12 +958,14 @@ mod tests { } #[test] - fn clear_recovery_pending_resets_the_barrier() { - let git_dir = unique_test_git_dir("clear-recovery-pending"); + fn clear_recovery_pending_if_quiescent_resets_the_barrier_when_no_attempts_remain() { + let git_dir = unique_test_git_dir("clear-recovery-pending-quiescent"); std::fs::create_dir_all(&git_dir).expect("git dir should be created"); mark_recovery_pending(&git_dir).expect("marking recovery pending should succeed"); - clear_recovery_pending(&git_dir).expect("clearing the barrier should succeed"); + let outcome = clear_recovery_pending_if_quiescent(&git_dir) + .expect("clearing the barrier should succeed"); + assert_eq!(outcome, ClearRecoveryOutcome::Cleared); let state = read_state(&git_dir).expect("state should be readable"); assert!(!state.recovery_pending); @@ -751,6 +973,92 @@ mod tests { remove_test_git_dir(&git_dir); } + #[test] + fn clear_recovery_pending_if_quiescent_is_a_no_op_when_recovery_is_already_clear() { + let git_dir = unique_test_git_dir("clear-recovery-pending-already-clear"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + let outcome = clear_recovery_pending_if_quiescent(&git_dir) + .expect("clearing an already-clear barrier should succeed"); + assert_eq!(outcome, ClearRecoveryOutcome::Cleared); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn clear_recovery_pending_if_quiescent_leaves_recovery_armed_when_an_attempt_remains() { + let git_dir = unique_test_git_dir("clear-recovery-pending-still-pending"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let allocated = allocate_attempt(&git_dir, &key("session-1", None, "toolu_1"), "Write") + .expect("allocation should succeed"); + mark_recovery_pending_and_pending_abandon( + &git_dir, + std::slice::from_ref(&allocated.attempt.scope_id), + ) + .expect("atomic establishment should succeed"); + + let outcome = clear_recovery_pending_if_quiescent(&git_dir) + .expect("proving quiescence should not error"); + assert_eq!(outcome, ClearRecoveryOutcome::StillPending); + + let state = read_state(&git_dir).expect("state should be readable"); + assert!( + state.recovery_pending, + "recovery must remain armed when an unresolved attempt survives the proof" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn clear_recovery_pending_if_quiescent_interrupted_before_rename_keeps_recovery_armed() { + let git_dir = unique_test_git_dir("clear-recovery-pending-interrupted-before-rename"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + mark_recovery_pending(&git_dir).expect("marking recovery pending should succeed"); + + let result = + clear_recovery_pending_if_quiescent_inner(&git_dir, |tmp_path, canonical_path| { + assert!( + tmp_path.exists(), + "temp replacement file should exist by the time the pre-rename hook runs" + ); + assert!( + canonical_path.exists(), + "canonical state file should still exist at the pre-rename hook" + ); + Err(anyhow!("injected interruption before rename")) + }); + + assert!( + result.is_err(), + "an interrupted durable write must surface the injected error" + ); + + let after = + read_state(&git_dir).expect("state should be readable after the injected interruption"); + assert!( + after.recovery_pending, + "an interrupted clear must leave the canonical state fail-closed with recovery armed" + ); + assert!( + after.attempts.is_empty(), + "the interrupted clear must not fabricate or lose attempt bookkeeping" + ); + + let outcome = clear_recovery_pending_if_quiescent(&git_dir) + .expect("a retried clear should succeed once nothing interrupts the rename"); + assert_eq!(outcome, ClearRecoveryOutcome::Cleared); + + let final_state = + read_state(&git_dir).expect("state should be readable after the successful retry"); + assert!( + !final_state.recovery_pending, + "the retried clear must durably disarm the barrier" + ); + + remove_test_git_dir(&git_dir); + } + #[test] fn adapter_state_files_live_only_below_git_dir_sce() { let git_dir = unique_test_git_dir("path-boundary"); diff --git a/cli/src/services/hooks/claude_mutation_scope/tests.rs b/cli/src/services/hooks/claude_mutation_scope/tests.rs new file mode 100644 index 000000000..c2b407c9c --- /dev/null +++ b/cli/src/services/hooks/claude_mutation_scope/tests.rs @@ -0,0 +1,3506 @@ +use super::*; + +fn pre_tool_use_json(overrides: &[(&str, Value)]) -> String { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(HOOK_EVENT_PRE_TOOL_USE.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("session-1".to_string()), + ); + object.insert( + CWD_FIELD.to_string(), + Value::String("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/repo/checkout".to_string()), + ); + object.insert( + TOOL_NAME_FIELD.to_string(), + Value::String("Write".to_string()), + ); + object.insert( + TOOL_USE_ID_FIELD.to_string(), + Value::String("toolu_1".to_string()), + ); + for (field, value) in overrides { + object.insert((*field).to_string(), value.clone()); + } + Value::Object(object).to_string() +} + +fn identity(session_id: &str, agent_id: Option<&str>, tool_use_id: &str) -> AttemptKey { + AttemptKey { + session_id: session_id.to_string(), + agent_id: agent_id.map(str::to_string), + tool_use_id: tool_use_id.to_string(), + } +} + +#[test] +fn pre_tool_use_parses_required_and_optional_fields() { + let payload = pre_tool_use_json(&[ + (AGENT_ID_FIELD, Value::String("agent-1".to_string())), + (PROMPT_ID_FIELD, Value::String("prompt-1".to_string())), + ( + AGENT_TYPE_FIELD, + Value::String("general-purpose".to_string()), + ), + ]); + + let event = parse_claude_hook_event(&payload).expect("valid PreToolUse parses"); + let ClaudeHookEvent::PreToolUse(execution) = event else { + panic!("expected PreToolUse"); + }; + + assert_eq!(execution.identity.session_id, "session-1"); + assert_eq!(execution.identity.cwd, "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/repo/checkout"); + assert_eq!(execution.identity.tool_name, "Write"); + assert_eq!(execution.identity.tool_use_id, "toolu_1"); + assert_eq!(execution.identity.agent_id.as_deref(), Some("agent-1")); + assert_eq!(execution.prompt_id.as_deref(), Some("prompt-1")); + assert_eq!(execution.agent_type.as_deref(), Some("general-purpose")); + assert!(!execution.run_in_background); +} + +#[test] +fn pre_tool_use_agent_id_absent_means_main_thread() { + let payload = pre_tool_use_json(&[]); + + let event = parse_claude_hook_event(&payload).unwrap(); + let ClaudeHookEvent::PreToolUse(execution) = event else { + panic!("expected PreToolUse"); + }; + + assert_eq!(execution.identity.agent_id, None); + assert!(!execution.identity.is_subagent()); +} + +#[test] +fn pre_tool_use_agent_id_present_means_subagent() { + let payload = pre_tool_use_json(&[(AGENT_ID_FIELD, Value::String("agent-1".to_string()))]); + + let event = parse_claude_hook_event(&payload).unwrap(); + let ClaudeHookEvent::PreToolUse(execution) = event else { + panic!("expected PreToolUse"); + }; + + assert!(execution.identity.is_subagent()); +} + +#[test] +fn pre_tool_use_prompt_id_and_agent_type_are_optional() { + let payload = pre_tool_use_json(&[]); + + let event = parse_claude_hook_event(&payload).unwrap(); + let ClaudeHookEvent::PreToolUse(execution) = event else { + panic!("expected PreToolUse"); + }; + + assert_eq!(execution.prompt_id, None); + assert_eq!(execution.agent_type, None); +} + +#[test] +fn missing_required_fields_are_rejected_without_fabricating_identity() { + for field in [ + SESSION_ID_FIELD, + CWD_FIELD, + TOOL_NAME_FIELD, + TOOL_USE_ID_FIELD, + ] { + let mut object: serde_json::Map = + serde_json::from_str(&pre_tool_use_json(&[])).unwrap(); + object.remove(field); + let payload = Value::Object(object).to_string(); + + let error = parse_claude_hook_event(&payload).unwrap_err(); + assert!( + error.to_string().contains(&format!("'{field}'")), + "expected missing-field error to name '{field}', got: {error}" + ); + } +} + +#[test] +fn wrong_type_required_field_is_rejected() { + let payload = pre_tool_use_json(&[(SESSION_ID_FIELD, Value::from(42))]); + + let error = parse_claude_hook_event(&payload).unwrap_err(); + assert!(error.to_string().contains("session_id")); +} + +#[test] +fn empty_string_required_field_is_rejected() { + let payload = pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String(String::new()))]); + + let error = parse_claude_hook_event(&payload).unwrap_err(); + assert!(error.to_string().contains("non-blank")); +} + +#[test] +fn wrong_type_optional_field_is_rejected() { + let payload = pre_tool_use_json(&[(PROMPT_ID_FIELD, Value::from(1))]); + + let error = parse_claude_hook_event(&payload).unwrap_err(); + assert!(error.to_string().contains("prompt_id")); +} + +#[test] +fn empty_optional_field_is_rejected() { + let payload = pre_tool_use_json(&[(AGENT_ID_FIELD, Value::String(String::new()))]); + + let error = parse_claude_hook_event(&payload).unwrap_err(); + assert!(error.to_string().contains("agent_id")); +} + +#[test] +fn null_optional_field_is_none() { + let payload = pre_tool_use_json(&[(AGENT_ID_FIELD, Value::Null)]); + + let event = parse_claude_hook_event(&payload).unwrap(); + let ClaudeHookEvent::PreToolUse(execution) = event else { + panic!("expected PreToolUse"); + }; + assert_eq!(execution.identity.agent_id, None); +} + +#[test] +fn empty_payload_is_rejected() { + let error = parse_claude_hook_event("").unwrap_err(); + assert!(error.to_string().contains("empty payload")); + + let error = parse_claude_hook_event(" ").unwrap_err(); + assert!(error.to_string().contains("empty payload")); +} + +#[test] +fn malformed_json_is_rejected() { + let error = parse_claude_hook_event("{not json").unwrap_err(); + assert!(error.to_string().contains("valid JSON")); +} + +#[test] +fn non_object_json_is_rejected() { + let error = parse_claude_hook_event("[1, 2, 3]").unwrap_err(); + assert!(error.to_string().contains("JSON object")); +} + +#[test] +fn unsupported_hook_event_name_is_rejected() { + let payload = pre_tool_use_json(&[( + HOOK_EVENT_NAME_FIELD, + Value::String("PostToolBatch".to_string()), + )]); + + let error = parse_claude_hook_event(&payload).unwrap_err(); + assert!(error.to_string().contains("unsupported hook_event_name")); +} + +#[test] +fn post_tool_use_and_failure_and_permission_denied_share_tool_identity_shape() { + for event_name in [ + HOOK_EVENT_POST_TOOL_USE, + HOOK_EVENT_POST_TOOL_USE_FAILURE, + HOOK_EVENT_PERMISSION_DENIED, + ] { + let payload = + pre_tool_use_json(&[(HOOK_EVENT_NAME_FIELD, Value::String(event_name.to_string()))]); + + let event = parse_claude_hook_event(&payload).unwrap(); + let identity = match event { + ClaudeHookEvent::PostToolUse(identity) + | ClaudeHookEvent::PostToolUseFailure(identity) + | ClaudeHookEvent::PermissionDenied(identity) => identity, + other => panic!("expected a tool-identity event, got {other:?}"), + }; + assert_eq!(identity.session_id, "session-1"); + assert_eq!(identity.tool_use_id, "toolu_1"); + } +} + +#[test] +fn session_scoped_lifecycle_events_parse_session_identity() { + for event_name in [ + HOOK_EVENT_STOP, + HOOK_EVENT_STOP_FAILURE, + HOOK_EVENT_USER_PROMPT_SUBMIT, + HOOK_EVENT_SESSION_END, + ] { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(event_name.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("session-1".to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String("/repo".to_string())); + let payload = Value::Object(object).to_string(); + + let event = parse_claude_hook_event(&payload).unwrap(); + let identity = match event { + ClaudeHookEvent::Stop(identity) + | ClaudeHookEvent::StopFailure(identity) + | ClaudeHookEvent::UserPromptSubmit(identity) + | ClaudeHookEvent::SessionEnd(identity) => identity, + other => panic!("expected a session-identity event, got {other:?}"), + }; + assert_eq!(identity.session_id, "session-1"); + assert_eq!(identity.cwd, "/repo"); + } +} + +#[test] +fn subagent_stop_requires_agent_id() { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(HOOK_EVENT_SUBAGENT_STOP.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("session-1".to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String("/repo".to_string())); + let payload = Value::Object(object).to_string(); + + let error = parse_claude_hook_event(&payload).unwrap_err(); + assert!(error.to_string().contains("agent_id")); +} + +#[test] +fn subagent_stop_parses_agent_identity() { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(HOOK_EVENT_SUBAGENT_STOP.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("session-1".to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String("/repo".to_string())); + object.insert( + AGENT_ID_FIELD.to_string(), + Value::String("agent-1".to_string()), + ); + let payload = Value::Object(object).to_string(); + + let event = parse_claude_hook_event(&payload).unwrap(); + let ClaudeHookEvent::SubagentStop(identity) = event else { + panic!("expected SubagentStop"); + }; + assert_eq!(identity.agent_id, "agent-1"); +} + +#[test] +fn worktree_remove_requires_worktree_path_not_cwd() { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(HOOK_EVENT_WORKTREE_REMOVE.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("session-1".to_string()), + ); + object.insert( + WORKTREE_PATH_FIELD.to_string(), + Value::String("/repo/.claude/worktrees/agent-1".to_string()), + ); + let payload = Value::Object(object).to_string(); + + let event = parse_claude_hook_event(&payload).unwrap(); + let ClaudeHookEvent::WorktreeRemove(worktree_remove) = event else { + panic!("expected WorktreeRemove"); + }; + assert_eq!(worktree_remove.session_id, "session-1"); + assert_eq!( + worktree_remove.worktree_path, + "/repo/.claude/worktrees/agent-1" + ); +} + +#[test] +fn session_start_and_subagent_start_establish_no_scope_payload() { + for event_name in [HOOK_EVENT_SESSION_START, HOOK_EVENT_SUBAGENT_START] { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(event_name.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("session-1".to_string()), + ); + let payload = Value::Object(object).to_string(); + + let event = parse_claude_hook_event(&payload).unwrap(); + assert!(matches!( + event, + ClaudeHookEvent::SessionStart | ClaudeHookEvent::SubagentStart + )); + } +} + +#[test] +fn run_in_background_true_is_parsed() { + let payload = pre_tool_use_json(&[( + TOOL_INPUT_FIELD, + serde_json::json!({ "run_in_background": true }), + )]); + + let event = parse_claude_hook_event(&payload).unwrap(); + let ClaudeHookEvent::PreToolUse(execution) = event else { + panic!("expected PreToolUse"); + }; + assert!(execution.run_in_background); +} + +#[test] +fn run_in_background_false_is_parsed() { + let payload = pre_tool_use_json(&[( + TOOL_INPUT_FIELD, + serde_json::json!({ "run_in_background": false }), + )]); + + let event = parse_claude_hook_event(&payload).unwrap(); + let ClaudeHookEvent::PreToolUse(execution) = event else { + panic!("expected PreToolUse"); + }; + assert!(!execution.run_in_background); +} + +#[test] +fn run_in_background_absent_defaults_to_false() { + let payload = pre_tool_use_json(&[( + TOOL_INPUT_FIELD, + serde_json::json!({ "command": "echo hi" }), + )]); + + let event = parse_claude_hook_event(&payload).unwrap(); + let ClaudeHookEvent::PreToolUse(execution) = event else { + panic!("expected PreToolUse"); + }; + assert!(!execution.run_in_background); +} + +#[test] +fn tool_input_absent_defaults_run_in_background_to_false() { + let mut object: serde_json::Map = + serde_json::from_str(&pre_tool_use_json(&[])).unwrap(); + object.remove(TOOL_INPUT_FIELD); + let payload = Value::Object(object).to_string(); + + let event = parse_claude_hook_event(&payload).unwrap(); + let ClaudeHookEvent::PreToolUse(execution) = event else { + panic!("expected PreToolUse"); + }; + assert!(!execution.run_in_background); +} + +#[test] +fn run_in_background_wrong_type_is_rejected() { + let payload = pre_tool_use_json(&[( + TOOL_INPUT_FIELD, + serde_json::json!({ "run_in_background": "yes" }), + )]); + + let error = parse_claude_hook_event(&payload).unwrap_err(); + assert!(error.to_string().contains("run_in_background")); +} + +#[test] +fn tool_input_wrong_type_is_rejected() { + let payload = pre_tool_use_json(&[(TOOL_INPUT_FIELD, Value::String("nope".to_string()))]); + + let error = parse_claude_hook_event(&payload).unwrap_err(); + assert!(error.to_string().contains("tool_input")); +} + +#[test] +fn known_mutation_capable_tools_are_classified_mutation_capable() { + for tool_name in [ + "Bash", + "PowerShell", + "Write", + "Edit", + "NotebookEdit", + "MultiEdit", + ] { + assert_eq!( + classify_tool(tool_name), + ToolClassification::MutationCapable, + "expected {tool_name} to be MutationCapable" + ); + } +} + +#[test] +fn known_read_only_tools_are_classified_read_only() { + for tool_name in [ + "Read", + "Glob", + "Grep", + "WebFetch", + "WebSearch", + "AskUserQuestion", + ] { + assert_eq!( + classify_tool(tool_name), + ToolClassification::ReadOnly, + "expected {tool_name} to be ReadOnly" + ); + } +} + +#[test] +fn agent_is_classified_delegation() { + assert_eq!(classify_tool("Agent"), ToolClassification::Delegation); +} + +#[test] +fn mcp_tools_are_classified_mutation_capable() { + assert_eq!( + classify_tool("mcp__claude-in-chrome__navigate"), + ToolClassification::MutationCapable + ); +} + +#[test] +fn unknown_tool_names_are_conservatively_mutation_capable() { + assert_eq!( + classify_tool("SomeBrandNewTool"), + ToolClassification::MutationCapable + ); +} + +const PROBE14_BASH_RUN_IN_BACKGROUND_TRUE: &str = + include_str!("fixtures/probe14-run-in-background-true.pre_tool_use.json"); +const PROBE15_BASH_RUN_IN_BACKGROUND_FALSE: &str = + include_str!("fixtures/probe15-run-in-background-false-hard-gate.pre_tool_use.json"); + +fn parsed_pre_tool_use(payload: &str) -> ClaudeToolExecution { + let event = parse_claude_hook_event(payload).unwrap(); + let ClaudeHookEvent::PreToolUse(execution) = event else { + panic!("expected PreToolUse"); + }; + execution +} + +#[test] +fn real_bash_run_in_background_true_fixture_is_explicit_background_shell() { + let execution = parsed_pre_tool_use(PROBE14_BASH_RUN_IN_BACKGROUND_TRUE); + + assert_eq!(execution.identity.tool_name, "Bash"); + assert!(execution.run_in_background); + assert!(is_explicit_background_shell( + &execution.identity.tool_name, + execution.run_in_background + )); +} + +#[test] +fn real_bash_run_in_background_false_fixture_is_not_explicit_background_shell() { + let execution = parsed_pre_tool_use(PROBE15_BASH_RUN_IN_BACKGROUND_FALSE); + + assert_eq!(execution.identity.tool_name, "Bash"); + assert!(!execution.run_in_background); + assert!(!is_explicit_background_shell( + &execution.identity.tool_name, + execution.run_in_background + )); +} + +#[test] +fn powershell_with_run_in_background_true_is_explicit_background_shell() { + assert!(is_explicit_background_shell("PowerShell", true)); +} + +#[test] +fn powershell_with_run_in_background_false_is_not_explicit_background_shell() { + assert!(!is_explicit_background_shell("PowerShell", false)); +} + +#[test] +fn write_with_run_in_background_true_is_not_explicit_background_shell() { + assert!(!is_explicit_background_shell("Write", true)); +} + +#[test] +fn same_attempt_seq_and_key_is_deterministic() { + let key = identity("session-1", Some("agent-1"), "toolu_1"); + + let first = format_claude_scope_id(3, &key); + let second = format_claude_scope_id(3, &key); + + assert_eq!( + first, second, + "AC4: duplicate delivery must reuse the same ScopeId" + ); + assert_eq!( + claude_scope_start_event_id(&first), + claude_scope_start_event_id(&second) + ); +} + +#[test] +fn fresh_attempt_seq_yields_a_new_scope_id() { + let key = identity("session-1", None, "toolu_1"); + + let first = format_claude_scope_id(1, &key); + let second = format_claude_scope_id(2, &key); + + assert_ne!( + first, second, + "AC5: a fresh attempt_seq for the same tool_use_id must get a new ScopeId" + ); +} + +#[test] +fn main_and_distinct_agents_produce_distinct_scope_ids() { + let main = identity("session-1", None, "toolu_1"); + let agent_a = identity("session-1", Some("A"), "toolu_1"); + let agent_b = identity("session-1", Some("B"), "toolu_1"); + + let main_scope = format_claude_scope_id(1, &main); + let scope_for_a = format_claude_scope_id(1, &agent_a); + let scope_for_b = format_claude_scope_id(1, &agent_b); + + assert_ne!( + main_scope, scope_for_a, + "AC6: main vs agent_id=A must differ" + ); + assert_ne!( + main_scope, scope_for_b, + "AC6: main vs agent_id=B must differ" + ); + assert_ne!( + scope_for_a, scope_for_b, + "AC6: agent_id=A vs agent_id=B must differ" + ); +} + +#[test] +fn event_id_derivation_is_a_pure_function_of_scope_id() { + let scope_id = format_claude_scope_id(7, &identity("session-1", None, "toolu_1")); + + assert_eq!( + claude_scope_start_event_id(&scope_id), + format!("{scope_id}|start") + ); + assert_eq!( + claude_scope_close_event_id(&scope_id), + format!("{scope_id}|close") + ); + assert_ne!( + claude_scope_start_event_id(&scope_id), + claude_scope_close_event_id(&scope_id) + ); +} + +#[test] +fn length_prefixing_disambiguates_delimiter_characters_inside_fields() { + let tricky = identity( + "sess|a=0:x|t=1:y", + Some("agent|with|pipes"), + "tool:with:colons", + ); + + let scope_id = format_claude_scope_id(1, &tricky); + + let agent_id = tricky.agent_id.as_deref().unwrap(); + let expected = format!( + "cc-tool-v1|n=1|s={}:{}|a={}:{}|t={}:{}", + tricky.session_id.len(), + tricky.session_id, + agent_id.len(), + agent_id, + tricky.tool_use_id.len(), + tricky.tool_use_id, + ); + + assert_eq!(scope_id, expected); +} + +#[test] +fn attempt_key_projects_only_the_execution_key_fields() { + let identity_a = ClaudeToolIdentity { + session_id: "session-1".to_string(), + cwd: "/repo".to_string(), + agent_id: Some("agent-1".to_string()), + tool_name: "Write".to_string(), + tool_use_id: "toolu_1".to_string(), + }; + let identity_b = ClaudeToolIdentity { + tool_name: "Bash".to_string(), + cwd: "/other".to_string(), + ..identity_a.clone() + }; + + assert_eq!( + identity_a.attempt_key(), + identity_b.attempt_key(), + "attempt_key must depend only on (session_id, agent_id, tool_use_id)" + ); +} + +mod driver { + use std::cell::{Cell, RefCell}; + use std::path::PathBuf; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::{Arc, Mutex}; + + use anyhow::anyhow; + + use super::*; + + static NEXT_TEST_GIT_DIR_ID: AtomicU64 = AtomicU64::new(0); + + fn unique_test_git_dir(label: &str) -> PathBuf { + let id = NEXT_TEST_GIT_DIR_ID.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "sce-claude-mutation-scope-driver-{label}-{}-{id}", + std::process::id() + )) + } + + fn remove_test_git_dir(git_dir: &Path) { + let _ = std::fs::remove_dir_all(git_dir); + } + + #[allow(clippy::unnecessary_wraps)] + fn ok_seam(_root: &Path, _payload: &str, _logger: Option<&dyn Logger>) -> Result { + Ok(String::new()) + } + + fn unreachable_seam( + _root: &Path, + payload: &str, + _logger: Option<&dyn Logger>, + ) -> Result { + panic!("the ingress seam must not be called for this payload: {payload}"); + } + + fn seam_failing_on( + operation: &'static str, + ) -> impl Fn(&Path, &str, Option<&dyn Logger>) -> Result { + seam_failing_on_any(vec![operation]) + } + + fn seam_failing_on_any( + operations: Vec<&'static str>, + ) -> impl Fn(&Path, &str, Option<&dyn Logger>) -> Result { + move |_root, payload, _logger| { + if operations + .iter() + .any(|operation| payload.contains(&format!(r#""operation":"{operation}""#))) + { + Err(anyhow!( + "seam failure injected by test for one of {operations:?}" + )) + } else { + Ok(String::new()) + } + } + } + + fn fixed_resolver(git_dir: PathBuf) -> impl Fn(&str) -> Result { + move |_cwd| Ok(git_dir.clone()) + } + + fn start_with_model_resolver( + payload: &str, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + model_state_resolver: ClaudeModelStateResolver, + seam: IngressSeam, + ) -> Result { + run_claude_mutation_scope_from_payload_with_resolver( + payload, + logger, + resolve_git_dir, + model_state_resolver, + seam, + ) + } + + #[derive(Clone, Default)] + struct RecordingLogger { + warnings: Arc>>, + } + + impl RecordingLogger { + fn warnings(&self) -> Vec<(String, String)> { + self.warnings + .lock() + .expect("recording logger mutex must not be poisoned") + .clone() + } + } + + impl Logger for RecordingLogger { + fn info(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} + fn debug(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} + + fn warn(&self, event_id: &str, message: &str, _: &[(&str, &str)], _: Option<&str>) { + self.warnings + .lock() + .expect("recording logger mutex must not be poisoned") + .push((event_id.to_string(), message.to_string())); + } + + fn error(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} + + fn log_cli_error(&self, _: &crate::services::error::CliError, _: Option<&str>) {} + } + + fn session_scoped_payload(event_name: &str, session_id: &str, cwd: &str) -> String { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(event_name.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String(session_id.to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String(cwd.to_string())); + Value::Object(object).to_string() + } + + #[test] + fn pre_tool_use_resolves_main_and_subagent_model_state_exactly_at_admission() { + let git_dir = unique_test_git_dir("model-state-admission"); + let git_dir_resolver = fixed_resolver(git_dir.clone()); + let resolver_calls: RefCell> = RefCell::new(Vec::new()); + let model_state_resolver = |_: &Path, session_id: &str, agent_id: &str| { + resolver_calls + .borrow_mut() + .push((session_id.to_string(), agent_id.to_string())); + Ok(Some(if agent_id.is_empty() { + "claude/sonnet".to_string() + } else { + "claude/opus".to_string() + })) + }; + let starts: RefCell> = RefCell::new(Vec::new()); + let seam = |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + let value: Value = serde_json::from_str(payload).expect("payload is JSON"); + if value.get("operation") == Some(&Value::String("start".to_string())) { + starts.borrow_mut().push(value); + } + Ok(String::new()) + }; + + let main_payload = pre_tool_use_json(&[]); + let subagent_payload = pre_tool_use_json(&[ + ( + TOOL_USE_ID_FIELD, + Value::String("toolu_subagent".to_string()), + ), + (AGENT_ID_FIELD, Value::String("agent-1".to_string())), + ]); + start_with_model_resolver( + &main_payload, + None, + &git_dir_resolver, + &model_state_resolver, + &seam, + ) + .expect("main-agent Start should succeed"); + start_with_model_resolver( + &subagent_payload, + None, + &git_dir_resolver, + &model_state_resolver, + &seam, + ) + .expect("subagent Start should succeed"); + + assert_eq!( + resolver_calls.into_inner(), + vec![ + ("cc_session-1".to_string(), String::new()), + ("cc_session-1".to_string(), "agent-1".to_string()), + ] + ); + let starts = starts.into_inner(); + assert_eq!(starts.len(), 2); + assert_eq!( + starts[0]["provenance"], + json!({"session_id": "cc_session-1", "model_id": "claude/sonnet"}) + ); + assert_eq!( + starts[1]["provenance"], + json!({"session_id": "cc_session-1", "model_id": "claude/opus"}) + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn subagent_without_exact_model_state_does_not_inherit_main_model() { + let git_dir = unique_test_git_dir("subagent-model-state-missing"); + let git_dir_resolver = fixed_resolver(git_dir.clone()); + let starts: RefCell> = RefCell::new(Vec::new()); + let model_state_resolver = |_: &Path, _: &str, agent_id: &str| { + Ok(if agent_id.is_empty() { + Some("claude/sonnet".to_string()) + } else { + None + }) + }; + let seam = |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + starts + .borrow_mut() + .push(serde_json::from_str(payload).expect("payload is JSON")); + Ok(String::new()) + }; + + for (tool_use_id, agent_id) in [("toolu_main", None), ("toolu_subagent", Some("agent-1"))] { + let overrides = agent_id + .map(|agent_id| vec![(AGENT_ID_FIELD, Value::String(agent_id.to_string()))]) + .unwrap_or_default(); + let mut overrides = overrides; + overrides.push((TOOL_USE_ID_FIELD, Value::String(tool_use_id.to_string()))); + let payload = pre_tool_use_json(&overrides); + start_with_model_resolver( + &payload, + None, + &git_dir_resolver, + &model_state_resolver, + &seam, + ) + .expect("both Starts should succeed"); + } + + let starts = starts.into_inner(); + assert_eq!(starts.len(), 2); + assert_eq!(starts[0]["provenance"]["model_id"], "claude/sonnet"); + assert_eq!(starts[1]["provenance"]["session_id"], "cc_session-1"); + assert_eq!(starts[1]["provenance"]["model_id"], Value::Null); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn missing_or_failed_model_resolution_keeps_session_provenance_and_allows_start() { + let git_dir = unique_test_git_dir("model-state-unavailable"); + let git_dir_resolver = fixed_resolver(git_dir.clone()); + let starts: RefCell> = RefCell::new(Vec::new()); + let seam = |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + starts + .borrow_mut() + .push(serde_json::from_str(payload).expect("payload is JSON")); + Ok(String::new()) + }; + let response_index = Cell::new(0); + let model_state_resolver = |_: &Path, _: &str, _: &str| -> Result> { + let index = response_index.get(); + response_index.set(index + 1); + if index == 0 { + Ok(None) + } else { + Err(anyhow!("local model-state DB is unavailable")) + } + }; + + for tool_use_id in ["toolu_missing", "toolu_failed"] { + let payload = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String(tool_use_id.to_string()))]); + let output = start_with_model_resolver( + &payload, + None, + &git_dir_resolver, + &model_state_resolver, + &seam, + ) + .expect("model unavailability must not fail Start"); + assert_eq!(output, ""); + } + + let starts = starts.into_inner(); + assert_eq!(starts.len(), 2); + for start in starts { + assert_eq!( + start["provenance"], + json!({"session_id": "cc_session-1", "model_id": null}) + ); + } + + remove_test_git_dir(&git_dir); + } + + #[test] + fn read_only_tool_creates_no_scope_and_never_touches_the_seam_or_git_dir() { + let resolver = |_: &str| -> Result { + panic!("a read-only tool must never resolve a git dir") + }; + let payload = pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String("Read".to_string()))]); + + let output = run_claude_mutation_scope_from_payload_with( + &payload, + None, + &resolver, + &unreachable_seam, + ) + .expect("read-only PreToolUse should succeed"); + + assert_eq!(output, ""); + } + + #[test] + fn delegation_tool_creates_no_scope_ac3() { + let resolver = |_: &str| -> Result { + panic!("Agent delegation must never resolve a git dir") + }; + let payload = pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String("Agent".to_string()))]); + + let output = run_claude_mutation_scope_from_payload_with( + &payload, + None, + &resolver, + &unreachable_seam, + ) + .expect("Agent delegation PreToolUse should succeed"); + + assert_eq!(output, ""); + } + + #[test] + fn session_start_and_subagent_start_establish_no_scope_ac3() { + let resolver = |_: &str| -> Result { + panic!("a lifecycle-only event must never resolve a git dir") + }; + + for event_name in [HOOK_EVENT_SESSION_START, HOOK_EVENT_SUBAGENT_START] { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(event_name.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("session-1".to_string()), + ); + let payload = Value::Object(object).to_string(); + + let output = run_claude_mutation_scope_from_payload_with( + &payload, + None, + &resolver, + &unreachable_seam, + ) + .expect("a lifecycle-only event should succeed with no scope"); + assert_eq!(output, ""); + } + } + + #[test] + fn explicit_background_bash_is_denied_with_the_exact_reason_d20() { + let git_dir = unique_test_git_dir("explicit-background-bash"); + let resolver = fixed_resolver(git_dir.clone()); + let payload = pre_tool_use_json(&[ + (TOOL_NAME_FIELD, Value::String("Bash".to_string())), + ( + TOOL_INPUT_FIELD, + serde_json::json!({ "run_in_background": true }), + ), + ]); + + let output = run_claude_mutation_scope_from_payload_with( + &payload, + None, + &resolver, + &unreachable_seam, + ) + .expect("an explicit background shell should still return Ok with a deny payload"); + + assert_eq!( + output, + pre_tool_use_deny_json(EXPLICIT_BACKGROUND_SHELL_DENY_REASON) + ); + assert!( + !git_dir.exists(), + "D20: denial must precede any adapter-state I/O" + ); + } + + #[test] + fn explicit_background_powershell_is_denied_ac21() { + let git_dir = unique_test_git_dir("explicit-background-powershell"); + let resolver = fixed_resolver(git_dir.clone()); + let payload = pre_tool_use_json(&[ + (TOOL_NAME_FIELD, Value::String("PowerShell".to_string())), + ( + TOOL_INPUT_FIELD, + serde_json::json!({ "run_in_background": true }), + ), + ]); + + let output = run_claude_mutation_scope_from_payload_with( + &payload, + None, + &resolver, + &unreachable_seam, + ) + .expect("explicit background PowerShell should be denied"); + + assert_eq!( + output, + pre_tool_use_deny_json(EXPLICIT_BACKGROUND_SHELL_DENY_REASON) + ); + } + + #[test] + fn write_ahead_pending_start_persists_before_the_seam_start_call_ac7() { + let git_dir = unique_test_git_dir("write-ahead"); + let resolver = fixed_resolver(git_dir.clone()); + let git_dir_for_seam = git_dir.clone(); + let phase_seen_before_start: RefCell> = RefCell::new(None); + let observed_roots: RefCell> = RefCell::new(Vec::new()); + let seam = |root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + if payload.contains(r#""operation":"start""#) { + observed_roots.borrow_mut().push(root.to_path_buf()); + let observed = state::read_state(&git_dir_for_seam) + .expect("state should be readable under git_dir inside the seam call"); + *phase_seen_before_start.borrow_mut() = + observed.attempts.first().map(|attempt| attempt.phase); + } + Ok(String::new()) + }; + + let payload = pre_tool_use_json(&[]); + let output = run_claude_mutation_scope_from_payload_with(&payload, None, &resolver, &seam) + .expect("mutation-capable PreToolUse should succeed"); + + assert_eq!(output, ""); + assert_eq!( + phase_seen_before_start.into_inner(), + Some(state::AttemptPhase::PendingStart), + "AC7: the attempt must be durably pending_start (under git_dir) before the seam Start call" + ); + assert_eq!( + observed_roots.into_inner(), + vec![PathBuf::from("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/repo/checkout")], + "the seam must receive the raw Claude cwd as repository_root, never the resolved git_dir" + ); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!( + final_state.attempts[0].phase, + state::AttemptPhase::Active, + "phase must become active after a successful Start" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn linked_worktree_cwd_and_git_dir_are_never_conflated_for_pre_tool_use_start() { + let git_dir = unique_test_git_dir("cwd-vs-git-dir-start"); + let raw_cwd = "/repo/.claude/worktrees/agent-123"; + let resolver = fixed_resolver(git_dir.clone()); + + let observed_roots: RefCell> = RefCell::new(Vec::new()); + let seam = |root: &Path, _payload: &str, _logger: Option<&dyn Logger>| -> Result { + observed_roots.borrow_mut().push(root.to_path_buf()); + Ok(String::new()) + }; + + let payload = pre_tool_use_json(&[(CWD_FIELD, Value::String(raw_cwd.to_string()))]); + run_claude_mutation_scope_from_payload_with(&payload, None, &resolver, &seam) + .expect("PreToolUse Start should succeed"); + + assert_ne!( + PathBuf::from(raw_cwd), + git_dir, + "test sanity: the raw checkout path and the resolved git_dir must be deliberately distinct" + ); + assert_eq!( + observed_roots.into_inner(), + vec![PathBuf::from(raw_cwd)], + "the ingress seam must receive the raw Claude cwd, never git_dir" + ); + + let state = state::read_state(&git_dir).expect("state should be readable under git_dir"); + assert_eq!( + state.attempts.len(), + 1, + "adapter bookkeeping must be written under the resolved git_dir" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn post_tool_use_close_uses_git_dir_for_state_and_raw_cwd_for_the_seam() { + let git_dir = unique_test_git_dir("cwd-vs-git-dir-close"); + let raw_cwd = "/repo/.claude/worktrees/agent-123"; + let resolver = fixed_resolver(git_dir.clone()); + + let pre_payload = pre_tool_use_json(&[(CWD_FIELD, Value::String(raw_cwd.to_string()))]); + run_claude_mutation_scope_from_payload_with(&pre_payload, None, &resolver, &ok_seam) + .expect("PreToolUse should establish an active attempt"); + + let observed_roots: RefCell> = RefCell::new(Vec::new()); + let seam = |root: &Path, _payload: &str, _logger: Option<&dyn Logger>| -> Result { + observed_roots.borrow_mut().push(root.to_path_buf()); + Ok(String::new()) + }; + let post_payload = pre_tool_use_json(&[ + (CWD_FIELD, Value::String(raw_cwd.to_string())), + ( + HOOK_EVENT_NAME_FIELD, + Value::String(HOOK_EVENT_POST_TOOL_USE.to_string()), + ), + ]); + run_claude_mutation_scope_from_payload_with(&post_payload, None, &resolver, &seam) + .expect("PostToolUse Close should succeed"); + + assert_eq!( + observed_roots.into_inner(), + vec![PathBuf::from(raw_cwd)], + "Close must invoke the seam with the raw Claude cwd, never git_dir" + ); + + let state = state::read_state(&git_dir).expect("state should be readable under git_dir"); + assert!( + state.attempts.is_empty(), + "the closed attempt must be removed from git_dir bookkeeping" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn abandon_via_permission_denied_uses_git_dir_for_state_and_raw_cwd_for_the_seam() { + let git_dir = unique_test_git_dir("cwd-vs-git-dir-abandon"); + let raw_cwd = "/repo/.claude/worktrees/agent-123"; + let resolver = fixed_resolver(git_dir.clone()); + + let pre_payload = pre_tool_use_json(&[(CWD_FIELD, Value::String(raw_cwd.to_string()))]); + run_claude_mutation_scope_from_payload_with(&pre_payload, None, &resolver, &ok_seam) + .expect("PreToolUse should establish an active attempt"); + + let observed_roots: RefCell> = RefCell::new(Vec::new()); + let seam = |root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + if payload.contains(r#""operation":"abandon""#) { + observed_roots.borrow_mut().push(root.to_path_buf()); + } + Ok(String::new()) + }; + let denied_payload = pre_tool_use_json(&[ + (CWD_FIELD, Value::String(raw_cwd.to_string())), + ( + HOOK_EVENT_NAME_FIELD, + Value::String(HOOK_EVENT_PERMISSION_DENIED.to_string()), + ), + ]); + run_claude_mutation_scope_from_payload_with(&denied_payload, None, &resolver, &seam) + .expect("PermissionDenied should succeed"); + + assert_eq!( + observed_roots.into_inner(), + vec![PathBuf::from(raw_cwd)], + "Abandon must invoke the seam with the raw Claude cwd, never git_dir" + ); + + let state = state::read_state(&git_dir).expect("state should be readable under git_dir"); + assert!(state.attempts.is_empty()); + assert!(state.recovery_pending); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn recovery_barrier_flush_uses_raw_cwd_for_the_seam_and_git_dir_for_state() { + let git_dir = unique_test_git_dir("cwd-vs-git-dir-flush"); + let raw_cwd = "/repo/.claude/worktrees/agent-123"; + let resolver = fixed_resolver(git_dir.clone()); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + let seeded = state::allocate_attempt( + &git_dir, + &AttemptKey { + session_id: "session-1".to_string(), + agent_id: None, + tool_use_id: "toolu_seed".to_string(), + }, + "Write", + ) + .expect("seed allocation should succeed"); + state::mark_recovery_pending(&git_dir).expect("arming the barrier should succeed"); + state::remove_attempt(&git_dir, &seeded.attempt.scope_id) + .expect("removing the seed attempt should succeed"); + + let observed_roots: RefCell> = RefCell::new(Vec::new()); + let seam = |root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + if payload.contains(r#""operation":"flush""#) { + observed_roots.borrow_mut().push(root.to_path_buf()); + } + Ok(String::new()) + }; + + let new_pre = pre_tool_use_json(&[ + (CWD_FIELD, Value::String(raw_cwd.to_string())), + (TOOL_USE_ID_FIELD, Value::String("toolu_new".to_string())), + ]); + run_claude_mutation_scope_from_payload_with(&new_pre, None, &resolver, &seam) + .expect("quiescent recovery should flush against the raw checkout path"); + + assert_eq!( + observed_roots.into_inner(), + vec![PathBuf::from(raw_cwd)], + "flush must run against the raw Claude cwd, not git_dir" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn duplicate_live_pre_tool_use_reuses_the_same_scope_and_start_event_id_ac4() { + let git_dir = unique_test_git_dir("duplicate-delivery"); + let resolver = fixed_resolver(git_dir.clone()); + let start_event_ids: RefCell> = RefCell::new(Vec::new()); + let seam = |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + if payload.contains(r#""operation":"start""#) { + let value: Value = serde_json::from_str(payload).unwrap(); + start_event_ids + .borrow_mut() + .push(value["event_id"].as_str().unwrap().to_string()); + } + Ok(String::new()) + }; + + let payload = pre_tool_use_json(&[]); + run_claude_mutation_scope_from_payload_with(&payload, None, &resolver, &seam) + .expect("first delivery should succeed"); + run_claude_mutation_scope_from_payload_with(&payload, None, &resolver, &seam) + .expect("duplicate delivery should succeed"); + + let ids = start_event_ids.into_inner(); + assert_eq!(ids.len(), 2); + assert_eq!( + ids[0], ids[1], + "AC4: duplicate delivery must reuse the same Start EventId" + ); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert_eq!( + final_state.attempts.len(), + 1, + "duplicate delivery must not create a second bookkeeping entry" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn seam_start_failure_denies_and_leaves_the_attempt_pending_start_d8_d11() { + let git_dir = unique_test_git_dir("start-failure"); + let resolver = fixed_resolver(git_dir.clone()); + let seam = seam_failing_on("start"); + + let payload = pre_tool_use_json(&[]); + let output = run_claude_mutation_scope_from_payload_with(&payload, None, &resolver, &seam) + .expect("a Start failure must still return Ok with a deny payload, not propagate"); + + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!( + final_state.attempts[0].phase, + state::AttemptPhase::PendingStart, + "D11: a failed Start must not be marked active nor removed" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn pending_start_attempt_is_abandoned_not_late_started_on_a_terminal_signal_d11() { + let git_dir = unique_test_git_dir("pending-start-then-terminal"); + let resolver = fixed_resolver(git_dir.clone()); + + let start_failing_seam = seam_failing_on("start"); + let pre_payload = pre_tool_use_json(&[]); + run_claude_mutation_scope_from_payload_with( + &pre_payload, + None, + &resolver, + &start_failing_seam, + ) + .expect("the failed Start must still return Ok with a deny payload"); + + let seen_operations: RefCell> = RefCell::new(Vec::new()); + let recording = + |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + seen_operations.borrow_mut().push(payload.to_string()); + Ok(String::new()) + }; + let post_payload = pre_tool_use_json(&[( + HOOK_EVENT_NAME_FIELD, + Value::String(HOOK_EVENT_POST_TOOL_USE.to_string()), + )]); + let output = + run_claude_mutation_scope_from_payload_with(&post_payload, None, &resolver, &recording) + .expect("PostToolUse for a pending_start attempt should succeed"); + + assert_eq!(output, ""); + let operations = seen_operations.into_inner(); + assert_eq!(operations.len(), 1); + assert!( + operations[0].contains(r#""operation":"abandon""#), + "D11: a pending_start attempt must be abandoned, not late-started, got: {operations:?}" + ); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert!(final_state.attempts.is_empty()); + assert!(final_state.recovery_pending); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn close_seam_failure_is_retired_through_abandonment_not_a_replayed_close_d12() { + let git_dir = unique_test_git_dir("close-failure"); + let resolver = fixed_resolver(git_dir.clone()); + + let pre_payload = pre_tool_use_json(&[]); + run_claude_mutation_scope_from_payload_with(&pre_payload, None, &resolver, &ok_seam) + .expect("PreToolUse should establish an active attempt"); + + let close_failing_seam = seam_failing_on("close"); + let post_payload = pre_tool_use_json(&[( + HOOK_EVENT_NAME_FIELD, + Value::String(HOOK_EVENT_POST_TOOL_USE.to_string()), + )]); + let output = run_claude_mutation_scope_from_payload_with( + &post_payload, + None, + &resolver, + &close_failing_seam, + ) + .expect("a Close failure must still succeed via abandonment"); + + assert_eq!(output, ""); + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert!( + final_state.attempts.is_empty(), + "D12: after abandonment the attempt must be retired" + ); + assert!(final_state.recovery_pending); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn post_tool_use_failure_also_closes_the_scope_d10() { + let git_dir = unique_test_git_dir("post-tool-use-failure"); + let resolver = fixed_resolver(git_dir.clone()); + + let pre_payload = pre_tool_use_json(&[]); + run_claude_mutation_scope_from_payload_with(&pre_payload, None, &resolver, &ok_seam) + .expect("PreToolUse should establish an active attempt"); + + let seen_operations: RefCell> = RefCell::new(Vec::new()); + let recording = + |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + seen_operations.borrow_mut().push(payload.to_string()); + Ok(String::new()) + }; + let failure_payload = pre_tool_use_json(&[( + HOOK_EVENT_NAME_FIELD, + Value::String(HOOK_EVENT_POST_TOOL_USE_FAILURE.to_string()), + )]); + let output = run_claude_mutation_scope_from_payload_with( + &failure_payload, + None, + &resolver, + &recording, + ) + .expect("PostToolUseFailure should close the scope"); + + assert_eq!(output, ""); + let operations = seen_operations.into_inner(); + assert_eq!(operations.len(), 1); + assert!(operations[0].contains(r#""operation":"close""#)); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert!(final_state.attempts.is_empty()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn post_tool_use_with_no_live_attempt_is_a_safe_no_op_d9() { + let payload = pre_tool_use_json(&[( + HOOK_EVENT_NAME_FIELD, + Value::String(HOOK_EVENT_POST_TOOL_USE.to_string()), + )]); + let resolver = fixed_resolver(std::env::temp_dir().join("sce-unused-nonexistent-git-dir")); + + let output = run_claude_mutation_scope_from_payload_with( + &payload, + None, + &resolver, + &unreachable_seam, + ) + .expect("a PostToolUse with no live attempt must be a safe no-op"); + + assert_eq!(output, ""); + } + + #[test] + fn permission_denied_abandons_a_live_attempt_d13() { + let git_dir = unique_test_git_dir("permission-denied"); + let resolver = fixed_resolver(git_dir.clone()); + + let pre_payload = pre_tool_use_json(&[]); + run_claude_mutation_scope_from_payload_with(&pre_payload, None, &resolver, &ok_seam) + .expect("PreToolUse should establish an active attempt"); + + let seen_operations: RefCell> = RefCell::new(Vec::new()); + let recording = + |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + seen_operations.borrow_mut().push(payload.to_string()); + Ok(String::new()) + }; + let denied_payload = pre_tool_use_json(&[( + HOOK_EVENT_NAME_FIELD, + Value::String(HOOK_EVENT_PERMISSION_DENIED.to_string()), + )]); + let output = run_claude_mutation_scope_from_payload_with( + &denied_payload, + None, + &resolver, + &recording, + ) + .expect("PermissionDenied should succeed"); + + assert_eq!(output, ""); + let operations = seen_operations.into_inner(); + assert_eq!(operations.len(), 1); + assert!(operations[0].contains(r#""operation":"abandon""#)); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert!(final_state.attempts.is_empty()); + assert!(final_state.recovery_pending); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn stop_abandons_only_stale_main_thread_attempts_d14() { + let git_dir = unique_test_git_dir("stop-cleanup"); + let resolver = fixed_resolver(git_dir.clone()); + + let main_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_main".to_string()))]); + let subagent_pre = pre_tool_use_json(&[ + (TOOL_USE_ID_FIELD, Value::String("toolu_agent".to_string())), + (AGENT_ID_FIELD, Value::String("agent-1".to_string())), + ]); + run_claude_mutation_scope_from_payload_with(&main_pre, None, &resolver, &ok_seam) + .expect("main-thread PreToolUse should succeed"); + run_claude_mutation_scope_from_payload_with(&subagent_pre, None, &resolver, &ok_seam) + .expect("subagent PreToolUse should succeed"); + + let stop_payload = session_scoped_payload(HOOK_EVENT_STOP, "session-1", "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/repo/checkout"); + let output = + run_claude_mutation_scope_from_payload_with(&stop_payload, None, &resolver, &ok_seam) + .expect("Stop cleanup should succeed"); + assert_eq!(output, ""); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert_eq!( + final_state.attempts.len(), + 1, + "only the subagent attempt should remain" + ); + assert_eq!(final_state.attempts[0].tool_use_id, "toolu_agent"); + assert!( + final_state.recovery_pending, + "abandoning the stale main attempt must arm the barrier" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn stop_failure_abandons_stale_main_thread_attempts_the_same_way_d15() { + let git_dir = unique_test_git_dir("stop-failure-cleanup"); + let resolver = fixed_resolver(git_dir.clone()); + + let main_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_main".to_string()))]); + run_claude_mutation_scope_from_payload_with(&main_pre, None, &resolver, &ok_seam) + .expect("main-thread PreToolUse should succeed"); + + let stop_failure_payload = + session_scoped_payload(HOOK_EVENT_STOP_FAILURE, "session-1", "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/repo/checkout"); + run_claude_mutation_scope_from_payload_with( + &stop_failure_payload, + None, + &resolver, + &ok_seam, + ) + .expect("StopFailure cleanup should succeed"); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert!(final_state.attempts.is_empty()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn user_prompt_submit_abandons_only_stale_main_thread_attempts_d16() { + let git_dir = unique_test_git_dir("user-prompt-submit-cleanup"); + let resolver = fixed_resolver(git_dir.clone()); + + let main_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_main".to_string()))]); + let subagent_pre = pre_tool_use_json(&[ + (TOOL_USE_ID_FIELD, Value::String("toolu_agent".to_string())), + (AGENT_ID_FIELD, Value::String("agent-1".to_string())), + ]); + run_claude_mutation_scope_from_payload_with(&main_pre, None, &resolver, &ok_seam) + .expect("main-thread PreToolUse should succeed"); + run_claude_mutation_scope_from_payload_with(&subagent_pre, None, &resolver, &ok_seam) + .expect("subagent PreToolUse should succeed"); + + let prompt_payload = + session_scoped_payload(HOOK_EVENT_USER_PROMPT_SUBMIT, "session-1", "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/repo/checkout"); + run_claude_mutation_scope_from_payload_with(&prompt_payload, None, &resolver, &ok_seam) + .expect("UserPromptSubmit cleanup should succeed"); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert_eq!( + final_state.attempts.len(), + 1, + "the subagent attempt must survive" + ); + assert_eq!(final_state.attempts[0].tool_use_id, "toolu_agent"); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn subagent_stop_abandons_only_the_matching_agent_id_attempts_d17() { + let git_dir = unique_test_git_dir("subagent-stop-cleanup"); + let resolver = fixed_resolver(git_dir.clone()); + + let first_agent_payload = pre_tool_use_json(&[ + (TOOL_USE_ID_FIELD, Value::String("toolu_a".to_string())), + (AGENT_ID_FIELD, Value::String("agent-a".to_string())), + ]); + let second_agent_payload = pre_tool_use_json(&[ + (TOOL_USE_ID_FIELD, Value::String("toolu_b".to_string())), + (AGENT_ID_FIELD, Value::String("agent-b".to_string())), + ]); + run_claude_mutation_scope_from_payload_with( + &first_agent_payload, + None, + &resolver, + &ok_seam, + ) + .expect("agent-a PreToolUse should succeed"); + run_claude_mutation_scope_from_payload_with( + &second_agent_payload, + None, + &resolver, + &ok_seam, + ) + .expect("agent-b PreToolUse should succeed"); + + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(HOOK_EVENT_SUBAGENT_STOP.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("session-1".to_string()), + ); + object.insert( + CWD_FIELD.to_string(), + Value::String("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/repo/checkout".to_string()), + ); + object.insert( + AGENT_ID_FIELD.to_string(), + Value::String("agent-a".to_string()), + ); + let subagent_stop_payload = Value::Object(object).to_string(); + + run_claude_mutation_scope_from_payload_with( + &subagent_stop_payload, + None, + &resolver, + &ok_seam, + ) + .expect("SubagentStop cleanup should succeed"); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].tool_use_id, "toolu_b"); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn session_end_abandons_every_attempt_regardless_of_agent_id_d18() { + let git_dir = unique_test_git_dir("session-end-cleanup"); + let resolver = fixed_resolver(git_dir.clone()); + + let main_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_main".to_string()))]); + let subagent_pre = pre_tool_use_json(&[ + (TOOL_USE_ID_FIELD, Value::String("toolu_agent".to_string())), + (AGENT_ID_FIELD, Value::String("agent-1".to_string())), + ]); + run_claude_mutation_scope_from_payload_with(&main_pre, None, &resolver, &ok_seam) + .expect("main-thread PreToolUse should succeed"); + run_claude_mutation_scope_from_payload_with(&subagent_pre, None, &resolver, &ok_seam) + .expect("subagent PreToolUse should succeed"); + + let session_end_payload = + session_scoped_payload(HOOK_EVENT_SESSION_END, "session-1", "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/repo/checkout"); + run_claude_mutation_scope_from_payload_with( + &session_end_payload, + None, + &resolver, + &ok_seam, + ) + .expect("SessionEnd cleanup should succeed"); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert!(final_state.attempts.is_empty()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn worktree_remove_resolves_git_dir_from_worktree_path_not_cwd_d22() { + let main_git_dir = unique_test_git_dir("worktree-remove-main"); + let worktree_git_dir = unique_test_git_dir("worktree-remove-isolated"); + let main_git_dir_for_resolver = main_git_dir.clone(); + let worktree_git_dir_for_resolver = worktree_git_dir.clone(); + + let resolver = move |cwd: &str| -> Result { + if cwd == "/repo/.claude/worktrees/agent-1" { + Ok(worktree_git_dir_for_resolver.clone()) + } else { + Ok(main_git_dir_for_resolver.clone()) + } + }; + + let subagent_pre = pre_tool_use_json(&[ + ( + CWD_FIELD, + Value::String("/repo/.claude/worktrees/agent-1".to_string()), + ), + (AGENT_ID_FIELD, Value::String("agent-1".to_string())), + ]); + run_claude_mutation_scope_from_payload_with(&subagent_pre, None, &resolver, &ok_seam) + .expect("isolated-worktree PreToolUse should succeed"); + + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(HOOK_EVENT_WORKTREE_REMOVE.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("session-1".to_string()), + ); + object.insert( + WORKTREE_PATH_FIELD.to_string(), + Value::String("/repo/.claude/worktrees/agent-1".to_string()), + ); + let worktree_remove_payload = Value::Object(object).to_string(); + + run_claude_mutation_scope_from_payload_with( + &worktree_remove_payload, + None, + &resolver, + &ok_seam, + ) + .expect("WorktreeRemove cleanup should succeed"); + + let worktree_state = + state::read_state(&worktree_git_dir).expect("worktree state should be readable"); + assert!( + worktree_state.attempts.is_empty(), + "D22: WorktreeRemove must retire attempts under the worktree_path's git dir" + ); + + remove_test_git_dir(&main_git_dir); + remove_test_git_dir(&worktree_git_dir); + } + + #[test] + fn recovery_barrier_denies_new_mutation_capable_pre_tool_use_while_attempts_remain_d19() { + let git_dir = unique_test_git_dir("barrier-attempts-remain"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let resolver = fixed_resolver(git_dir.clone()); + + let surviving = state::allocate_attempt( + &git_dir, + &AttemptKey { + session_id: "session-1".to_string(), + agent_id: None, + tool_use_id: "toolu_surviving".to_string(), + }, + "Write", + ) + .expect("seeding a surviving attempt should succeed"); + let retiring = state::allocate_attempt( + &git_dir, + &AttemptKey { + session_id: "session-1".to_string(), + agent_id: None, + tool_use_id: "toolu_retiring".to_string(), + }, + "Write", + ) + .expect("seeding a retiring attempt should succeed"); + state::mark_recovery_pending(&git_dir).expect("arming the barrier should succeed"); + state::remove_attempt(&git_dir, &retiring.attempt.scope_id) + .expect("removing the retiring attempt should succeed"); + let _ = surviving; + + let new_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_new".to_string()))]); + let output = run_claude_mutation_scope_from_payload_with( + &new_pre, + None, + &resolver, + &unreachable_seam, + ) + .expect("D19: barrier denial must still return Ok with a deny payload"); + + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn recovery_barrier_flushes_once_quiescent_and_clears_before_starting_d19() { + let git_dir = unique_test_git_dir("barrier-flush-success"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let resolver = fixed_resolver(git_dir.clone()); + + let seeded = state::allocate_attempt( + &git_dir, + &AttemptKey { + session_id: "session-1".to_string(), + agent_id: None, + tool_use_id: "toolu_seed".to_string(), + }, + "Write", + ) + .expect("seeding the retired attempt should succeed"); + state::mark_recovery_pending(&git_dir).expect("arming the barrier should succeed"); + state::remove_attempt(&git_dir, &seeded.attempt.scope_id) + .expect("removing the seeded attempt should succeed"); + + let seen_operations: RefCell> = RefCell::new(Vec::new()); + let seam = |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + seen_operations.borrow_mut().push(payload.to_string()); + Ok(String::new()) + }; + + let new_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_new".to_string()))]); + let output = run_claude_mutation_scope_from_payload_with(&new_pre, None, &resolver, &seam) + .expect("D19: a quiescent recovery should flush then proceed"); + + assert_eq!(output, ""); + let operations = seen_operations.into_inner(); + assert_eq!( + operations.len(), + 2, + "expected flush then start, got: {operations:?}" + ); + assert!(operations[0].contains(r#""operation":"flush""#)); + assert!(operations[1].contains(r#""operation":"start""#)); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert!( + !final_state.recovery_pending, + "a successful flush must clear the barrier" + ); + assert_eq!(final_state.attempts.len(), 1); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn recovery_barrier_stays_fail_closed_when_flush_fails_d19() { + let git_dir = unique_test_git_dir("barrier-flush-failure"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let resolver = fixed_resolver(git_dir.clone()); + + let seeded = state::allocate_attempt( + &git_dir, + &AttemptKey { + session_id: "session-1".to_string(), + agent_id: None, + tool_use_id: "toolu_seed".to_string(), + }, + "Write", + ) + .expect("seeding the retired attempt should succeed"); + state::mark_recovery_pending(&git_dir).expect("arming the barrier should succeed"); + state::remove_attempt(&git_dir, &seeded.attempt.scope_id) + .expect("removing the seeded attempt should succeed"); + + let seam = seam_failing_on("flush"); + let new_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_new".to_string()))]); + let output = run_claude_mutation_scope_from_payload_with(&new_pre, None, &resolver, &seam) + .expect("D19: a failed flush must still return Ok with a deny payload"); + + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert!( + final_state.recovery_pending, + "a failed flush must keep the barrier armed" + ); + assert!( + final_state.attempts.is_empty(), + "a denied PreToolUse must not allocate a new attempt" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn failed_close_and_failed_abandon_keep_recovery_armed_and_the_attempt_tracked_d12_d19() { + let git_dir = unique_test_git_dir("close-and-abandon-failure"); + let resolver = fixed_resolver(git_dir.clone()); + + let pre_payload = pre_tool_use_json(&[]); + run_claude_mutation_scope_from_payload_with(&pre_payload, None, &resolver, &ok_seam) + .expect("PreToolUse should establish an active attempt"); + + let failing_seam = seam_failing_on_any(vec!["close", "abandon"]); + let post_payload = pre_tool_use_json(&[( + HOOK_EVENT_NAME_FIELD, + Value::String(HOOK_EVENT_POST_TOOL_USE.to_string()), + )]); + let error = run_claude_mutation_scope_from_payload_with( + &post_payload, + None, + &resolver, + &failing_seam, + ) + .expect_err( + "a failed Close followed by a failed Abandon must propagate, not silently succeed", + ); + assert!(error.to_string().contains("abandon")); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert_eq!( + final_state.attempts.len(), + 1, + "D12: an attempt whose abandonment failed must remain tracked" + ); + assert!( + final_state.recovery_pending, + "D19: recovery must be armed even though abandonment itself failed" + ); + + let new_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_new".to_string()))]); + let output = run_claude_mutation_scope_from_payload_with( + &new_pre, + None, + &resolver, + &unreachable_seam, + ) + .expect("the barrier denial must still return Ok with a deny payload"); + assert_eq!( + output, + pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), + "the next mutation-capable PreToolUse must be denied, and no new Start may occur" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn lifecycle_cleanup_with_a_failed_abandon_keeps_recovery_armed_and_the_attempt_tracked() { + let git_dir = unique_test_git_dir("lifecycle-cleanup-abandon-failure"); + let resolver = fixed_resolver(git_dir.clone()); + + let main_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_main".to_string()))]); + run_claude_mutation_scope_from_payload_with(&main_pre, None, &resolver, &ok_seam) + .expect("main-thread PreToolUse should succeed"); + + let abandon_failing_seam = seam_failing_on("abandon"); + let stop_payload = session_scoped_payload(HOOK_EVENT_STOP, "session-1", "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/repo/checkout"); + let error = run_claude_mutation_scope_from_payload_with( + &stop_payload, + None, + &resolver, + &abandon_failing_seam, + ) + .expect_err("a failed abandonment during Stop cleanup must propagate"); + assert!(error.to_string().contains("abandon")); + + let final_state = state::read_state(&git_dir).expect("state should be readable"); + assert_eq!( + final_state.attempts.len(), + 1, + "the attempt whose abandonment failed must remain tracked" + ); + assert!( + final_state.recovery_pending, + "the barrier must remain armed even though cleanup abandonment failed" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn resolver_failure_logs_the_detailed_error_and_denies_with_the_stable_reason_ac8_d8() { + let logger = RecordingLogger::default(); + let resolver = + |_: &str| -> Result { Err(anyhow!("boom: git rev-parse --git-dir failed")) }; + + let payload = pre_tool_use_json(&[]); + let output = run_claude_mutation_scope_from_payload_with( + &payload, + Some(&logger), + &resolver, + &unreachable_seam, + ) + .expect("a resolver failure must still return Ok with a deny payload"); + + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + assert!( + !output.contains("boom"), + "the detailed internal error must never leak into Claude's deny reason" + ); + assert!( + !output.contains("allow"), + "a fail-closed PreToolUse must never emit an allow decision" + ); + + let warnings = logger.warnings(); + assert_eq!(warnings.len(), 1); + assert!( + warnings[0].1.contains("boom"), + "the detailed error must be logged for operators, got: {warnings:?}" + ); + } + + #[test] + fn start_seam_failure_logs_the_detailed_error_and_denies_with_the_stable_reason() { + let git_dir = unique_test_git_dir("start-failure-logged"); + let resolver = fixed_resolver(git_dir.clone()); + let logger = RecordingLogger::default(); + let seam = seam_failing_on("start"); + + let payload = pre_tool_use_json(&[]); + let output = + run_claude_mutation_scope_from_payload_with(&payload, Some(&logger), &resolver, &seam) + .expect("a Start failure must still return Ok with a deny payload"); + + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + + let warnings = logger.warnings(); + assert!(!warnings.is_empty(), "the Start failure must be logged"); + assert!(warnings + .iter() + .any(|(_, message)| message.to_lowercase().contains("start"))); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn recovery_barrier_flush_failure_logs_the_detailed_error() { + let git_dir = unique_test_git_dir("barrier-flush-failure-logged"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let resolver = fixed_resolver(git_dir.clone()); + let logger = RecordingLogger::default(); + + let seeded = state::allocate_attempt( + &git_dir, + &AttemptKey { + session_id: "session-1".to_string(), + agent_id: None, + tool_use_id: "toolu_seed".to_string(), + }, + "Write", + ) + .expect("seed allocation should succeed"); + state::mark_recovery_pending(&git_dir).expect("arming the barrier should succeed"); + state::remove_attempt(&git_dir, &seeded.attempt.scope_id) + .expect("removing the seeded attempt should succeed"); + + let seam = seam_failing_on("flush"); + let new_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("toolu_new".to_string()))]); + let output = + run_claude_mutation_scope_from_payload_with(&new_pre, Some(&logger), &resolver, &seam) + .expect("a failed flush must still return Ok with a deny payload"); + + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + + let warnings = logger.warnings(); + assert!(!warnings.is_empty(), "the flush failure must be logged"); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn malformed_payload_propagates_as_a_real_error_not_fail_open() { + let error = run_claude_mutation_scope_from_payload("not json", None).unwrap_err(); + assert!(error.to_string().contains("valid JSON")); + } +} + +mod production_regressions { + use std::fs; + use std::path::{Path, PathBuf}; + use std::process::Command; + + use super::*; + use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; + use crate::services::agent_trace_db::{ClaudeModelStateObservation, ObservationKind}; + use crate::services::agent_trace_storage::{ + resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, + }; + use crate::services::mutation_trace::runtime::{resolve_git_dir, resolve_worktree_id}; + use crate::services::mutation_trace::store::decode_revision; + + fn git(dir: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(dir) + .output() + .expect("git should spawn"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).expect("git output should be UTF-8") + } + + struct ClaudeRepo { + temp: tempfile::TempDir, + root: PathBuf, + state_root: PathBuf, + } + + impl ClaudeRepo { + fn new(label: &str) -> Self { + let temp = tempfile::Builder::new() + .prefix(&format!("sce-claude-mutation-scope-regression-{label}-")) + .tempdir() + .expect("temp dir should be created"); + let root = temp.path().join("repo"); + fs::create_dir_all(&root).expect("repo dir should be created"); + git(&root, &["init", "-q"]); + git(&root, &["config", "user.email", "test@example.invalid"]); + git(&root, &["config", "user.name", "SCE Test"]); + git( + &root, + &["remote", "add", "origin", "git@github.com:acme/widgets.git"], + ); + fs::write(root.join("file.txt"), "one\n").expect("seed file should write"); + git(&root, &["add", "-A"]); + git(&root, &["commit", "-qm", "base"]); + + let state_root = temp.path().join("state"); + fs::create_dir_all(&state_root).expect("state root should be created"); + resolve_agent_trace_storage_at_state_root( + &AgentTraceStorageContext { + repository_root: &root, + explicit_repository_id: None, + repository_remote: "origin", + }, + &state_root, + ) + .expect("state-root storage should initialize the repository DB"); + + Self { + temp, + root, + state_root, + } + } + + fn drive(&self, payload: &str) -> Result { + run_claude_mutation_scope_from_payload_at_state_root(&self.state_root, payload, None) + } + + fn drive_generic(&self, payload: &str) -> Result { + crate::services::hooks::mutation_scope::run_mutation_scope_from_payload_at_state_root( + &self.root, + &self.state_root, + payload, + None, + ) + } + + fn drive_flush(&self) -> Result { + self.drive_generic(&flush_payload()) + } + + fn db(&self) -> RepositoryAgentTraceDb { + crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( + &self.root, + &self.state_root, + "claude mutation-scope regression test assertions", + ) + .expect("assertion DB should open") + } + + fn cwd(&self) -> String { + self.root.to_string_lossy().into_owned() + } + + fn cwd_at(root: &Path) -> String { + root.to_string_lossy().into_owned() + } + + fn working_tree_at(root: &Path) -> String { + git(root, &["add", "-A"]); + git(root, &["write-tree"]).trim().to_owned() + } + + fn working_tree(&self) -> String { + Self::working_tree_at(&self.root) + } + + fn git_dir_at(root: &Path) -> PathBuf { + resolve_git_dir(root).expect("git dir should resolve") + } + + fn git_dir(&self) -> PathBuf { + Self::git_dir_at(&self.root) + } + + fn adapter_state_at(root: &Path) -> state::AdapterState { + state::read_state(&Self::git_dir_at(root)).expect("adapter state should be readable") + } + + fn adapter_state(&self) -> state::AdapterState { + Self::adapter_state_at(&self.root) + } + + fn worktree_id_at(root: &Path) -> String { + resolve_worktree_id(root) + .expect("worktree id should resolve") + .0 + } + + fn worktree_id(&self) -> String { + Self::worktree_id_at(&self.root) + } + + fn add_worktree(&self, name: &str) -> PathBuf { + let worktree_path = self.temp.path().join(name); + git( + &self.root, + &[ + "worktree", + "add", + "-q", + worktree_path.to_str().expect("utf-8 worktree path"), + ], + ); + worktree_path + } + } + + fn count(db: &RepositoryAgentTraceDb, table: &str) -> i64 { + db.query_map(&format!("SELECT COUNT(*) FROM {table}"), (), |row| { + row.get::(0).map_err(anyhow::Error::from) + }) + .expect("count query should succeed") + .into_iter() + .next() + .expect("a count row should exist") + } + + fn assert_raw_agent_trace_tables_untouched(db: &RepositoryAgentTraceDb) { + assert_eq!(count(db, "diff_traces"), 0); + assert_eq!(count(db, "post_commit_patch_intersections"), 0); + assert_eq!(count(db, "agent_traces"), 0); + } + + fn worktree_row(db: &RepositoryAgentTraceDb, worktree_id: &str) -> Option<(u64, String, bool)> { + db.query_map( + "SELECT revision, cursor_tree, needs_rebaseline FROM mutation_trace_worktrees \ + WHERE worktree_id = ?1", + (worktree_id,), + |row| { + let blob: Vec = row.get(0).map_err(anyhow::Error::from)?; + let revision = decode_revision(&blob)?; + let cursor_tree = row.get::(1).map_err(anyhow::Error::from)?; + let needs_rebaseline = row.get::(2).map_err(anyhow::Error::from)? != 0; + Ok((revision, cursor_tree, needs_rebaseline)) + }, + ) + .expect("worktree-row query should succeed") + .into_iter() + .next() + } + + fn processed_events(db: &RepositoryAgentTraceDb) -> Vec<(String, String)> { + db.query_map( + "SELECT scope_id, event_id FROM mutation_trace_processed_events \ + ORDER BY scope_id, event_id", + (), + |row| { + let scope_id = row.get::(0).map_err(anyhow::Error::from)?; + let event_id = row.get::(1).map_err(anyhow::Error::from)?; + Ok((scope_id, event_id)) + }, + ) + .expect("processed-events query should succeed") + } + + fn scope_status(db: &RepositoryAgentTraceDb, scope_id: &str) -> Option<(String, String)> { + db.query_map( + "SELECT actor_kind, status FROM mutation_trace_scopes WHERE scope_id = ?1", + (scope_id,), + |row| { + let actor_kind = row.get::(0).map_err(anyhow::Error::from)?; + let status = row.get::(1).map_err(anyhow::Error::from)?; + Ok((actor_kind, status)) + }, + ) + .expect("scope query should succeed") + .into_iter() + .next() + } + + fn scope_provenance( + db: &RepositoryAgentTraceDb, + scope_id: &str, + ) -> Option<(String, Option)> { + db.query_map( + "SELECT session_id, model_id FROM mutation_trace_scope_provenance \ + WHERE scope_id = ?1", + (scope_id,), + |row| { + let session_id = row.get::(0).map_err(anyhow::Error::from)?; + let model_id = row.get::>(1).map_err(anyhow::Error::from)?; + Ok((session_id, model_id)) + }, + ) + .expect("scope-provenance query should succeed") + .into_iter() + .next() + } + + fn mutation_events_for( + db: &RepositoryAgentTraceDb, + worktree_id: &str, + ) -> Vec<(String, Option, String)> { + db.query_map( + "SELECT attribution_kind, attribution_scope_id, boundary_kind \ + FROM mutation_trace_events WHERE worktree_id = ?1 ORDER BY revision", + (worktree_id,), + |row| { + let attribution_kind = row.get::(0).map_err(anyhow::Error::from)?; + let attribution_scope_id = + row.get::>(1).map_err(anyhow::Error::from)?; + let boundary_kind = row.get::(2).map_err(anyhow::Error::from)?; + Ok((attribution_kind, attribution_scope_id, boundary_kind)) + }, + ) + .expect("mutation-events query should succeed") + } + + fn tool_identity_json( + event_name: &str, + cwd: &str, + session_id: &str, + tool_name: &str, + tool_use_id: &str, + agent_id: Option<&str>, + ) -> String { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(event_name.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String(session_id.to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String(cwd.to_string())); + object.insert( + TOOL_NAME_FIELD.to_string(), + Value::String(tool_name.to_string()), + ); + object.insert( + TOOL_USE_ID_FIELD.to_string(), + Value::String(tool_use_id.to_string()), + ); + if let Some(agent_id) = agent_id { + object.insert( + AGENT_ID_FIELD.to_string(), + Value::String(agent_id.to_string()), + ); + } + Value::Object(object).to_string() + } + + fn pre_tool_use_for( + cwd: &str, + session_id: &str, + tool_name: &str, + tool_use_id: &str, + agent_id: Option<&str>, + ) -> String { + tool_identity_json( + HOOK_EVENT_PRE_TOOL_USE, + cwd, + session_id, + tool_name, + tool_use_id, + agent_id, + ) + } + + fn background_pre_tool_use_for( + cwd: &str, + session_id: &str, + tool_use_id: &str, + agent_id: Option<&str>, + ) -> String { + let mut object: serde_json::Map = serde_json::from_str(&pre_tool_use_for( + cwd, + session_id, + "Bash", + tool_use_id, + agent_id, + )) + .expect("base PreToolUse payload should parse"); + object.insert( + TOOL_INPUT_FIELD.to_string(), + json!({ "run_in_background": true }), + ); + Value::Object(object).to_string() + } + + fn post_tool_use_for( + cwd: &str, + session_id: &str, + tool_name: &str, + tool_use_id: &str, + agent_id: Option<&str>, + ) -> String { + tool_identity_json( + HOOK_EVENT_POST_TOOL_USE, + cwd, + session_id, + tool_name, + tool_use_id, + agent_id, + ) + } + + fn post_tool_use_failure_for( + cwd: &str, + session_id: &str, + tool_name: &str, + tool_use_id: &str, + agent_id: Option<&str>, + ) -> String { + tool_identity_json( + HOOK_EVENT_POST_TOOL_USE_FAILURE, + cwd, + session_id, + tool_name, + tool_use_id, + agent_id, + ) + } + + fn permission_denied_for( + cwd: &str, + session_id: &str, + tool_name: &str, + tool_use_id: &str, + agent_id: Option<&str>, + ) -> String { + tool_identity_json( + HOOK_EVENT_PERMISSION_DENIED, + cwd, + session_id, + tool_name, + tool_use_id, + agent_id, + ) + } + + fn session_json(event_name: &str, cwd: &str, session_id: &str) -> String { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(event_name.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String(session_id.to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String(cwd.to_string())); + Value::Object(object).to_string() + } + + fn agent_json(event_name: &str, cwd: &str, session_id: &str, agent_id: &str) -> String { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(event_name.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String(session_id.to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String(cwd.to_string())); + object.insert( + AGENT_ID_FIELD.to_string(), + Value::String(agent_id.to_string()), + ); + Value::Object(object).to_string() + } + + fn worktree_remove_json(session_id: &str, worktree_path: &str) -> String { + let mut object = serde_json::Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(HOOK_EVENT_WORKTREE_REMOVE.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String(session_id.to_string()), + ); + object.insert( + WORKTREE_PATH_FIELD.to_string(), + Value::String(worktree_path.to_string()), + ); + Value::Object(object).to_string() + } + + #[test] + fn test1_foreground_write_closes_ai_exclusive() { + let repo = ClaudeRepo::new("test1-foreground-write"); + let cwd = repo.cwd(); + + assert_eq!( + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_1", + None + )) + .expect("PreToolUse should succeed"), + "" + ); + let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); + + fs::write(repo.root.join("file.txt"), "one\ntwo\n") + .expect("the tool's own edit should write"); + + assert_eq!( + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_1", + None + )) + .expect("PostToolUse should succeed"), + "" + ); + + assert!( + repo.adapter_state().attempts.is_empty(), + "the closed attempt must be removed from adapter bookkeeping" + ); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("claude_code".to_string(), "closed".to_string())) + ); + let worktree_id = repo.worktree_id(); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![( + "ai_exclusive".to_string(), + Some(scope_id.clone()), + "close".to_string(), + )] + ); + assert_eq!( + worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), + Some(repo.working_tree()) + ); + assert_eq!( + processed_events(&db), + vec![ + (scope_id.clone(), claude_scope_close_event_id(&scope_id)), + (scope_id.clone(), claude_scope_start_event_id(&scope_id)), + ], + "rows are ordered by (scope_id, event_id), and 'close' sorts before 'start'" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test2_failed_bash_partial_write_still_closes_ai_exclusive() { + let repo = ClaudeRepo::new("test2-failed-bash"); + let cwd = repo.cwd(); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Bash", + "toolu_1", + None, + )) + .expect("PreToolUse should succeed"); + let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); + + fs::write(repo.root.join("file.txt"), "one\npartial\n") + .expect("the failed tool's partial edit should write"); + + assert_eq!( + repo.drive(&post_tool_use_failure_for( + &cwd, + "session-1", + "Bash", + "toolu_1", + None + )) + .expect("PostToolUseFailure should succeed"), + "" + ); + + assert!(repo.adapter_state().attempts.is_empty()); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("claude_code".to_string(), "closed".to_string())) + ); + let worktree_id = repo.worktree_id(); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![( + "ai_exclusive".to_string(), + Some(scope_id.clone()), + "close".to_string(), + )] + ); + assert_eq!( + worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), + Some(repo.working_tree()) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test4_duplicate_pre_and_post_replay_has_no_duplicate_transition() { + let repo = ClaudeRepo::new("test4-duplicate-replay"); + let cwd = repo.cwd(); + let pre = pre_tool_use_for(&cwd, "session-1", "Write", "toolu_1", None); + + repo.drive(&pre).expect("first PreToolUse should succeed"); + assert_eq!( + repo.drive(&pre) + .expect("duplicate PreToolUse should be idempotent"), + "" + ); + assert_eq!( + repo.adapter_state().attempts.len(), + 1, + "AC4: duplicate PreToolUse delivery must reuse the same attempt" + ); + let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); + + fs::write(repo.root.join("file.txt"), "one\ntwo\n").expect("the edit should write"); + + let post = post_tool_use_for(&cwd, "session-1", "Write", "toolu_1", None); + repo.drive(&post).expect("first PostToolUse should succeed"); + + let db = repo.db(); + let (revision_before, events_before, processed_before) = ( + worktree_row(&db, &repo.worktree_id()) + .map(|(revision, _, _)| revision) + .expect("a worktree row should exist"), + count(&db, "mutation_trace_events"), + count(&db, "mutation_trace_processed_events"), + ); + + assert_eq!( + repo.drive(&post) + .expect("duplicate PostToolUse delivery must be a safe no-op"), + "" + ); + + let db = repo.db(); + assert_eq!( + worktree_row(&db, &repo.worktree_id()).map(|(revision, _, _)| revision), + Some(revision_before) + ); + assert_eq!(count(&db, "mutation_trace_events"), events_before); + assert_eq!( + count(&db, "mutation_trace_processed_events"), + processed_before + ); + assert_eq!( + processed_events(&db) + .into_iter() + .filter(|(scope, event)| scope == &scope_id + && event == &claude_scope_close_event_id(&scope_id)) + .count(), + 1 + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test5_auto_permission_denied_abandons_and_requires_rebaseline() { + let repo = ClaudeRepo::new("test5-permission-denied"); + let cwd = repo.cwd(); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_1", + None, + )) + .expect("PreToolUse should succeed"); + let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); + + assert_eq!( + repo.drive(&permission_denied_for( + &cwd, + "session-1", + "Write", + "toolu_1", + None + )) + .expect("PermissionDenied should succeed"), + "" + ); + + assert!( + repo.adapter_state().attempts.is_empty(), + "the denied attempt must be retired from adapter bookkeeping" + ); + assert!( + repo.adapter_state().recovery_pending, + "D19: abandonment must arm the recovery barrier" + ); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("claude_code".to_string(), "abandoned".to_string())) + ); + let worktree_id = repo.worktree_id(); + assert!( + worktree_row(&db, &worktree_id) + .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline), + "AC12: a denied execution must leave the worktree needing rebaseline" + ); + assert_eq!(count(&db, "mutation_trace_events"), 0); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test3_two_parallel_subagent_tools_produce_ai_contended() { + let repo = ClaudeRepo::new("test3-parallel-subagents"); + let cwd = repo.cwd(); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_a", + Some("agent-a"), + )) + .expect("agent-a PreToolUse should succeed"); + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_b", + Some("agent-b"), + )) + .expect("agent-b PreToolUse should succeed"); + assert_eq!(repo.adapter_state().attempts.len(), 2); + + fs::write(repo.root.join("file.txt"), "one\ncontended\n") + .expect("the racing edit should write"); + + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_a", + Some("agent-a"), + )) + .expect("agent-a PostToolUse (closing while agent-b is still active) should succeed"); + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_b", + Some("agent-b"), + )) + .expect("agent-b PostToolUse should succeed"); + + assert!(repo.adapter_state().attempts.is_empty()); + + let db = repo.db(); + let worktree_id = repo.worktree_id(); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![("ai_contended".to_string(), None, "close".to_string())], + "AC11: a tree transition observed while two scopes are live must be AiContended" + ); + assert_eq!( + worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), + Some(repo.working_tree()) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test6_other_hook_denial_is_retired_by_stop_cleanup() { + let repo = ClaudeRepo::new("test6-stop-cleanup"); + let cwd = repo.cwd(); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Bash", + "toolu_1", + None, + )) + .expect("PreToolUse should succeed"); + let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); + + assert_eq!( + repo.drive(&session_json(HOOK_EVENT_STOP, &cwd, "session-1")) + .expect("Stop should succeed"), + "" + ); + + assert!( + repo.adapter_state().attempts.is_empty(), + "AC13: Stop must retire the stale main-thread attempt" + ); + assert!(repo.adapter_state().recovery_pending); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("claude_code".to_string(), "abandoned".to_string())) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test7_interrupted_main_turn_is_retired_by_next_user_prompt_submit() { + let repo = ClaudeRepo::new("test7-user-prompt-submit-cleanup"); + let cwd = repo.cwd(); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_1", + None, + )) + .expect("PreToolUse should succeed"); + let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); + + fs::write(repo.root.join("file.txt"), "one\ninterrupted\n") + .expect("the interrupted edit should write"); + + assert_eq!( + repo.drive(&session_json( + HOOK_EVENT_USER_PROMPT_SUBMIT, + &cwd, + "session-1" + )) + .expect("UserPromptSubmit should succeed"), + "" + ); + + assert!( + repo.adapter_state().attempts.is_empty(), + "AC14: UserPromptSubmit must retire the stale main-thread attempt \ + before another mutation-capable tool can start" + ); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("claude_code".to_string(), "abandoned".to_string())) + ); + let worktree_id = repo.worktree_id(); + assert!(worktree_row(&db, &worktree_id) + .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline)); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test8_resumed_subagent_tool_use_id_gets_a_fresh_scope_id() { + let repo = ClaudeRepo::new("test8-resumed-subagent"); + let cwd = repo.cwd(); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_resumed", + Some("agent-a"), + )) + .expect("first PreToolUse should succeed"); + let first_scope_id = repo.adapter_state().attempts[0].scope_id.clone(); + + fs::write(repo.root.join("file.txt"), "one\nfirst\n").expect("first edit should write"); + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_resumed", + Some("agent-a"), + )) + .expect("first PostToolUse should succeed"); + assert!(repo.adapter_state().attempts.is_empty()); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_resumed", + Some("agent-a"), + )) + .expect("resumed PreToolUse should succeed"); + let second_scope_id = repo.adapter_state().attempts[0].scope_id.clone(); + + assert_ne!( + first_scope_id, second_scope_id, + "AC15: a resumed subagent's new tool attempt must receive a fresh ScopeId" + ); + + fs::write(repo.root.join("file.txt"), "one\nfirst\nsecond\n") + .expect("second edit should write"); + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_resumed", + Some("agent-a"), + )) + .expect("second PostToolUse should succeed"); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &first_scope_id).map(|(_, status)| status), + Some("closed".to_string()) + ); + assert_eq!( + scope_status(&db, &second_scope_id).map(|(_, status)| status), + Some("closed".to_string()) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test9_main_and_subagent_concurrent_mutation_is_ai_contended() { + let repo = ClaudeRepo::new("test9-main-plus-subagent"); + let cwd = repo.cwd(); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_main", + None, + )) + .expect("main-thread PreToolUse should succeed"); + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_sub", + Some("agent-a"), + )) + .expect("subagent PreToolUse should succeed"); + assert_eq!(repo.adapter_state().attempts.len(), 2); + + fs::write(repo.root.join("file.txt"), "one\nboth-writing\n") + .expect("the racing edit should write"); + + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_main", + None, + )) + .expect("main-thread PostToolUse should succeed"); + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_sub", + Some("agent-a"), + )) + .expect("subagent PostToolUse should succeed"); + + let db = repo.db(); + let worktree_id = repo.worktree_id(); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![("ai_contended".to_string(), None, "close".to_string())], + "AC11: main + subagent concurrent mutation must be AiContended" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test10_isolated_subagent_worktree_advances_only_its_own_cursor() { + let repo = ClaudeRepo::new("test10-isolated-worktree"); + let worktree_path = repo.add_worktree("subagent-worktree"); + let worktree_cwd = ClaudeRepo::cwd_at(&worktree_path); + + let main_worktree_id = repo.worktree_id(); + let sub_worktree_id = ClaudeRepo::worktree_id_at(&worktree_path); + assert_ne!( + main_worktree_id, sub_worktree_id, + "a linked worktree must resolve to a distinct WorktreeId" + ); + + repo.drive_flush() + .expect("main-checkout baseline flush should succeed"); + let main_cursor_before = worktree_row(&repo.db(), &main_worktree_id) + .map(|(_, cursor_tree, _)| cursor_tree) + .expect("main checkout should have a baseline worktree row"); + + repo.drive(&pre_tool_use_for( + &worktree_cwd, + "session-1", + "Write", + "toolu_sub", + Some("agent-a"), + )) + .expect("subagent PreToolUse in the isolated worktree should succeed"); + fs::write(worktree_path.join("file.txt"), "one\nisolated\n") + .expect("the isolated worktree's own edit should write"); + repo.drive(&post_tool_use_for( + &worktree_cwd, + "session-1", + "Write", + "toolu_sub", + Some("agent-a"), + )) + .expect("subagent PostToolUse in the isolated worktree should succeed"); + + let db = repo.db(); + assert_eq!( + worktree_row(&db, &main_worktree_id).map(|(_, cursor_tree, _)| cursor_tree), + Some(main_cursor_before), + "AC17: the main checkout's mutation cursor must be unchanged" + ); + let sub_row = worktree_row(&db, &sub_worktree_id).expect("subagent worktree row"); + assert_eq!( + sub_row.1, + ClaudeRepo::working_tree_at(&worktree_path), + "AC16/AC17: the isolated worktree's own cursor must advance" + ); + assert_eq!( + mutation_events_for(&db, &sub_worktree_id) + .into_iter() + .map(|(attribution, _, boundary)| (attribution, boundary)) + .collect::>(), + vec![("ai_exclusive".to_string(), "close".to_string())] + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test11_worktree_remove_cleans_only_that_worktrees_outstanding_attempt() { + let repo = ClaudeRepo::new("test11-worktree-remove"); + let worktree_path = repo.add_worktree("removed-worktree"); + let worktree_cwd = ClaudeRepo::cwd_at(&worktree_path); + let main_cwd = repo.cwd(); + + repo.drive(&pre_tool_use_for( + &main_cwd, + "session-1", + "Write", + "toolu_main", + None, + )) + .expect("main-thread PreToolUse should succeed"); + repo.drive(&pre_tool_use_for( + &worktree_cwd, + "session-1", + "Write", + "toolu_sub", + Some("agent-a"), + )) + .expect("subagent PreToolUse in the isolated worktree should succeed"); + + assert_eq!(ClaudeRepo::adapter_state_at(&repo.root).attempts.len(), 1); + assert_eq!( + ClaudeRepo::adapter_state_at(&worktree_path).attempts.len(), + 1 + ); + + assert_eq!( + repo.drive(&worktree_remove_json("session-1", &worktree_cwd)) + .expect("WorktreeRemove should succeed"), + "" + ); + + assert!( + ClaudeRepo::adapter_state_at(&worktree_path) + .attempts + .is_empty(), + "AC13/D22: WorktreeRemove must retire the outstanding attempt for that worktree" + ); + assert_eq!( + ClaudeRepo::adapter_state_at(&repo.root).attempts.len(), + 1, + "WorktreeRemove for one worktree must not touch the main checkout's attempts" + ); + + assert_raw_agent_trace_tables_untouched(&repo.db()); + } + + #[test] + fn test12_pending_start_crash_before_start_is_recovered_conservatively() { + let repo = ClaudeRepo::new("test12-pending-start-crash"); + let cwd = repo.cwd(); + let git_dir = repo.git_dir(); + + let key = AttemptKey { + session_id: "session-1".to_string(), + agent_id: None, + tool_use_id: "toolu_crashed".to_string(), + }; + let allocated = + state::allocate_attempt(&git_dir, &key, "Write").expect("allocation should succeed"); + assert_eq!(allocated.attempt.phase, state::AttemptPhase::PendingStart); + + assert_eq!( + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_crashed", + None + )) + .expect("D11: PostToolUse on a pending_start attempt must abandon, not late-start"), + "" + ); + + assert!( + repo.adapter_state().attempts.is_empty(), + "the never-started attempt must be retired" + ); + assert!(repo.adapter_state().recovery_pending); + let db = repo.db(); + assert_eq!( + scope_status(&db, &allocated.attempt.scope_id), + None, + "a Start that never committed must never appear as a real scope" + ); + assert_eq!(count(&db, "mutation_trace_events"), 0); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_fresh", + None, + )) + .expect("the next PreToolUse should proceed after the quiescent flush"); + assert!(!repo.adapter_state().recovery_pending); + assert_eq!(repo.adapter_state().attempts.len(), 1); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test13_start_committed_before_state_settlement_is_recovered_by_abandonment() { + let repo = ClaudeRepo::new("test13-start-committed-crash"); + let cwd = repo.cwd(); + let git_dir = repo.git_dir(); + + let key = AttemptKey { + session_id: "session-1".to_string(), + agent_id: None, + tool_use_id: "toolu_crashed".to_string(), + }; + let allocated = + state::allocate_attempt(&git_dir, &key, "Write").expect("allocation should succeed"); + let scope_id = allocated.attempt.scope_id.clone(); + + repo.drive_generic(&scope_boundary_payload( + "start", + &scope_id, + &claude_scope_start_event_id(&scope_id), + )) + .expect("the runtime Start should commit durably"); + assert_eq!( + state::read_state(&git_dir).unwrap().attempts[0].phase, + state::AttemptPhase::PendingStart + ); + + assert_eq!( + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_crashed", + None + )) + .expect("D11: a pending_start attempt with a committed Start must be abandoned"), + "" + ); + + assert!(repo.adapter_state().attempts.is_empty()); + assert!(repo.adapter_state().recovery_pending); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("claude_code".to_string(), "abandoned".to_string())), + "the runtime's own committed Start must settle as a real abandonment" + ); + let worktree_id = repo.worktree_id(); + assert!(worktree_row(&db, &worktree_id) + .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline)); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test14_terminal_runtime_success_before_state_cleanup_is_replay_safe() { + let repo = ClaudeRepo::new("test14-close-committed-crash"); + let cwd = repo.cwd(); + let git_dir = repo.git_dir(); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_1", + None, + )) + .expect("PreToolUse should succeed"); + let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); + fs::write(repo.root.join("file.txt"), "one\ntwo\n").expect("the edit should write"); + + repo.drive_generic(&scope_boundary_payload( + "close", + &scope_id, + &claude_scope_close_event_id(&scope_id), + )) + .expect("the runtime Close should commit durably"); + assert_eq!(state::read_state(&git_dir).unwrap().attempts.len(), 1); + + let db = repo.db(); + let (revision_before, events_before) = ( + worktree_row(&db, &repo.worktree_id()) + .map(|(revision, _, _)| revision) + .expect("a worktree row should exist"), + count(&db, "mutation_trace_events"), + ); + + assert_eq!( + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_1", + None + )) + .expect("a replayed Close against an already-durable commit must be safe"), + "" + ); + + assert!( + repo.adapter_state().attempts.is_empty(), + "the stale bookkeeping must finally be cleared" + ); + + let db = repo.db(); + assert_eq!( + worktree_row(&db, &repo.worktree_id()).map(|(revision, _, _)| revision), + Some(revision_before), + "a durably completed Close must never be re-applied as a second transition" + ); + assert_eq!(count(&db, "mutation_trace_events"), events_before); + assert_eq!( + scope_status(&db, &scope_id).map(|(_, status)| status), + Some("closed".to_string()) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test15_explicit_background_bash_is_denied_with_no_scope() { + let repo = ClaudeRepo::new("test15-explicit-background-bash"); + let cwd = repo.cwd(); + + let output = repo + .drive(&background_pre_tool_use_for( + &cwd, + "session-1", + "toolu_1", + None, + )) + .expect("an explicit background shell must still return Ok with a deny payload"); + + assert_eq!( + output, + pre_tool_use_deny_json(EXPLICIT_BACKGROUND_SHELL_DENY_REASON) + ); + assert!( + repo.adapter_state().attempts.is_empty(), + "AC21: an explicit background shell must create no scope" + ); + + let db = repo.db(); + assert_eq!(count(&db, "mutation_trace_scopes"), 0); + assert_eq!(count(&db, "mutation_trace_events"), 0); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test18_model_switch_does_not_rewrite_scope_provenance() { + let repo = ClaudeRepo::new("model-switch-provenance"); + let session_id = "session-model-switch"; + let db = repo.db(); + db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: "cc_session-model-switch".to_string(), + agent_id: String::new(), + model_id: "claude/sonnet".to_string(), + observation_kind: ObservationKind::SessionStart, + source: "test".to_string(), + observed_at_ms: 1, + }) + .expect("initial Claude model state should persist"); + + let pre_payload = + pre_tool_use_for(&repo.cwd(), session_id, "Bash", "toolu_model_switch", None); + repo.drive(&pre_payload) + .expect("initial PreToolUse should establish a scope"); + let scope_id = repo + .adapter_state() + .attempts + .first() + .expect("the scope should remain live") + .scope_id + .clone(); + let before = scope_provenance(&repo.db(), &scope_id); + assert_eq!( + before, + Some(( + "cc_session-model-switch".to_string(), + Some("claude/sonnet".to_string()), + )) + ); + + repo.db() + .upsert_claude_model_state(ClaudeModelStateObservation { + session_id: "cc_session-model-switch".to_string(), + agent_id: String::new(), + model_id: "claude/opus".to_string(), + observation_kind: ObservationKind::PostModelSwitch, + source: "test".to_string(), + observed_at_ms: 2, + }) + .expect("model switch should persist"); + repo.drive(&pre_payload) + .expect("replayed PreToolUse should remain idempotent"); + + assert_eq!(scope_provenance(&repo.db(), &scope_id), before); + } + + #[test] + fn test16_regression_matrix_leaves_raw_agent_trace_tables_untouched() { + let repo = ClaudeRepo::new("test16-raw-tables-untouched"); + let cwd = repo.cwd(); + + let before = { + let db = repo.db(); + ( + count(&db, "diff_traces"), + count(&db, "post_commit_patch_intersections"), + count(&db, "agent_traces"), + ) + }; + assert_eq!(before, (0, 0, 0)); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_1", + None, + )) + .expect("PreToolUse should succeed"); + fs::write(repo.root.join("file.txt"), "one\ntwo\n").expect("the edit should write"); + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_1", + None, + )) + .expect("PostToolUse should succeed"); + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Write", + "toolu_2", + None, + )) + .expect("second PreToolUse should succeed"); + repo.drive(&permission_denied_for( + &cwd, + "session-1", + "Write", + "toolu_2", + None, + )) + .expect("PermissionDenied should succeed"); + + let db = repo.db(); + let after = ( + count(&db, "diff_traces"), + count(&db, "post_commit_patch_intersections"), + count(&db, "agent_traces"), + ); + assert_eq!( + after, + (0, 0, 0), + "AC20: Claude mutation-scope-only regressions must leave the raw \ + Agent Trace tables unchanged" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test17_detached_descendant_write_after_post_tool_use_is_not_folded_into_the_closed_scope() { + let repo = ClaudeRepo::new("test17-detached-descendant"); + let cwd = repo.cwd(); + + repo.drive(&pre_tool_use_for( + &cwd, + "session-1", + "Bash", + "toolu_1", + None, + )) + .expect("PreToolUse should succeed"); + let scope_id = repo.adapter_state().attempts[0].scope_id.clone(); + + fs::write(repo.root.join("file.txt"), "one\nforeground-output\n") + .expect("the tool's own foreground write should write"); + let tree_at_close = repo.working_tree(); + + repo.drive(&post_tool_use_for( + &cwd, + "session-1", + "Bash", + "toolu_1", + None, + )) + .expect("PostToolUse should succeed"); + + let db = repo.db(); + let worktree_id = repo.worktree_id(); + assert_eq!( + scope_status(&db, &scope_id).map(|(_, status)| status), + Some("closed".to_string()) + ); + assert_eq!( + worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), + Some(tree_at_close.clone()), + "the scope must close at the tool's own observed tree" + ); + + fs::write( + repo.root.join("file.txt"), + "one\nforeground-output\ndetached-descendant\n", + ) + .expect("the detached descendant's later write should write"); + let tree_after_descendant = repo.working_tree(); + assert_ne!(tree_after_descendant, tree_at_close); + + let events_before_flush = mutation_events_for(&db, &worktree_id); + + repo.drive_flush() + .expect("a later recovery/diagnostic flush should succeed"); + + let db = repo.db(); + let events_after_flush = mutation_events_for(&db, &worktree_id); + assert_eq!( + events_after_flush.len(), + events_before_flush.len() + 1, + "the detached descendant's mutation must surface as its own event" + ); + let (attribution_kind, attribution_scope_id, _) = events_after_flush + .last() + .expect("a flush event should exist"); + assert_ne!( + attribution_scope_id.as_deref(), + Some(scope_id.as_str()), + "the detached descendant's mutation must never be attributed to the \ + already-closed tool scope" + ); + assert_eq!(attribution_kind, "ineligible_unscoped"); + assert_eq!( + worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), + Some(tree_after_descendant) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } +} diff --git a/cli/src/services/hooks/claude_transcript.rs b/cli/src/services/hooks/claude_transcript.rs index 8ea3a5dea..b2aa26b1a 100644 --- a/cli/src/services/hooks/claude_transcript.rs +++ b/cli/src/services/hooks/claude_transcript.rs @@ -4,12 +4,6 @@ use std::path::Path; use serde_json::Value; -/// Extract the model identity from a Claude JSONL transcript by matching an -/// assistant message whose `tool_use` content block has the given ID. -/// -/// Transcript access and parsing are fail-open. Unreadable files, unreadable -/// lines, missing fields, and unmatched tool calls return `None`; malformed -/// unrelated JSONL records are skipped so later valid records can still match. pub fn extract_claude_transcript_model( transcript_path: &Path, tool_use_id: &str, @@ -39,8 +33,6 @@ fn extract_claude_transcript_model_from_reader( continue; }; - // Current Claude transcripts wrap the assistant message in `message`. - // Keep support for the earlier flat assistant-message shape as well. let message = if let Some(message) = record.get("message").and_then(Value::as_object) { let is_assistant = record .get("type") diff --git a/cli/src/services/hooks/claude_transforms.rs b/cli/src/services/hooks/claude_transforms.rs new file mode 100644 index 000000000..d7a35bc3c --- /dev/null +++ b/cli/src/services/hooks/claude_transforms.rs @@ -0,0 +1,231 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{bail, Result}; +use serde_json::{json, Value}; + +use crate::services::structured_patch::{build_claude_post_tool_use_patch, PatchBuildResult}; + +use super::conversation_trace::{ + conversation_trace_validation_error, required_non_empty_string_field, + CONVERSATION_TRACE_MESSAGE_PART_UPDATED, CONVERSATION_TRACE_MESSAGE_UPDATED, +}; +use super::runtime::current_unix_time_ms; + +pub(crate) fn transform_claude_user_prompt_submit( + payload: &serde_json::Map, +) -> Result> { + transform_claude_user_prompt_submit_with( + payload, + || { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + let ts = uuid::Timestamp::from_unix(uuid::NoContext, now.as_secs(), now.subsec_nanos()); + uuid::Uuid::new_v7(ts) + }, + || current_unix_time_ms().unwrap_or(0), + ) +} + +pub(crate) fn transform_claude_user_prompt_submit_with( + payload: &serde_json::Map, + generate_message_id: G, + generate_timestamp_ms: T, +) -> Result> +where + G: FnOnce() -> uuid::Uuid, + T: FnOnce() -> i64, +{ + let event_name = required_non_empty_string_field( + payload, + "hook_event_name", + conversation_trace_validation_error, + )?; + + if event_name != "UserPromptSubmit" { + let raw_content = serde_json::to_string(payload).unwrap_or_default(); + bail!(conversation_trace_validation_error(&format!( + "unsupported Claude hook event '{event_name}': only 'UserPromptSubmit' is supported. Raw event: {raw_content}" + ))); + } + + let session_id = required_non_empty_string_field( + payload, + "session_id", + conversation_trace_validation_error, + )?; + let prompt = + required_non_empty_string_field(payload, "prompt", conversation_trace_validation_error)?; + + let message_id = generate_message_id().to_string(); + let generated_at_unix_ms = generate_timestamp_ms(); + + Ok(vec![ + json!({ + "type": CONVERSATION_TRACE_MESSAGE_UPDATED, + "session_id": session_id, + "message_id": message_id, + "role": "user", + "generated_at_unix_ms": generated_at_unix_ms, + }), + json!({ + "type": CONVERSATION_TRACE_MESSAGE_PART_UPDATED, + "session_id": session_id, + "message_id": message_id, + "part_type": "text", + "text": prompt, + "generated_at_unix_ms": generated_at_unix_ms, + }), + ]) +} + +pub(crate) fn transform_claude_stop( + payload: &serde_json::Map, +) -> Result> { + transform_claude_stop_with( + payload, + || { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + let ts = uuid::Timestamp::from_unix(uuid::NoContext, now.as_secs(), now.subsec_nanos()); + uuid::Uuid::new_v7(ts) + }, + || current_unix_time_ms().unwrap_or(0), + ) +} + +pub(crate) fn transform_claude_stop_with( + payload: &serde_json::Map, + generate_message_id: G, + generate_timestamp_ms: T, +) -> Result> +where + G: FnOnce() -> uuid::Uuid, + T: FnOnce() -> i64, +{ + let event_name = required_non_empty_string_field( + payload, + "hook_event_name", + conversation_trace_validation_error, + )?; + + if event_name != "Stop" { + let raw_content = serde_json::to_string(payload).unwrap_or_default(); + bail!(conversation_trace_validation_error(&format!( + "unsupported Claude hook event '{event_name}': only 'Stop' is supported. Raw event: {raw_content}" + ))); + } + + let session_id = required_non_empty_string_field( + payload, + "session_id", + conversation_trace_validation_error, + )?; + let last_assistant_message = required_non_empty_string_field( + payload, + "last_assistant_message", + conversation_trace_validation_error, + )?; + + let message_id = generate_message_id().to_string(); + let generated_at_unix_ms = generate_timestamp_ms(); + + Ok(vec![ + json!({ + "type": CONVERSATION_TRACE_MESSAGE_UPDATED, + "session_id": session_id, + "message_id": message_id, + "role": "assistant", + "generated_at_unix_ms": generated_at_unix_ms, + }), + json!({ + "type": CONVERSATION_TRACE_MESSAGE_PART_UPDATED, + "session_id": session_id, + "message_id": message_id, + "part_type": "text", + "text": last_assistant_message, + "generated_at_unix_ms": generated_at_unix_ms, + }), + ]) +} +pub(crate) fn transform_claude_post_tool_use( + payload: &serde_json::Map, +) -> Result> { + transform_claude_post_tool_use_with( + payload, + || { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + let ts = uuid::Timestamp::from_unix(uuid::NoContext, now.as_secs(), now.subsec_nanos()); + uuid::Uuid::new_v7(ts) + }, + || current_unix_time_ms().unwrap_or(0), + ) +} + +pub(crate) fn transform_claude_post_tool_use_with( + payload: &serde_json::Map, + generate_message_id: G, + generate_timestamp_ms: T, +) -> Result> +where + G: FnOnce() -> uuid::Uuid, + T: FnOnce() -> i64, +{ + let event_name = required_non_empty_string_field( + payload, + "hook_event_name", + conversation_trace_validation_error, + )?; + + if event_name != "PostToolUse" { + let raw_content = serde_json::to_string(payload).unwrap_or_default(); + bail!(conversation_trace_validation_error(&format!( + "unsupported Claude hook event '{event_name}': only 'PostToolUse' is supported. Raw event: {raw_content}" + ))); + } + + let tool_name = payload + .get("tool_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if tool_name != "Write" && tool_name != "Edit" { + return Ok(vec![]); + } + + let session_id = required_non_empty_string_field( + payload, + "session_id", + conversation_trace_validation_error, + )?; + + let message_id = generate_message_id().to_string(); + let generated_at_unix_ms = generate_timestamp_ms(); + + match build_claude_post_tool_use_patch(payload) { + PatchBuildResult::Built(parsed_patch) => { + let text = serde_json::to_string(&parsed_patch)?; + let items = vec![ + json!({ + "type": CONVERSATION_TRACE_MESSAGE_UPDATED, + "session_id": session_id, + "message_id": message_id, + "role": "assistant", + "generated_at_unix_ms": generated_at_unix_ms, + }), + json!({ + "type": CONVERSATION_TRACE_MESSAGE_PART_UPDATED, + "session_id": session_id, + "message_id": message_id, + "part_type": "patch", + "text": text, + "generated_at_unix_ms": generated_at_unix_ms, + }), + ]; + Ok(items) + } + PatchBuildResult::Skipped(_) => Ok(vec![]), + } +} diff --git a/cli/src/services/hooks/codex/apply_patch/mod.rs b/cli/src/services/hooks/codex/apply_patch/mod.rs index a4b9a00c2..aab79885f 100644 --- a/cli/src/services/hooks/codex/apply_patch/mod.rs +++ b/cli/src/services/hooks/codex/apply_patch/mod.rs @@ -1,8 +1,3 @@ -//! Parses Codex's custom `apply_patch` text format (`*** Begin Patch` ... -//! `*** End Patch`) into a typed [`CodexPatch`], normalizes it into an -//! SCE-supported unified diff, and persists non-empty results as a -//! `diff_traces` row for the `PostToolUse`/`apply_patch` dispatch arm. - mod normalize; mod parser; mod path; @@ -32,14 +27,6 @@ use super::super::{ }; use super::CodexHookEvent; -/// Handles a Codex `PostToolUse(apply_patch)` event: reads the raw patch text -/// from `tool_input.command`, parses it (T10), normalizes it (T11), and — for -/// a non-empty normalized result — persists one `diff_traces` row. -/// -/// Every path here, success or fail-open, returns empty stdout: a missing or -/// non-string `command`, a parse failure (logged), and an empty normalized -/// patch (e.g. delete-only) all resolve to `Ok(String::new())` with no -/// evidence written. pub(super) fn handle( repository_root: &Path, event: &CodexHookEvent, @@ -54,8 +41,6 @@ pub(super) fn handle_with_state_root( state_root: Option<&Path>, logger: Option<&dyn Logger>, ) -> Result { - // Validate the session before parsing, path resolution, or DB access so - // invalid Codex events can never reach apply_patch persistence. required_session_id(event.session_id.as_deref())?; let Some(command) = apply_patch_command_from_event(event) else { @@ -160,9 +145,6 @@ pub(super) fn handle_with_state_root( persist_with(&db, event, &normalized_patch, time_ms) } -/// Codex's `PostToolUse` `tool_input` for the `apply_patch` tool carries the -/// raw patch text under `command`, mirroring the `Bash` tool's `tool_input` -/// shape this module's sibling `bash_policy.rs` already relies on. fn apply_patch_command_from_event(event: &CodexHookEvent) -> Option<&str> { event .tool_input @@ -180,9 +162,6 @@ fn required_session_id(value: Option<&str>) -> Result<&str> { } } -/// Injectable counterpart of `handle`'s persistence step, for deterministic -/// testing against an already-open Agent Trace DB — mirrors the -/// `user_prompt_submit`/`stop` sibling arms' `capture_with` pattern. fn persist_with( db: &RepositoryAgentTraceDb, event: &CodexHookEvent, @@ -322,10 +301,6 @@ mod tests { } } - // --- fail-open / successful no-op behaviors: `handle` returns before it - // would ever open the Agent Trace DB, so a non-existent repository root - // is safe to pass through unused. --- - #[test] fn handle_fails_open_silently_when_tool_input_missing() { let output = handle( @@ -437,11 +412,6 @@ mod tests { assert_eq!(output, ""); } - // --- persistence content (AC11-AC14): `persist_with` against a real, - // directly-opened Agent Trace DB, mirroring `user_prompt_submit`/`stop`'s - // own injectable-level testing precedent rather than the full - // hook-runtime DB resolution (which requires a prior `sce setup`). --- - #[test] fn apply_patch_persists_one_row_with_expected_field_values_for_add_and_update() { let db_path = unique_test_db_path("add-update"); @@ -585,13 +555,6 @@ mod tests { remove_test_db(&db_path); } - /// AC15: a committed Codex `apply_patch` Update whose `diff_trace` carries - /// synthetic, patch-local line numbers is still attributed through the - /// existing, unmodified post-commit intersection pipeline - /// (`build_agent_trace`, the same function the real `post-commit` hook - /// flow calls) when the real committed line numbers differ, and the - /// resulting Agent Trace identifies Codex as the tool and preserves the - /// Codex model ID. #[test] fn apply_patch_diff_trace_attributes_through_agent_trace_pipeline_at_different_real_lines() { let db_path = unique_test_db_path("agent-trace-pipeline"); @@ -612,9 +575,6 @@ mod tests { assert_eq!(recent.loaded_count(), 1); let constructed = &recent.patches[0]; - // A realistic post-commit patch where the same touched lines sit at - // real line 42, far from the diff_trace's synthetic line 1, plus one - // unrelated committed line that must not be attributed to Codex. let post_commit_patch = ParsedPatch { files: vec![PatchFileChange { old_path: "src/lib.rs".to_string(), diff --git a/cli/src/services/hooks/codex/apply_patch/normalize.rs b/cli/src/services/hooks/codex/apply_patch/normalize.rs index 93dff816e..4ffd55fa4 100644 --- a/cli/src/services/hooks/codex/apply_patch/normalize.rs +++ b/cli/src/services/hooks/codex/apply_patch/normalize.rs @@ -1,25 +1,3 @@ -//! Normalizes a parsed Codex `apply_patch` payload ([`CodexPatch`]) into SCE -//! `Index:`-form unified-diff text that `crate::services::patch::parse_patch` -//! already accepts. -//! -//! Positions are deterministic and event-scoped: each `Update File` operation -//! numbers only the touched (`+`/`-`) lines it actually emits, from a bounded -//! range derived from the stable `tool_use_id`. Local offsets are allocated -//! across every emitted operation, hunk, and file. Codex's own unchanged -//! context lines are dropped, not persisted as evidence, and contribute no -//! positional weight. These positions are evidence identities, never real -//! filesystem line numbers. The -//! existing, unmodified `intersect_patches` -//! historical `kind`+`content` fallback is what lets this synthetic-line -//! evidence still attribute correctly once a real commit lands at different -//! real line numbers (see plan `context/plans/codex-cli-integration.md` -//! T11/AC15) — this module does not touch that fallback. -//! -//! `Delete File` operations, and `Update File` + `Move to` operations with no -//! changed lines, contribute no evidence and are silently dropped: an -//! `apply_patch` producing no provable evidence normalizes to an empty -//! string. - use std::fmt::Write as _; use sha2::{Digest, Sha256}; @@ -32,8 +10,6 @@ const CODEX_SYNTHETIC_LINE_ID_DOMAIN: &[u8] = b"sce-codex-apply-patch-line-id-v1 const SYNTHETIC_EVENT_RANGE_SIZE: u64 = 1 << 31; const SYNTHETIC_BASE_OFFSET: u64 = 2; -/// Error produced when Codex apply-patch evidence cannot be assigned safe, -/// event-scoped synthetic line identities. #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct CodexPatchNormalizeError { message: String, @@ -53,12 +29,6 @@ fn normalize_error(message: impl Into) -> CodexPatchNormalizeError { } } -/// Normalizes every `Add`/`Update` file operation in `patch` into one -/// combined SCE `Index:`-form unified-diff string, in operation order. -/// -/// The synthetic line identities are deterministic for `tool_use_id`, and -/// local offsets are allocated across the entire patch rather than restarting -/// for each file. They are evidence identities, not source line numbers. #[allow(dead_code)] pub(crate) fn normalize_codex_patch( patch: &CodexPatch, @@ -192,8 +162,6 @@ fn normalize_update( for line in &hunk.lines { match line { - // Codex's unchanged context is dropped, not persisted as - // evidence, and does not affect synthetic positions. CodexHunkLine::Context(_) => {} CodexHunkLine::Removed(content) => { hunk_body.push('-'); @@ -329,8 +297,6 @@ mod tests { let normalized = normalize_codex_patch(&patch, "tool-1").expect("should normalize"); let base = test_base(); - // The context line (" unchanged") is dropped entirely and - // contributes no positional weight. assert_eq!( normalized, format!( @@ -719,11 +685,6 @@ mod tests { synthetic_base(tool_use_id).expect("test identity should hash") } - /// AC15: synthetic patch-local line numbers must still attribute - /// correctly through the existing, unmodified `intersect_patches` - /// historical `kind`+`content` fallback once the real commit lands the - /// same touched lines at different real line numbers, while an unrelated - /// committed line does not intersect. #[test] fn intersect_patches_matches_synthetic_lines_via_historical_fallback() { let codex_patch = parse( @@ -738,9 +699,6 @@ mod tests { let constructed_patch = parse_patch(&normalized, Some("cx_test")).expect("constructed patch should parse"); - // A realistic post-commit patch where the same touched lines sit at - // different real line numbers than the event-scoped synthetic ones, - // plus one unrelated line that should not intersect. let post_commit_patch = real_commit_patch(); let overlap = intersect_patches(&constructed_patch, &post_commit_patch); diff --git a/cli/src/services/hooks/codex/apply_patch/parser.rs b/cli/src/services/hooks/codex/apply_patch/parser.rs index ba9b6cfb9..e66f01eba 100644 --- a/cli/src/services/hooks/codex/apply_patch/parser.rs +++ b/cli/src/services/hooks/codex/apply_patch/parser.rs @@ -1,38 +1,3 @@ -//! Grammar parser for Codex's `apply_patch` custom patch text. -//! -//! The grammar implemented here follows the official Lark grammar documented -//! in `openai/codex`'s `codex-rs/apply-patch/src/parser.rs` (checked against -//! that source directly for this task; see plan -//! `context/plans/codex-cli-integration.md` Assumptions): -//! -//! ```text -//! start: begin_patch environment_id? hunk+ end_patch -//! begin_patch: "*** Begin Patch" LF -//! environment_id: "*** Environment ID: " filename LF -//! end_patch: "*** End Patch" LF? -//! -//! hunk: add_hunk | delete_hunk | update_hunk -//! add_hunk: "*** Add File: " filename LF add_line+ -//! delete_hunk: "*** Delete File: " filename LF -//! update_hunk: "*** Update File: " filename LF change_move? change? -//! filename: /(.+)/ -//! add_line: "+" /(.+)/ LF -> line -//! -//! change_move: "*** Move to: " filename LF -//! change: (change_context | change_line)+ eof_line? -//! change_context: ("@@" | "@@ " /(.+)/) LF -//! change_line: ("+" | "-" | " ") /(.+)/ LF -//! eof_line: "*** End of File" LF -//! ``` -//! -//! Upstream Codex itself accepts absolute hunk paths and `..` traversal -//! segments, resolving them against the tool's own `cwd` later. This parser -//! preserves that model: it validates only the syntactic `apply_patch` grammar -//! and basic path representability (e.g. a non-empty path), and leaves the -//! decision of whether a parsed path is safe and stays inside the canonical -//! Git worktree to `resolve_codex_patch_paths` in this module's sibling -//! `path.rs`, which runs after parsing. - const BEGIN_PATCH_MARKER: &str = "*** Begin Patch"; const END_PATCH_MARKER: &str = "*** End Patch"; const ENVIRONMENT_ID_MARKER: &str = "*** Environment ID: "; @@ -44,15 +9,12 @@ const END_OF_FILE_MARKER: &str = "*** End of File"; const CHANGE_CONTEXT_MARKER: &str = "@@"; const CHANGE_CONTEXT_MARKER_WITH_TEXT: &str = "@@ "; -/// One fully parsed Codex `apply_patch` payload: an ordered list of the file -/// operations it declares. Order is preserved from the source text. #[allow(dead_code)] #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct CodexPatch { pub(crate) operations: Vec, } -/// A single `*** Add File:` / `*** Update File:` / `*** Delete File:` block. #[allow(dead_code)] #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) enum CodexFileOperation { @@ -70,9 +32,6 @@ pub(crate) enum CodexFileOperation { }, } -/// One contiguous change region within an `*** Update File:` block, started -/// either by an explicit `@@` context marker or implicitly by the first -/// change line when no `@@` marker precedes it. #[allow(dead_code)] #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct CodexHunk { @@ -81,8 +40,6 @@ pub(crate) struct CodexHunk { pub(crate) is_end_of_file: bool, } -/// A single line within a [`CodexHunk`], without its leading marker -/// character. #[allow(dead_code)] #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) enum CodexHunkLine { @@ -91,8 +48,6 @@ pub(crate) enum CodexHunkLine { Removed(String), } -/// Error produced when raw `apply_patch` text does not conform to the -/// grammar above, or contains an unrepresentable path (e.g. empty). #[allow(dead_code)] #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct CodexPatchParseError { @@ -113,19 +68,12 @@ fn error(message: impl Into) -> CodexPatchParseError { } } -/// Removes the optional shell-like heredoc boundary that current upstream -/// Codex may include around an `apply_patch` command. The canonical grammar -/// parser intentionally does not know about shell syntax; callers should pass -/// this result to [`parse_codex_apply_patch`]. #[allow(dead_code)] pub(crate) fn normalize_outer_apply_patch_input(raw: &str) -> Result { let trimmed = raw.trim(); let lines: Vec<&str> = trimmed.lines().collect(); if has_canonical_boundaries(&lines) { - // Preserve ordinary raw patch input byte-for-byte. The canonical - // parser performs the same boundary trimming it did before this - // outer-normalization seam was introduced. return Ok(raw.to_string()); } @@ -158,10 +106,6 @@ fn has_canonical_boundaries(lines: &[&str]) -> bool { && lines.last().map(|line| line.trim()) == Some(END_PATCH_MARKER) } -/// Parses canonical Codex `apply_patch` text into a [`CodexPatch`]. Performs -/// no outer shell normalization, normalization to SCE unified-diff form, or -/// filesystem access; callers handling hook `tool_input.command` should first -/// use [`normalize_outer_apply_patch_input`]. #[allow(dead_code)] pub(crate) fn parse_codex_apply_patch(raw: &str) -> Result { let trimmed = raw.trim(); @@ -271,9 +215,6 @@ fn is_top_level_marker(line: &str) -> bool { || line.starts_with(UPDATE_FILE_MARKER) } -/// Parses the `change_move? change?` tail of an `*** Update File:` block -/// (with any `*** Move to:` line already consumed by the caller), returning -/// the resulting hunks plus how many lines of `lines` were consumed. fn parse_update_hunks( path: &str, lines: &[&str], @@ -347,11 +288,6 @@ fn parse_update_hunks( Ok((hunks, consumed)) } -/// Validates only that a parsed path is representable at all (non-empty). -/// Absolute paths and `..` traversal segments are syntactically valid Codex -/// `apply_patch` paths and are passed through unchanged; whether a given path -/// is safe is decided later, against the event cwd and canonical Git -/// worktree, by `resolve_codex_patch_paths` in `path.rs`. fn validate_path(path: &str) -> Result { if path.is_empty() { return Err(error("Codex apply_patch path cannot be empty.")); @@ -755,11 +691,6 @@ mod tests { #[test] fn accepts_absolute_and_parent_traversal_path_syntax_unresolved() { - // The parser owns only the apply_patch grammar: absolute paths and - // `..` traversal segments are syntactically valid here and are - // passed through unresolved. Whether they are actually safe is - // `resolve_codex_patch_paths` (path.rs)'s decision, made later - // against the event cwd and canonical Git worktree. let patch = "*** Begin Patch\n\ *** Add File: /etc/passwd\n\ +x\n\ diff --git a/cli/src/services/hooks/codex/apply_patch/path.rs b/cli/src/services/hooks/codex/apply_patch/path.rs index 213d2a070..cbb30d5ee 100644 --- a/cli/src/services/hooks/codex/apply_patch/path.rs +++ b/cli/src/services/hooks/codex/apply_patch/path.rs @@ -1,11 +1,3 @@ -//! Resolves Codex `apply_patch` paths against the event cwd and the real Git -//! repository root. -//! -//! Codex invokes command hooks with the event's cwd, while the SCE dispatcher -//! may be launched from any directory in the checkout. This module keeps that -//! distinction explicit and only emits repository-relative, UTF-8 paths that -//! can be represented losslessly in SCE's patch format. - use std::path::{Component, Path, PathBuf}; use std::process::Command; @@ -13,10 +5,6 @@ use anyhow::{anyhow, bail, Context, Result}; use super::parser::{CodexFileOperation, CodexPatch}; -/// Resolves every path that can contribute Codex patch evidence in `patch`. -/// -/// The Git root and event cwd are validated once per event. Source and move -/// destination paths are then resolved independently from the event cwd. pub(crate) fn resolve_codex_patch_paths( repository_root: &Path, event_cwd: &str, @@ -44,12 +32,6 @@ pub(crate) fn resolve_codex_patch_paths( Ok(()) } -/// Resolves one Codex path to a repository-relative path. -/// -/// This public seam intentionally performs the same Git-root and cwd checks -/// as the event-level resolver, making the path contract independently -/// testable without invoking the hook dispatcher or opening the Agent Trace -/// database. #[allow(dead_code)] pub(crate) fn resolve_codex_patch_path( repository_root: &Path, @@ -162,9 +144,6 @@ fn resolve_path_from_cwd(git_root: &Path, event_cwd: &Path, codex_path: &str) -> ) } -/// Resolve a lexically normalized absolute path while preserving filesystem -/// semantics for existing components. Lexical normalization must happen before -/// this function so a symlink component removed by `..` is never inspected. fn resolve_candidate_inside_repository(git_root: &Path, candidate: &Path) -> Result { let (existing, suffix) = nearest_existing_prefix(candidate)?; let canonical_existing = canonicalize_inside_repository(git_root, &existing, candidate)?; @@ -179,9 +158,6 @@ fn resolve_candidate_inside_repository(git_root: &Path, candidate: &Path) -> Res Ok(resolved) } -/// Normalize an absolute path using Codex's lexical `PathUri::join` semantics: -/// `.` is removed, `..` removes the preceding lexical component, and parent -/// traversal at the filesystem root is clamped rather than treated as an error. fn normalize_absolute_path(path: &Path) -> Result { if !path.is_absolute() { bail!("Codex path must be absolute after joining with the event cwd."); @@ -274,8 +250,6 @@ fn append_path_lexically(base: &Path, suffix: &Path) -> Result { Ok(result) } -/// Convert a canonical or lexically resolved path into the slash-separated -/// UTF-8 form used by SCE patch text. fn path_to_utf8_slash_path(path: &Path) -> Result { let mut components = Vec::new(); for component in path.components() { @@ -495,10 +469,7 @@ mod tests { .expect("temporary repository should have a name") .to_str() .expect("temporary repository name should be UTF-8"); - // Overshoot past the filesystem root by a wide margin: `TMPDIR` depth - // varies by platform (e.g. macOS's `/var/folders/xx/yyyy/T/` nests - // deeper than Linux's `/tmp/`), so a fixed `..` count that clamps on - // one platform can undershoot the root on another. + let cwd_depth = cwd.components().count(); let excess_traversal = "../".repeat(cwd_depth + 8); let path = format!("{excess_traversal}{parent_path}/{root_name}/clamped.rs"); diff --git a/cli/src/services/hooks/codex/mod.rs b/cli/src/services/hooks/codex/mod.rs index 7947de198..c6f4744b0 100644 --- a/cli/src/services/hooks/codex/mod.rs +++ b/cli/src/services/hooks/codex/mod.rs @@ -20,17 +20,6 @@ const CODEX_HOOK_EVENT_POST_TOOL_USE: &str = "PostToolUse"; const CODEX_HOOK_TOOL_BASH: &str = "Bash"; const CODEX_HOOK_TOOL_APPLY_PATCH: &str = "apply_patch"; -/// Distinguishes a JSON field that is absent from the payload entirely -/// (`Missing`) from one that is present with an explicit `null` (`Null`) -/// from one that is present with a value (`Value`). A plain -/// `#[serde(default)] Option` cannot make this distinction: Serde's -/// `Option` deserializer maps JSON `null` to `None` at the *same* layer -/// it uses for "value absent", so both missing-field and explicit-null -/// collapse to `None`. `#[serde(default, deserialize_with = "...")]` on a -/// field of this type keeps `Default` (→ `Missing`) for the no-field case -/// and routes every present field (including `null`) through -/// [`deserialize_nullable_field`], which is the only path that can produce -/// `Null` or `Value`. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub(crate) enum NullableField { #[default] @@ -72,21 +61,6 @@ where }) } -/// A single Codex hook lifecycle event, deserialized from the raw STDIN JSON -/// payload `sce hooks codex` receives via -/// `.codex/hooks/run-sce-or-show-install-guidance.sh`. -/// -/// Working contract (see plan `context/plans/codex-cli-integration.md` -/// Assumptions): `hook_event_name` is present on every event; `session_id`, -/// `turn_id`, `cwd`, and `model` vary by event; `tool_name`/`tool_use_id`/ -/// `tool_input`/`tool_response` are present only on `PreToolUse`/`PostToolUse`; -/// `prompt` is present only on `UserPromptSubmit`, matching Claude's own -/// `UserPromptSubmit` payload shape (see `transform_claude_user_prompt_submit_with`); -/// `last_assistant_message` is present (per current upstream Codex `Stop` -/// schema, required and typed `string | null`) only on `Stop`, matching -/// Claude's own `Stop` payload shape (see `transform_claude_stop_with`) -/// except that Codex allows an explicit `null` where Claude does not — see -/// [`NullableField`]. #[derive(Debug, Deserialize)] #[allow(dead_code)] pub(crate) struct CodexHookEvent { @@ -113,8 +87,6 @@ pub(crate) struct CodexHookEvent { pub(crate) last_assistant_message: NullableField, } -/// The set of Codex hook-event/tool combinations `sce hooks codex` gives -/// distinct behavior. Every other combination classifies as `NoOp`. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum CodexDispatchArm { UserPromptSubmit, @@ -483,17 +455,6 @@ mod tests { fs::remove_dir_all(&state_root).ok(); } - // Explicit-empty-string and normal-text persistence *through raw JSON - // deserialization* are covered in `stop::tests` (e.g. - // `capture_with_persists_deserialized_raw_json_with_empty_string_last_assistant_message`), - // not here: `open_agent_trace_db_for_hook_runtime` (used by `stop::handle` - // for every persisting case) resolves the real default Agent Trace - // storage path and has no `state_root` injection seam — unlike - // `apply_patch`, which added one specifically for its own dispatcher - // tests. Missing/null above need no DB at all (they short-circuit before - // DB open), so they remain safe to exercise through the full - // `run_codex_subcommand_from_payload_at_state_root` dispatcher path. - #[test] fn codex_hook_event_deserializes_missing_last_assistant_message_as_missing() { let event: CodexHookEvent = @@ -807,14 +768,6 @@ mod tests { fs::remove_dir_all(&state_root).ok(); } - // --- T20/AC26 ownership boundary: the parser accepts absolute and `..` - // path syntax unresolved (see apply_patch/parser.rs), and - // `resolve_codex_patch_paths` (apply_patch/path.rs) is the sole - // authority deciding whether a parsed path is safe and stays inside the - // canonical Git worktree. These end-to-end tests exercise the real - // `PostToolUse apply_patch -> parse -> cwd-aware path resolution -> - // normalize -> diff_traces` pipeline, not `path.rs` in isolation. --- - fn diff_trace_count(repository_root: &Path, state_root: &Path) -> usize { let storage = resolve_agent_trace_storage_for_hook_runtime_at_state_root( &AgentTraceStorageContext { diff --git a/cli/src/services/hooks/codex/stop.rs b/cli/src/services/hooks/codex/stop.rs index c19a9856b..6d0fb5545 100644 --- a/cli/src/services/hooks/codex/stop.rs +++ b/cli/src/services/hooks/codex/stop.rs @@ -13,34 +13,10 @@ use super::super::{ }; use super::{CodexHookEvent, NullableField}; -/// Captures a Codex `Stop` event as one `messages` row (`role = "assistant"`) -/// and one `parts` row (`part_type = "text"`, `text = last_assistant_message`) -/// under session `cx_`, message `cx::assistant`. -/// -/// Upstream Codex's `Stop` schema requires `session_id`, `turn_id`, and -/// `last_assistant_message` (typed `string | null`) on every Stop payload. -/// This handler validates all three *before* any side effect — timestamp -/// acquisition, Agent Trace DB access, or persistence — via -/// [`validate_stop_event`]. A missing/blank `session_id` or `turn_id`, or a -/// missing `last_assistant_message`, is a malformed payload that errors so -/// the outer Codex dispatcher fail-open boundary (`run_codex_subcommand` → -/// `log_codex_fail_open`) logs it and emits exact empty stdout with no DB -/// access — this is true even for an otherwise-valid explicit `null`: a -/// null Stop with a blank/missing identifier is still malformed and must -/// not reach the null no-op path. Only once identifiers and presence are -/// confirmed valid does an explicit `null` short-circuit as a silent -/// successful no-op *before* timestamp acquisition or the Agent Trace DB is -/// ever opened; a present value (including an explicit empty string, -/// persisted like any other text) is captured normally. pub(super) fn handle(repository_root: &Path, event: &CodexHookEvent) -> Result { handle_with_clock(repository_root, event, current_unix_time_ms) } -/// Injectable-clock counterpart of `handle`. Timestamp acquisition is -/// fallible and its failure is propagated as `Err` rather than swallowed -/// internally, so the existing outer Codex fail-open boundary owns logging -/// and the empty-stdout contract for a failed clock exactly as it does for -/// any other handler error. fn handle_with_clock(repository_root: &Path, event: &CodexHookEvent, now: F) -> Result where F: FnOnce() -> Result, @@ -66,12 +42,6 @@ where ) } -/// A Codex `Stop` event whose `session_id`/`turn_id` are confirmed -/// non-blank and trimmed, and whose `last_assistant_message` presence has -/// already been confirmed (a missing field cannot produce a `ValidatedStop` -/// at all). `None` here means an explicit upstream `null` — the valid -/// "no assistant text this turn" no-op signal; `Some` carries a present -/// value (including an explicit empty string). #[derive(Debug)] struct ValidatedStop<'a> { session_id: &'a str, @@ -79,10 +49,6 @@ struct ValidatedStop<'a> { last_assistant_message: Option<&'a str>, } -/// The single validation layer for `Stop` events: every required-field -/// check (`session_id`, `turn_id`, `last_assistant_message` presence) lives -/// here so no other function re-validates the same fields with subtly -/// different semantics. Runs before any timestamp acquisition or DB access. fn validate_stop_event(event: &CodexHookEvent) -> Result> { let session_id = required_trimmed_field(event.session_id.as_deref(), "session_id")?; let turn_id = required_trimmed_field(event.turn_id.as_deref(), "turn_id")?; @@ -103,9 +69,6 @@ fn validate_stop_event(event: &CodexHookEvent) -> Result> { }) } -/// Persists an already-validated `Stop` event with a known-present -/// assistant message against an already-open Agent Trace DB. Performs no -/// validation of its own. fn persist_with( db: &RepositoryAgentTraceDb, validated: &ValidatedStop<'_>, @@ -136,9 +99,6 @@ fn persist_with( Ok(String::new()) } -/// Validates an identifier field (`session_id`/`turn_id`) is present and -/// non-blank, returning it trimmed so downstream prefixing/formatting never -/// persists incidental leading/trailing whitespace. fn required_trimmed_field<'a>(value: Option<&'a str>, field_name: &str) -> Result<&'a str> { match value.map(str::trim) { Some(value) if !value.is_empty() => Ok(value), @@ -148,12 +108,6 @@ fn required_trimmed_field<'a>(value: Option<&'a str>, field_name: &str) -> Resul } } -/// Test-only convenience wrapper preserving the pre-refactor `capture_with` -/// call shape (`event` + timestamp, against an already-open DB) for tests -/// that build a full `CodexHookEvent`. Routes through the same single -/// validation layer (`validate_stop_event`) as production `handle`, so it -/// exercises identical semantics — including the null no-op — rather than -/// re-implementing validation. #[cfg(test)] fn capture_with( db: &RepositoryAgentTraceDb, @@ -465,9 +419,6 @@ mod tests { let mut payload = event("session-1", "turn-1", "unused"); payload.last_assistant_message = NullableField::Null; - // A nonexistent repository root proves `handle` never reaches Agent - // Trace DB resolution for a null `last_assistant_message`: DB opening - // against a nonexistent repository would otherwise fail loudly. let output = handle(Path::new("/nonexistent-repository-root"), &payload) .expect("null last_assistant_message should be a silent successful no-op"); assert_eq!(output, ""); @@ -490,10 +441,6 @@ mod tests { let mut payload = event("session-1", "turn-1", "unused"); payload.last_assistant_message = NullableField::Null; - // A failing/panicking clock closure and a nonexistent repository - // root together prove `handle_with_clock` short-circuits before - // timestamp acquisition and before Agent Trace DB resolution for an - // explicit null. let output = handle_with_clock(Path::new("/nonexistent-repository-root"), &payload, || { panic!("clock must not be called for an explicit null last_assistant_message") }) @@ -505,10 +452,6 @@ mod tests { fn handle_with_clock_propagates_a_timestamp_failure_as_an_error_with_no_persistence() { let payload = event("session-1", "turn-1", "hello back"); - // A nonexistent repository root additionally proves the failed - // clock is consulted (and propagated) before Agent Trace DB - // resolution is ever attempted: a subsequent DB-open attempt - // against this path would fail loudly instead. let error = handle_with_clock(Path::new("/nonexistent-repository-root"), &payload, || { Err(anyhow::anyhow!("clock failed")) }) @@ -607,8 +550,6 @@ mod tests { payload.turn_id = Some(" turn-1 ".to_string()); payload.last_assistant_message = NullableField::Null; - // Padded-but-otherwise-valid identifiers must validate under their - // trimmed representation even though a null Stop persists nothing. let output = handle_with_clock(Path::new("/nonexistent-repository-root"), &payload, || { panic!("clock must not be called for an explicit null last_assistant_message") }) diff --git a/cli/src/services/hooks/codex/user_prompt_submit.rs b/cli/src/services/hooks/codex/user_prompt_submit.rs index fc044cd42..7d99d4add 100644 --- a/cli/src/services/hooks/codex/user_prompt_submit.rs +++ b/cli/src/services/hooks/codex/user_prompt_submit.rs @@ -13,22 +13,10 @@ use super::super::{ }; use super::CodexHookEvent; -/// Captures a Codex `UserPromptSubmit` event as one `messages` row -/// (`role = "user"`) and one `parts` row (`part_type = "text"`, `text = prompt`) -/// under session `cx_`, message `cx::user`. pub(super) fn handle(repository_root: &Path, event: &CodexHookEvent) -> Result { handle_with_clock(repository_root, event, current_unix_time_ms) } -/// Injectable-clock counterpart of `handle`. Validates `session_id`, -/// `turn_id`, and `prompt` (via [`validate_user_prompt_submit_event`]) -/// *before* any side effect — a malformed payload never reaches timestamp -/// acquisition or Agent Trace DB access. Timestamp acquisition is itself -/// fallible and its failure is propagated as `Err` rather than swallowed -/// internally, so the existing outer Codex fail-open boundary -/// (`run_codex_subcommand` → `log_codex_fail_open`) owns logging and the -/// empty-stdout contract for both a malformed payload and a failed clock, -/// exactly as it does for any other handler error. fn handle_with_clock(repository_root: &Path, event: &CodexHookEvent, now: F) -> Result where F: FnOnce() -> Result, @@ -45,20 +33,12 @@ where persist_with(&db, &validated, generated_at_unix_ms) } -/// A Codex `UserPromptSubmit` event whose `session_id`/`turn_id` are -/// confirmed non-blank and trimmed, and whose `prompt` is confirmed -/// present and non-blank (but left untrimmed — prompt text is not -/// whitespace-normalized). struct ValidatedUserPromptSubmit<'a> { session_id: &'a str, turn_id: &'a str, prompt: &'a str, } -/// The single validation layer for `UserPromptSubmit` events: every -/// required-field check (`session_id`, `turn_id`, `prompt`) lives here so -/// no other function re-validates the same fields with subtly different -/// semantics. Runs before any timestamp acquisition or DB access. fn validate_user_prompt_submit_event( event: &CodexHookEvent, ) -> Result> { @@ -73,8 +53,6 @@ fn validate_user_prompt_submit_event( }) } -/// Persists an already-validated `UserPromptSubmit` event against an -/// already-open Agent Trace DB. Performs no validation of its own. fn persist_with( db: &RepositoryAgentTraceDb, validated: &ValidatedUserPromptSubmit<'_>, @@ -113,9 +91,6 @@ fn required_field<'a>(value: Option<&'a str>, field_name: &str) -> Result<&'a st } } -/// Validates an identifier field (`session_id`/`turn_id`) is present and -/// non-blank, returning it trimmed so downstream prefixing/formatting never -/// persists incidental leading/trailing whitespace. fn required_trimmed_field<'a>(value: Option<&'a str>, field_name: &str) -> Result<&'a str> { match value.map(str::trim) { Some(value) if !value.is_empty() => Ok(value), @@ -125,11 +100,6 @@ fn required_trimmed_field<'a>(value: Option<&'a str>, field_name: &str) -> Resul } } -/// Test-only convenience wrapper preserving the pre-refactor `capture_with` -/// call shape (`event` + timestamp, against an already-open DB) for tests -/// that build a full `CodexHookEvent`. Routes through the same single -/// validation layer (`validate_user_prompt_submit_event`) as production -/// `handle`. #[cfg(test)] fn capture_with( db: &RepositoryAgentTraceDb, @@ -343,10 +313,6 @@ mod tests { fn handle_with_clock_propagates_a_timestamp_failure_as_an_error_with_no_persistence() { let payload = event("session-1", "turn-1", "hello world"); - // A nonexistent repository root additionally proves the failed - // clock is consulted (and propagated) before Agent Trace DB - // resolution is ever attempted: a subsequent DB-open attempt - // against this path would fail loudly instead. let error = handle_with_clock(Path::new("/nonexistent-repository-root"), &payload, || { Err(anyhow::anyhow!("clock failed")) }) diff --git a/cli/src/services/hooks/codex_mutation_scope/events.rs b/cli/src/services/hooks/codex_mutation_scope/events.rs new file mode 100644 index 000000000..c83864032 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/events.rs @@ -0,0 +1,277 @@ +use anyhow::{anyhow, bail, Context, Result}; +use serde_json::{Map, Value}; + +pub(super) const HOOK_EVENT_NAME_FIELD: &str = "hook_event_name"; +pub(super) const SESSION_ID_FIELD: &str = "session_id"; +pub(super) const TURN_ID_FIELD: &str = "turn_id"; +pub(super) const CWD_FIELD: &str = "cwd"; +pub(super) const AGENT_ID_FIELD: &str = "agent_id"; +pub(super) const AGENT_TYPE_FIELD: &str = "agent_type"; +pub(super) const MODEL_FIELD: &str = "model"; +pub(super) const PROVENANCE_FIELD: &str = "provenance"; +pub(super) const TOOL_NAME_FIELD: &str = "tool_name"; +pub(super) const TOOL_USE_ID_FIELD: &str = "tool_use_id"; +pub(super) const TOOL_INPUT_FIELD: &str = "tool_input"; + +pub(super) const CODEX_TRACKED_TOOL_BASH: &str = "Bash"; + +pub(super) const HOOK_EVENT_PRE_TOOL_USE: &str = "PreToolUse"; +pub(super) const HOOK_EVENT_POST_TOOL_USE: &str = "PostToolUse"; +pub(super) const HOOK_EVENT_STOP: &str = "Stop"; +pub(super) const HOOK_EVENT_INTERRUPT: &str = "Interrupt"; +pub(super) const HOOK_EVENT_SUBAGENT_STOP: &str = "SubagentStop"; +pub(super) const HOOK_EVENT_SESSION_END: &str = "SessionEnd"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum CodexHookEvent { + PreToolUse(CodexToolExecution), + PostToolUse(CodexToolIdentity), + Stop(CodexTurnIdentity), + Interrupt(CodexTurnIdentity), + SubagentStop(CodexAgentIdentity), + SessionEnd(CodexSessionIdentity), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CodexToolIdentity { + pub session_id: String, + pub turn_id: String, + pub cwd: String, + pub agent_id: Option, + pub tool_name: String, + pub tool_use_id: String, +} + +impl CodexToolIdentity { + pub(crate) fn attempt_key(&self) -> AttemptKey { + AttemptKey { + session_id: self.session_id.clone(), + agent_id: self.agent_id.clone(), + tool_use_id: self.tool_use_id.clone(), + } + } + + pub(crate) fn is_subagent(&self) -> bool { + self.agent_id.is_some() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CodexToolExecution { + pub identity: CodexToolIdentity, + pub agent_type: Option, + pub model: Option, + pub tool_input: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CodexTurnIdentity { + pub session_id: String, + pub turn_id: String, + pub cwd: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CodexAgentIdentity { + pub session_id: String, + pub turn_id: String, + pub cwd: String, + pub agent_id: String, + pub agent_type: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CodexSessionIdentity { + pub session_id: String, + pub cwd: String, +} + +#[allow(clippy::struct_field_names)] +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub(crate) struct AttemptKey { + pub session_id: String, + pub agent_id: Option, + pub tool_use_id: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ToolClassification { + TrackedMutation, + Delegation, + Untracked, +} + +pub(super) const TRACKED_MUTATION_TOOL_NAMES: &[&str] = &["Bash", "apply_patch"]; +pub(super) const DELEGATION_TOOL_NAMES: &[&str] = + &["collaborationspawn_agent", "collaborationwait_agent"]; +pub(super) const MCP_TOOL_NAME_PREFIX: &str = "mcp__"; + +pub(crate) fn is_mcp_tool_name(tool_name: &str) -> bool { + tool_name.starts_with(MCP_TOOL_NAME_PREFIX) +} + +pub(crate) fn classify_tool(tool_name: &str) -> ToolClassification { + if TRACKED_MUTATION_TOOL_NAMES.contains(&tool_name) { + ToolClassification::TrackedMutation + } else if DELEGATION_TOOL_NAMES.contains(&tool_name) { + ToolClassification::Delegation + } else { + ToolClassification::Untracked + } +} + +pub(super) const CODEX_SCOPE_ID_SCHEME: &str = "cx-tool-v1"; + +pub(crate) fn format_codex_scope_id(attempt_seq: u64, key: &AttemptKey) -> String { + let agent_id = key.agent_id.as_deref().unwrap_or(""); + format!( + "{CODEX_SCOPE_ID_SCHEME}|n={attempt_seq}|s={}:{}|a={}:{}|t={}:{}", + key.session_id.len(), + key.session_id, + agent_id.len(), + agent_id, + key.tool_use_id.len(), + key.tool_use_id, + ) +} + +pub(crate) fn codex_scope_start_event_id(scope_id: &str) -> String { + format!("{scope_id}|start") +} + +pub(crate) fn codex_scope_close_event_id(scope_id: &str) -> String { + format!("{scope_id}|close") +} + +pub(crate) fn parse_codex_hook_event(stdin_payload: &str) -> Result { + if stdin_payload.trim().is_empty() { + bail!(validation_error( + "expected a JSON object, got an empty payload" + )); + } + + let parsed: Value = serde_json::from_str(stdin_payload) + .with_context(|| validation_error("expected valid JSON"))?; + let object = parsed + .as_object() + .ok_or_else(|| anyhow!(validation_error("expected a JSON object")))?; + + let hook_event_name = required_non_blank_str(object, HOOK_EVENT_NAME_FIELD)?; + + match hook_event_name.as_str() { + HOOK_EVENT_PRE_TOOL_USE => parse_pre_tool_use(object).map(CodexHookEvent::PreToolUse), + HOOK_EVENT_POST_TOOL_USE => parse_tool_identity(object).map(CodexHookEvent::PostToolUse), + HOOK_EVENT_STOP => parse_turn_identity(object).map(CodexHookEvent::Stop), + HOOK_EVENT_INTERRUPT => parse_turn_identity(object).map(CodexHookEvent::Interrupt), + HOOK_EVENT_SUBAGENT_STOP => parse_agent_identity(object).map(CodexHookEvent::SubagentStop), + HOOK_EVENT_SESSION_END => parse_session_identity(object).map(CodexHookEvent::SessionEnd), + other => bail!(validation_error(&format!( + "unsupported hook_event_name '{other}'" + ))), + } +} + +pub(super) fn parse_tool_identity(object: &Map) -> Result { + Ok(CodexToolIdentity { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + turn_id: required_non_blank_str(object, TURN_ID_FIELD)?, + cwd: required_non_blank_str(object, CWD_FIELD)?, + agent_id: optional_non_blank_str(object, AGENT_ID_FIELD)?, + tool_name: required_non_blank_str(object, TOOL_NAME_FIELD)?, + tool_use_id: required_non_blank_str(object, TOOL_USE_ID_FIELD)?, + }) +} + +pub(super) fn parse_pre_tool_use(object: &Map) -> Result { + Ok(CodexToolExecution { + identity: parse_tool_identity(object)?, + agent_type: optional_non_blank_str(object, AGENT_TYPE_FIELD)?, + model: tolerated_model(object), + tool_input: object.get(TOOL_INPUT_FIELD).cloned(), + }) +} + +pub(super) fn parse_turn_identity(object: &Map) -> Result { + Ok(CodexTurnIdentity { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + turn_id: required_non_blank_str(object, TURN_ID_FIELD)?, + cwd: required_non_blank_str(object, CWD_FIELD)?, + }) +} + +pub(super) fn parse_agent_identity(object: &Map) -> Result { + Ok(CodexAgentIdentity { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + turn_id: required_non_blank_str(object, TURN_ID_FIELD)?, + cwd: required_non_blank_str(object, CWD_FIELD)?, + agent_id: required_non_blank_str(object, AGENT_ID_FIELD)?, + agent_type: optional_non_blank_str(object, AGENT_TYPE_FIELD)?, + }) +} + +pub(super) fn parse_session_identity(object: &Map) -> Result { + Ok(CodexSessionIdentity { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + cwd: required_non_blank_str(object, CWD_FIELD)?, + }) +} + +pub(super) fn required_field<'a>(object: &'a Map, field: &str) -> Result<&'a Value> { + object.get(field).ok_or_else(|| { + anyhow!(validation_error(&format!( + "missing required field '{field}'" + ))) + }) +} + +pub(super) fn required_str(object: &Map, field: &str) -> Result { + required_field(object, field)? + .as_str() + .map(str::to_owned) + .ok_or_else(|| { + anyhow!(validation_error(&format!( + "field '{field}' must be a string" + ))) + }) +} + +pub(super) fn required_non_blank_str(object: &Map, field: &str) -> Result { + let value = required_str(object, field)?; + if value.trim().is_empty() { + bail!(validation_error(&format!( + "field '{field}' must be a non-blank string" + ))); + } + Ok(value) +} + +pub(super) fn optional_non_blank_str( + object: &Map, + field: &str, +) -> Result> { + match object.get(field) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) => { + if value.trim().is_empty() { + bail!(validation_error(&format!( + "field '{field}' must be null, absent, or a non-blank string" + ))); + } + Ok(Some(value.clone())) + } + Some(_) => bail!(validation_error(&format!( + "field '{field}' must be null, absent, or a non-blank string" + ))), + } +} + +pub(super) fn tolerated_model(object: &Map) -> Option { + object + .get(MODEL_FIELD) + .and_then(Value::as_str) + .map(str::to_owned) +} + +pub(super) fn validation_error(detail: &str) -> String { + format!("Invalid Codex hook event payload from STDIN: {detail}.") +} diff --git a/cli/src/services/hooks/codex_mutation_scope/lifecycle.rs b/cli/src/services/hooks/codex_mutation_scope/lifecycle.rs new file mode 100644 index 000000000..5eb417bd1 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/lifecycle.rs @@ -0,0 +1,542 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Result}; + +use crate::services::hooks; +use crate::services::hooks::codex::bash_policy::{ + bash_command_from_tool_input, evaluate_codex_bash_policy, CodexBashPolicyDecision, +}; +use crate::services::hooks::{ + normalize_codex_model_id, prefixed_diff_trace_session_id, CODEX_TOOL_NAME, +}; +use crate::services::mutation_trace::runtime::resolve_git_dir; +use crate::services::observability::traits::Logger; + +use super::boundary_lock::{AdapterBoundaryLock, DEFAULT_BOUNDARY_LOCK_TIMEOUT}; +use super::events::CODEX_TRACKED_TOOL_BASH; +use super::state; +use super::{ + abandon_payload, classify_tool, codex_scope_close_event_id, codex_scope_start_event_id, + flush_payload, parse_codex_hook_event, pre_tool_use_deny_json, scope_boundary_payload, + scope_start_payload, AttemptKey, CodexHookEvent, CodexToolExecution, ToolClassification, +}; + +pub(super) type GitDirResolver<'a> = &'a dyn Fn(&str) -> Result; + +pub(super) type IngressSeam<'a> = &'a dyn Fn(&Path, &str, Option<&dyn Logger>) -> Result; + +pub(super) type BashPolicyEvaluator<'a> = + &'a dyn Fn(&Path, &str) -> Result; + +pub(super) const ACTOR_KIND_CODEX: &str = "codex"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CodexScopeProvenance { + pub session_id: String, + pub model_id: Option, +} + +pub(super) fn codex_scope_provenance(execution: &CodexToolExecution) -> CodexScopeProvenance { + CodexScopeProvenance { + session_id: prefixed_diff_trace_session_id(CODEX_TOOL_NAME, &execution.identity.session_id), + model_id: execution + .model + .as_deref() + .and_then(normalize_codex_model_id), + } +} + +pub(super) const FAIL_CLOSED_DENY_REASON: &str = + "SCE could not establish mutation attribution for this tool execution."; + +pub(super) const PRE_TOOL_USE_FAIL_CLOSED_EVENT: &str = + "sce.hooks.codex_mutation_scope.pre_tool_use_fail_closed"; + +pub(super) fn log_pre_tool_use_fail_closed( + logger: Option<&dyn Logger>, + context: &str, + error: &anyhow::Error, +) { + if let Some(log) = logger { + log.warn( + PRE_TOOL_USE_FAIL_CLOSED_EVENT, + &error.to_string(), + &[("context", context)], + None, + ); + } +} + +pub(crate) fn run_codex_mutation_scope_subcommand(logger: Option<&dyn Logger>) -> Result { + let stdin_payload = hooks::read_hook_stdin()?; + run_codex_mutation_scope_from_payload(&stdin_payload, logger) +} + +pub(crate) fn run_codex_mutation_scope_from_payload( + stdin_payload: &str, + logger: Option<&dyn Logger>, +) -> Result { + let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); + let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { + hooks::mutation_scope::run_mutation_scope_from_payload(repository_root, payload, logger) + }; + let bash_policy_fn = |repository_root: &Path, command: &str| { + evaluate_codex_bash_policy(repository_root, command) + }; + + run_codex_mutation_scope_from_payload_with_seams( + stdin_payload, + logger, + &resolve_git_dir_fn, + &seam_fn, + &bash_policy_fn, + ) +} + +#[cfg(test)] +pub(crate) fn run_codex_mutation_scope_from_payload_at_state_root( + state_root: &Path, + stdin_payload: &str, + logger: Option<&dyn Logger>, +) -> Result { + let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); + let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { + hooks::mutation_scope::run_mutation_scope_from_payload_at_state_root( + repository_root, + state_root, + payload, + logger, + ) + }; + let bash_policy_fn = |repository_root: &Path, command: &str| { + evaluate_codex_bash_policy(repository_root, command) + }; + + run_codex_mutation_scope_from_payload_with_seams( + stdin_payload, + logger, + &resolve_git_dir_fn, + &seam_fn, + &bash_policy_fn, + ) +} + +#[cfg(test)] +pub(super) fn run_codex_mutation_scope_from_payload_with( + stdin_payload: &str, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, +) -> Result { + let allow_all = |_repository_root: &Path, _command: &str| Ok(CodexBashPolicyDecision::Allowed); + run_codex_mutation_scope_from_payload_with_seams( + stdin_payload, + logger, + resolve_git_dir, + seam, + &allow_all, + ) +} + +#[cfg(test)] +pub(super) fn run_codex_mutation_scope_from_payload_with_bash_policy( + stdin_payload: &str, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, + evaluate_bash_policy: BashPolicyEvaluator, +) -> Result { + run_codex_mutation_scope_from_payload_with_seams( + stdin_payload, + logger, + resolve_git_dir, + seam, + evaluate_bash_policy, + ) +} + +pub(super) fn run_codex_mutation_scope_from_payload_with_seams( + stdin_payload: &str, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, + evaluate_bash_policy: BashPolicyEvaluator, +) -> Result { + let event = parse_codex_hook_event(stdin_payload)?; + dispatch_codex_hook_event(event, logger, resolve_git_dir, seam, evaluate_bash_policy) +} + +pub(super) fn dispatch_codex_hook_event( + event: CodexHookEvent, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, + evaluate_bash_policy: BashPolicyEvaluator, +) -> Result { + match event { + CodexHookEvent::PreToolUse(execution) => Ok(handle_pre_tool_use( + &execution, + logger, + resolve_git_dir, + seam, + evaluate_bash_policy, + )), + CodexHookEvent::PostToolUse(identity) => { + if !matches!( + classify_tool(&identity.tool_name), + ToolClassification::TrackedMutation + ) { + return Ok(String::new()); + } + + let git_dir = resolve_git_dir(&identity.cwd)?; + let repository_root = Path::new(&identity.cwd); + with_boundary_lock(&git_dir, || { + handle_close( + &git_dir, + repository_root, + &identity.attempt_key(), + logger, + seam, + ) + }) + } + CodexHookEvent::Stop(turn) => { + let git_dir = resolve_git_dir(&turn.cwd)?; + let repository_root = Path::new(&turn.cwd); + let session_id = turn.session_id.clone(); + with_boundary_lock(&git_dir, || { + cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { + attempt.session_id == session_id && attempt.agent_id.is_none() + }) + }) + } + CodexHookEvent::Interrupt(turn) => { + let git_dir = resolve_git_dir(&turn.cwd)?; + let repository_root = Path::new(&turn.cwd); + let session_id = turn.session_id.clone(); + with_boundary_lock(&git_dir, || { + cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { + attempt.session_id == session_id + }) + }) + } + CodexHookEvent::SubagentStop(agent) => { + let git_dir = resolve_git_dir(&agent.cwd)?; + let repository_root = Path::new(&agent.cwd); + let session_id = agent.session_id.clone(); + let agent_id = agent.agent_id.clone(); + with_boundary_lock(&git_dir, || { + cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { + attempt.session_id == session_id + && attempt.agent_id.as_deref() == Some(&agent_id) + }) + }) + } + CodexHookEvent::SessionEnd(session) => { + let git_dir = resolve_git_dir(&session.cwd)?; + let repository_root = Path::new(&session.cwd); + let session_id = session.session_id.clone(); + with_boundary_lock(&git_dir, || { + cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { + attempt.session_id == session_id + }) + }) + } + } +} + +pub(super) fn with_boundary_lock( + git_dir: &Path, + operation: impl FnOnce() -> Result, +) -> Result { + let _boundary = AdapterBoundaryLock::acquire(git_dir, DEFAULT_BOUNDARY_LOCK_TIMEOUT) + .map_err(|error| anyhow!("Failed to acquire adapter boundary lock: {error}"))?; + operation() +} + +pub(super) fn handle_pre_tool_use( + execution: &CodexToolExecution, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, + evaluate_bash_policy: BashPolicyEvaluator, +) -> String { + let identity = &execution.identity; + + if !matches!( + classify_tool(&identity.tool_name), + ToolClassification::TrackedMutation + ) { + return String::new(); + } + + let repository_root = Path::new(&identity.cwd); + + if identity.tool_name == CODEX_TRACKED_TOOL_BASH { + match codex_bash_policy_preflight(repository_root, execution, evaluate_bash_policy) { + BashPolicyPreflight::Allowed => {} + BashPolicyPreflight::Blocked(response) => return response, + BashPolicyPreflight::EvaluationFailed(error) => { + log_pre_tool_use_fail_closed(logger, "bash_policy_preflight", &error); + return pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON); + } + } + } + + let git_dir = match resolve_git_dir(&identity.cwd) { + Ok(git_dir) => git_dir, + Err(error) => { + log_pre_tool_use_fail_closed(logger, "resolve_git_dir", &error); + return pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON); + } + }; + + let key = identity.attempt_key(); + let turn_id = identity.turn_id.as_str(); + let provenance = codex_scope_provenance(execution); + let outcome = with_boundary_lock(&git_dir, || { + state::normalize_recovery_after_boundary_lock_acquired(&git_dir)?; + + sweep_stale_lane_predecessors(&git_dir, repository_root, &key, turn_id, logger, seam)?; + + match admit_or_recover( + &git_dir, + repository_root, + &key, + turn_id, + &identity.tool_name, + logger, + seam, + )? { + Admission::Admitted(allocated) => { + establish_start( + &git_dir, + repository_root, + &allocated, + &provenance, + logger, + seam, + )?; + Ok(PreToolUseOutcome::Continue) + } + Admission::Denied => Ok(PreToolUseOutcome::Deny), + } + }); + + match outcome { + Ok(PreToolUseOutcome::Continue) => String::new(), + Ok(PreToolUseOutcome::Deny) => pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), + Err(error) => { + log_pre_tool_use_fail_closed(logger, "codex_mutation_scope_pre_tool_use", &error); + pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON) + } + } +} + +pub(super) enum PreToolUseOutcome { + Continue, + Deny, +} + +pub(super) enum BashPolicyPreflight { + Allowed, + Blocked(String), + EvaluationFailed(anyhow::Error), +} + +pub(super) fn codex_bash_policy_preflight( + repository_root: &Path, + execution: &CodexToolExecution, + evaluate_bash_policy: BashPolicyEvaluator, +) -> BashPolicyPreflight { + let command = match bash_command_from_tool_input(execution.tool_input.as_ref()) { + Ok(command) => command, + Err(error) => return BashPolicyPreflight::EvaluationFailed(error), + }; + + match evaluate_bash_policy(repository_root, command) { + Ok(CodexBashPolicyDecision::Allowed) => BashPolicyPreflight::Allowed, + Ok(CodexBashPolicyDecision::Blocked(response)) => BashPolicyPreflight::Blocked(response), + Err(error) => BashPolicyPreflight::EvaluationFailed(error), + } +} + +pub(super) enum Admission { + Admitted(state::AllocatedAttempt), + Denied, +} + +pub(super) fn sweep_stale_lane_predecessors( + git_dir: &Path, + repository_root: &Path, + key: &AttemptKey, + turn_id: &str, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result<()> { + loop { + let current = state::read_state(git_dir)?; + let Some(stale) = current + .attempts + .iter() + .find(|attempt| { + attempt.in_builtin_lane(&key.session_id, turn_id) + && !attempt_matches_key(attempt, key) + }) + .cloned() + else { + return Ok(()); + }; + abandon_attempt(git_dir, repository_root, &stale, logger, seam)?; + } +} + +pub(super) fn admit_or_recover( + git_dir: &Path, + repository_root: &Path, + key: &AttemptKey, + turn_id: &str, + tool_name: &str, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result { + match state::admit_tracked_attempt(git_dir, key, turn_id, tool_name)? { + state::AdmitDecision::Admitted(allocated) => Ok(Admission::Admitted(allocated)), + state::AdmitDecision::RecoveryBlocked + | state::AdmitDecision::UncertainAttemptBlocked + | state::AdmitDecision::StalePredecessorBlocked => Ok(Admission::Denied), + state::AdmitDecision::FlushClaimed { generation } => { + match seam(repository_root, &flush_payload(), logger) { + Ok(_) => match state::complete_recovery_flush(git_dir, generation)? { + state::RecoveryFlushCompletion::Cleared => { + readmit_after_flush(git_dir, key, turn_id, tool_name) + } + state::RecoveryFlushCompletion::Superseded => Ok(Admission::Denied), + }, + Err(error) => { + log_pre_tool_use_fail_closed(logger, "recovery_flush", &error); + state::relinquish_recovery_flush(git_dir, generation)?; + Ok(Admission::Denied) + } + } + } + } +} + +pub(super) fn readmit_after_flush( + git_dir: &Path, + key: &AttemptKey, + turn_id: &str, + tool_name: &str, +) -> Result { + match state::admit_tracked_attempt(git_dir, key, turn_id, tool_name)? { + state::AdmitDecision::Admitted(allocated) => Ok(Admission::Admitted(allocated)), + state::AdmitDecision::FlushClaimed { generation } => { + state::relinquish_recovery_flush(git_dir, generation)?; + Ok(Admission::Denied) + } + state::AdmitDecision::RecoveryBlocked + | state::AdmitDecision::UncertainAttemptBlocked + | state::AdmitDecision::StalePredecessorBlocked => Ok(Admission::Denied), + } +} + +pub(super) fn establish_start( + git_dir: &Path, + repository_root: &Path, + allocated: &state::AllocatedAttempt, + provenance: &CodexScopeProvenance, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result<()> { + let scope_id = &allocated.attempt.scope_id; + + if allocated.reused && allocated.attempt.phase == state::AttemptPhase::Active { + return Ok(()); + } + + let start_payload = + scope_start_payload(scope_id, &codex_scope_start_event_id(scope_id), provenance); + + seam(repository_root, &start_payload, logger)?; + state::mark_active(git_dir, scope_id)?; + Ok(()) +} + +pub(super) fn handle_close( + git_dir: &Path, + repository_root: &Path, + key: &AttemptKey, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result { + let current = state::read_state(git_dir)?; + let Some(attempt) = current + .attempts + .iter() + .find(|attempt| attempt_matches_key(attempt, key)) + .cloned() + else { + return Ok(String::new()); + }; + + if attempt.phase == state::AttemptPhase::PendingStart { + abandon_attempt(git_dir, repository_root, &attempt, logger, seam)?; + return Ok(String::new()); + } + + let close_payload = scope_boundary_payload( + "close", + &attempt.scope_id, + &codex_scope_close_event_id(&attempt.scope_id), + ); + + if seam(repository_root, &close_payload, logger).is_ok() { + state::remove_attempt(git_dir, &attempt.scope_id)?; + } else { + abandon_attempt(git_dir, repository_root, &attempt, logger, seam)?; + } + Ok(String::new()) +} + +pub(super) fn cleanup_attempts_matching( + git_dir: &Path, + repository_root: &Path, + logger: Option<&dyn Logger>, + seam: IngressSeam, + predicate: impl Fn(&state::AdapterAttempt) -> bool, +) -> Result { + let current = state::read_state(git_dir)?; + let stale: Vec = current + .attempts + .into_iter() + .filter(|attempt| predicate(attempt)) + .collect(); + + for attempt in &stale { + abandon_attempt(git_dir, repository_root, attempt, logger, seam)?; + } + + Ok(String::new()) +} + +pub(super) fn attempt_matches_key(attempt: &state::AdapterAttempt, key: &AttemptKey) -> bool { + attempt.session_id == key.session_id + && attempt.agent_id == key.agent_id + && attempt.tool_use_id == key.tool_use_id +} + +pub(super) fn abandon_attempt( + git_dir: &Path, + repository_root: &Path, + attempt: &state::AdapterAttempt, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result<()> { + state::arm_recovery(git_dir)?; + + seam(repository_root, &abandon_payload(&attempt.scope_id), logger)?; + state::remove_attempt(git_dir, &attempt.scope_id)?; + Ok(()) +} diff --git a/cli/src/services/hooks/codex_mutation_scope/mod.rs b/cli/src/services/hooks/codex_mutation_scope/mod.rs index beaba255f..fc904317c 100644 --- a/cli/src/services/hooks/codex_mutation_scope/mod.rs +++ b/cli/src/services/hooks/codex_mutation_scope/mod.rs @@ -1,5656 +1,66 @@ #![allow(dead_code)] mod boundary_lock; +mod events; pub(crate) mod health; +mod lifecycle; mod os_lock; +mod payload; pub(crate) mod state; -use std::path::{Path, PathBuf}; - -use anyhow::{anyhow, bail, Context, Result}; -use serde_json::{json, Map, Value}; - +#[allow(unused_imports)] use crate::services::hooks::codex::bash_policy::{ - bash_command_from_tool_input, evaluate_codex_bash_policy, CodexBashPolicyDecision, -}; -use crate::services::hooks::{ - normalize_codex_model_id, prefixed_diff_trace_session_id, CODEX_TOOL_NAME, + evaluate_codex_bash_policy, CodexBashPolicyDecision, }; +#[allow(unused_imports)] use crate::services::mutation_trace::runtime::resolve_git_dir; +#[allow(unused_imports)] use crate::services::observability::traits::Logger; +#[allow(unused_imports)] +use anyhow::{anyhow, bail, Context, Result}; +#[allow(unused_imports)] +use std::path::{Path, PathBuf}; -use boundary_lock::{AdapterBoundaryLock, DEFAULT_BOUNDARY_LOCK_TIMEOUT}; - -const HOOK_EVENT_NAME_FIELD: &str = "hook_event_name"; -const SESSION_ID_FIELD: &str = "session_id"; -const TURN_ID_FIELD: &str = "turn_id"; -const CWD_FIELD: &str = "cwd"; -const AGENT_ID_FIELD: &str = "agent_id"; -const AGENT_TYPE_FIELD: &str = "agent_type"; -const MODEL_FIELD: &str = "model"; -const PROVENANCE_FIELD: &str = "provenance"; -const TOOL_NAME_FIELD: &str = "tool_name"; -const TOOL_USE_ID_FIELD: &str = "tool_use_id"; -const TOOL_INPUT_FIELD: &str = "tool_input"; - -const CODEX_TRACKED_TOOL_BASH: &str = "Bash"; - -const HOOK_EVENT_PRE_TOOL_USE: &str = "PreToolUse"; -const HOOK_EVENT_POST_TOOL_USE: &str = "PostToolUse"; -const HOOK_EVENT_STOP: &str = "Stop"; -const HOOK_EVENT_INTERRUPT: &str = "Interrupt"; -const HOOK_EVENT_SUBAGENT_STOP: &str = "SubagentStop"; -const HOOK_EVENT_SESSION_END: &str = "SessionEnd"; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) enum CodexHookEvent { - PreToolUse(CodexToolExecution), - PostToolUse(CodexToolIdentity), - Stop(CodexTurnIdentity), - Interrupt(CodexTurnIdentity), - SubagentStop(CodexAgentIdentity), - SessionEnd(CodexSessionIdentity), -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct CodexToolIdentity { - pub session_id: String, - pub turn_id: String, - pub cwd: String, - pub agent_id: Option, - pub tool_name: String, - pub tool_use_id: String, -} - -impl CodexToolIdentity { - pub(crate) fn attempt_key(&self) -> AttemptKey { - AttemptKey { - session_id: self.session_id.clone(), - agent_id: self.agent_id.clone(), - tool_use_id: self.tool_use_id.clone(), - } - } - - pub(crate) fn is_subagent(&self) -> bool { - self.agent_id.is_some() - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct CodexToolExecution { - pub identity: CodexToolIdentity, - pub agent_type: Option, - pub model: Option, - pub tool_input: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct CodexTurnIdentity { - pub session_id: String, - pub turn_id: String, - pub cwd: String, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct CodexAgentIdentity { - pub session_id: String, - pub turn_id: String, - pub cwd: String, - pub agent_id: String, - pub agent_type: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct CodexSessionIdentity { - pub session_id: String, - pub cwd: String, -} - -#[allow(clippy::struct_field_names)] -#[derive(Clone, Debug, Eq, PartialEq, Hash)] -pub(crate) struct AttemptKey { - pub session_id: String, - pub agent_id: Option, - pub tool_use_id: String, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum ToolClassification { - TrackedMutation, - Delegation, - Untracked, -} - -const TRACKED_MUTATION_TOOL_NAMES: &[&str] = &["Bash", "apply_patch"]; -const DELEGATION_TOOL_NAMES: &[&str] = &["collaborationspawn_agent", "collaborationwait_agent"]; -const MCP_TOOL_NAME_PREFIX: &str = "mcp__"; - -pub(crate) fn is_mcp_tool_name(tool_name: &str) -> bool { - tool_name.starts_with(MCP_TOOL_NAME_PREFIX) -} - -pub(crate) fn classify_tool(tool_name: &str) -> ToolClassification { - if TRACKED_MUTATION_TOOL_NAMES.contains(&tool_name) { - ToolClassification::TrackedMutation - } else if DELEGATION_TOOL_NAMES.contains(&tool_name) { - ToolClassification::Delegation - } else { - ToolClassification::Untracked - } -} - -const CODEX_SCOPE_ID_SCHEME: &str = "cx-tool-v1"; - -pub(crate) fn format_codex_scope_id(attempt_seq: u64, key: &AttemptKey) -> String { - let agent_id = key.agent_id.as_deref().unwrap_or(""); - format!( - "{CODEX_SCOPE_ID_SCHEME}|n={attempt_seq}|s={}:{}|a={}:{}|t={}:{}", - key.session_id.len(), - key.session_id, - agent_id.len(), - agent_id, - key.tool_use_id.len(), - key.tool_use_id, - ) -} - -pub(crate) fn codex_scope_start_event_id(scope_id: &str) -> String { - format!("{scope_id}|start") -} - -pub(crate) fn codex_scope_close_event_id(scope_id: &str) -> String { - format!("{scope_id}|close") -} - -pub(crate) fn parse_codex_hook_event(stdin_payload: &str) -> Result { - if stdin_payload.trim().is_empty() { - bail!(validation_error( - "expected a JSON object, got an empty payload" - )); - } - - let parsed: Value = serde_json::from_str(stdin_payload) - .with_context(|| validation_error("expected valid JSON"))?; - let object = parsed - .as_object() - .ok_or_else(|| anyhow!(validation_error("expected a JSON object")))?; - - let hook_event_name = required_non_blank_str(object, HOOK_EVENT_NAME_FIELD)?; - - match hook_event_name.as_str() { - HOOK_EVENT_PRE_TOOL_USE => parse_pre_tool_use(object).map(CodexHookEvent::PreToolUse), - HOOK_EVENT_POST_TOOL_USE => parse_tool_identity(object).map(CodexHookEvent::PostToolUse), - HOOK_EVENT_STOP => parse_turn_identity(object).map(CodexHookEvent::Stop), - HOOK_EVENT_INTERRUPT => parse_turn_identity(object).map(CodexHookEvent::Interrupt), - HOOK_EVENT_SUBAGENT_STOP => parse_agent_identity(object).map(CodexHookEvent::SubagentStop), - HOOK_EVENT_SESSION_END => parse_session_identity(object).map(CodexHookEvent::SessionEnd), - other => bail!(validation_error(&format!( - "unsupported hook_event_name '{other}'" - ))), - } -} - -fn parse_tool_identity(object: &Map) -> Result { - Ok(CodexToolIdentity { - session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, - turn_id: required_non_blank_str(object, TURN_ID_FIELD)?, - cwd: required_non_blank_str(object, CWD_FIELD)?, - agent_id: optional_non_blank_str(object, AGENT_ID_FIELD)?, - tool_name: required_non_blank_str(object, TOOL_NAME_FIELD)?, - tool_use_id: required_non_blank_str(object, TOOL_USE_ID_FIELD)?, - }) -} - -fn parse_pre_tool_use(object: &Map) -> Result { - Ok(CodexToolExecution { - identity: parse_tool_identity(object)?, - agent_type: optional_non_blank_str(object, AGENT_TYPE_FIELD)?, - model: tolerated_model(object), - tool_input: object.get(TOOL_INPUT_FIELD).cloned(), - }) -} - -fn parse_turn_identity(object: &Map) -> Result { - Ok(CodexTurnIdentity { - session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, - turn_id: required_non_blank_str(object, TURN_ID_FIELD)?, - cwd: required_non_blank_str(object, CWD_FIELD)?, - }) -} - -fn parse_agent_identity(object: &Map) -> Result { - Ok(CodexAgentIdentity { - session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, - turn_id: required_non_blank_str(object, TURN_ID_FIELD)?, - cwd: required_non_blank_str(object, CWD_FIELD)?, - agent_id: required_non_blank_str(object, AGENT_ID_FIELD)?, - agent_type: optional_non_blank_str(object, AGENT_TYPE_FIELD)?, - }) -} - -fn parse_session_identity(object: &Map) -> Result { - Ok(CodexSessionIdentity { - session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, - cwd: required_non_blank_str(object, CWD_FIELD)?, - }) -} - -fn required_field<'a>(object: &'a Map, field: &str) -> Result<&'a Value> { - object.get(field).ok_or_else(|| { - anyhow!(validation_error(&format!( - "missing required field '{field}'" - ))) - }) -} - -fn required_str(object: &Map, field: &str) -> Result { - required_field(object, field)? - .as_str() - .map(str::to_owned) - .ok_or_else(|| { - anyhow!(validation_error(&format!( - "field '{field}' must be a string" - ))) - }) -} - -fn required_non_blank_str(object: &Map, field: &str) -> Result { - let value = required_str(object, field)?; - if value.trim().is_empty() { - bail!(validation_error(&format!( - "field '{field}' must be a non-blank string" - ))); - } - Ok(value) -} - -fn optional_non_blank_str(object: &Map, field: &str) -> Result> { - match object.get(field) { - None | Some(Value::Null) => Ok(None), - Some(Value::String(value)) => { - if value.trim().is_empty() { - bail!(validation_error(&format!( - "field '{field}' must be null, absent, or a non-blank string" - ))); - } - Ok(Some(value.clone())) - } - Some(_) => bail!(validation_error(&format!( - "field '{field}' must be null, absent, or a non-blank string" - ))), - } -} - -fn tolerated_model(object: &Map) -> Option { - object - .get(MODEL_FIELD) - .and_then(Value::as_str) - .map(str::to_owned) -} - -fn validation_error(detail: &str) -> String { - format!("Invalid Codex hook event payload from STDIN: {detail}.") -} - -type GitDirResolver<'a> = &'a dyn Fn(&str) -> Result; - -type IngressSeam<'a> = &'a dyn Fn(&Path, &str, Option<&dyn Logger>) -> Result; - -type BashPolicyEvaluator<'a> = &'a dyn Fn(&Path, &str) -> Result; - -const ACTOR_KIND_CODEX: &str = "codex"; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct CodexScopeProvenance { - pub session_id: String, - pub model_id: Option, -} - -fn codex_scope_provenance(execution: &CodexToolExecution) -> CodexScopeProvenance { - CodexScopeProvenance { - session_id: prefixed_diff_trace_session_id(CODEX_TOOL_NAME, &execution.identity.session_id), - model_id: execution - .model - .as_deref() - .and_then(normalize_codex_model_id), - } -} - -const FAIL_CLOSED_DENY_REASON: &str = - "SCE could not establish mutation attribution for this tool execution."; - -const PRE_TOOL_USE_FAIL_CLOSED_EVENT: &str = - "sce.hooks.codex_mutation_scope.pre_tool_use_fail_closed"; - -fn log_pre_tool_use_fail_closed(logger: Option<&dyn Logger>, context: &str, error: &anyhow::Error) { - if let Some(log) = logger { - log.warn( - PRE_TOOL_USE_FAIL_CLOSED_EVENT, - &error.to_string(), - &[("context", context)], - None, - ); - } -} - -pub(crate) fn run_codex_mutation_scope_subcommand(logger: Option<&dyn Logger>) -> Result { - let stdin_payload = super::read_hook_stdin()?; - run_codex_mutation_scope_from_payload(&stdin_payload, logger) -} - -pub(crate) fn run_codex_mutation_scope_from_payload( - stdin_payload: &str, - logger: Option<&dyn Logger>, -) -> Result { - let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); - let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { - super::mutation_scope::run_mutation_scope_from_payload(repository_root, payload, logger) - }; - let bash_policy_fn = |repository_root: &Path, command: &str| { - evaluate_codex_bash_policy(repository_root, command) - }; - - run_codex_mutation_scope_from_payload_with_seams( - stdin_payload, - logger, - &resolve_git_dir_fn, - &seam_fn, - &bash_policy_fn, - ) -} - -#[cfg(test)] -pub(crate) fn run_codex_mutation_scope_from_payload_at_state_root( - state_root: &Path, - stdin_payload: &str, - logger: Option<&dyn Logger>, -) -> Result { - let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); - let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { - super::mutation_scope::run_mutation_scope_from_payload_at_state_root( - repository_root, - state_root, - payload, - logger, - ) - }; - let bash_policy_fn = |repository_root: &Path, command: &str| { - evaluate_codex_bash_policy(repository_root, command) - }; - - run_codex_mutation_scope_from_payload_with_seams( - stdin_payload, - logger, - &resolve_git_dir_fn, - &seam_fn, - &bash_policy_fn, - ) -} +#[allow(unused_imports)] +use serde_json::{json, Map, Value}; +#[allow(unused_imports)] +pub(crate) use events::{ + classify_tool, codex_scope_close_event_id, codex_scope_start_event_id, format_codex_scope_id, + is_mcp_tool_name, parse_codex_hook_event, AttemptKey, CodexAgentIdentity, CodexHookEvent, + CodexSessionIdentity, CodexToolExecution, CodexToolIdentity, CodexTurnIdentity, + ToolClassification, +}; +#[allow(unused_imports)] +use events::{ + AGENT_ID_FIELD, AGENT_TYPE_FIELD, CODEX_TRACKED_TOOL_BASH, CWD_FIELD, HOOK_EVENT_INTERRUPT, + HOOK_EVENT_NAME_FIELD, HOOK_EVENT_POST_TOOL_USE, HOOK_EVENT_PRE_TOOL_USE, + HOOK_EVENT_SESSION_END, HOOK_EVENT_STOP, HOOK_EVENT_SUBAGENT_STOP, MODEL_FIELD, + PROVENANCE_FIELD, SESSION_ID_FIELD, TOOL_INPUT_FIELD, TOOL_NAME_FIELD, TOOL_USE_ID_FIELD, + TRACKED_MUTATION_TOOL_NAMES, TURN_ID_FIELD, +}; #[cfg(test)] -fn run_codex_mutation_scope_from_payload_with( - stdin_payload: &str, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - seam: IngressSeam, -) -> Result { - let allow_all = |_repository_root: &Path, _command: &str| Ok(CodexBashPolicyDecision::Allowed); - run_codex_mutation_scope_from_payload_with_seams( - stdin_payload, - logger, - resolve_git_dir, - seam, - &allow_all, - ) -} - +pub(crate) use lifecycle::run_codex_mutation_scope_from_payload_at_state_root; +#[allow(unused_imports)] +use lifecycle::{ + abandon_attempt, run_codex_mutation_scope_from_payload_with_seams, FAIL_CLOSED_DENY_REASON, + PRE_TOOL_USE_FAIL_CLOSED_EVENT, +}; #[cfg(test)] -fn run_codex_mutation_scope_from_payload_with_bash_policy( - stdin_payload: &str, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - seam: IngressSeam, - evaluate_bash_policy: BashPolicyEvaluator, -) -> Result { - run_codex_mutation_scope_from_payload_with_seams( - stdin_payload, - logger, - resolve_git_dir, - seam, - evaluate_bash_policy, - ) -} - -fn run_codex_mutation_scope_from_payload_with_seams( - stdin_payload: &str, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - seam: IngressSeam, - evaluate_bash_policy: BashPolicyEvaluator, -) -> Result { - let event = parse_codex_hook_event(stdin_payload)?; - dispatch_codex_hook_event(event, logger, resolve_git_dir, seam, evaluate_bash_policy) -} - -fn dispatch_codex_hook_event( - event: CodexHookEvent, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - seam: IngressSeam, - evaluate_bash_policy: BashPolicyEvaluator, -) -> Result { - match event { - CodexHookEvent::PreToolUse(execution) => Ok(handle_pre_tool_use( - &execution, - logger, - resolve_git_dir, - seam, - evaluate_bash_policy, - )), - CodexHookEvent::PostToolUse(identity) => { - if !matches!( - classify_tool(&identity.tool_name), - ToolClassification::TrackedMutation - ) { - return Ok(String::new()); - } - - let git_dir = resolve_git_dir(&identity.cwd)?; - let repository_root = Path::new(&identity.cwd); - with_boundary_lock(&git_dir, || { - handle_close( - &git_dir, - repository_root, - &identity.attempt_key(), - logger, - seam, - ) - }) - } - CodexHookEvent::Stop(turn) => { - let git_dir = resolve_git_dir(&turn.cwd)?; - let repository_root = Path::new(&turn.cwd); - let session_id = turn.session_id.clone(); - with_boundary_lock(&git_dir, || { - cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { - attempt.session_id == session_id && attempt.agent_id.is_none() - }) - }) - } - CodexHookEvent::Interrupt(turn) => { - let git_dir = resolve_git_dir(&turn.cwd)?; - let repository_root = Path::new(&turn.cwd); - let session_id = turn.session_id.clone(); - with_boundary_lock(&git_dir, || { - cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { - attempt.session_id == session_id - }) - }) - } - CodexHookEvent::SubagentStop(agent) => { - let git_dir = resolve_git_dir(&agent.cwd)?; - let repository_root = Path::new(&agent.cwd); - let session_id = agent.session_id.clone(); - let agent_id = agent.agent_id.clone(); - with_boundary_lock(&git_dir, || { - cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { - attempt.session_id == session_id - && attempt.agent_id.as_deref() == Some(&agent_id) - }) - }) - } - CodexHookEvent::SessionEnd(session) => { - let git_dir = resolve_git_dir(&session.cwd)?; - let repository_root = Path::new(&session.cwd); - let session_id = session.session_id.clone(); - with_boundary_lock(&git_dir, || { - cleanup_attempts_matching(&git_dir, repository_root, logger, seam, |attempt| { - attempt.session_id == session_id - }) - }) - } - } -} - -fn with_boundary_lock(git_dir: &Path, operation: impl FnOnce() -> Result) -> Result { - let _boundary = AdapterBoundaryLock::acquire(git_dir, DEFAULT_BOUNDARY_LOCK_TIMEOUT) - .map_err(|error| anyhow!("Failed to acquire adapter boundary lock: {error}"))?; - operation() -} - -fn handle_pre_tool_use( - execution: &CodexToolExecution, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - seam: IngressSeam, - evaluate_bash_policy: BashPolicyEvaluator, -) -> String { - let identity = &execution.identity; - - if !matches!( - classify_tool(&identity.tool_name), - ToolClassification::TrackedMutation - ) { - return String::new(); - } - - let repository_root = Path::new(&identity.cwd); - - if identity.tool_name == CODEX_TRACKED_TOOL_BASH { - match codex_bash_policy_preflight(repository_root, execution, evaluate_bash_policy) { - BashPolicyPreflight::Allowed => {} - BashPolicyPreflight::Blocked(response) => return response, - BashPolicyPreflight::EvaluationFailed(error) => { - log_pre_tool_use_fail_closed(logger, "bash_policy_preflight", &error); - return pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON); - } - } - } - - let git_dir = match resolve_git_dir(&identity.cwd) { - Ok(git_dir) => git_dir, - Err(error) => { - log_pre_tool_use_fail_closed(logger, "resolve_git_dir", &error); - return pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON); - } - }; - - let key = identity.attempt_key(); - let turn_id = identity.turn_id.as_str(); - let provenance = codex_scope_provenance(execution); - let outcome = with_boundary_lock(&git_dir, || { - state::normalize_recovery_after_boundary_lock_acquired(&git_dir)?; - - sweep_stale_lane_predecessors(&git_dir, repository_root, &key, turn_id, logger, seam)?; - - match admit_or_recover( - &git_dir, - repository_root, - &key, - turn_id, - &identity.tool_name, - logger, - seam, - )? { - Admission::Admitted(allocated) => { - establish_start( - &git_dir, - repository_root, - &allocated, - &provenance, - logger, - seam, - )?; - Ok(PreToolUseOutcome::Continue) - } - Admission::Denied => Ok(PreToolUseOutcome::Deny), - } - }); - - match outcome { - Ok(PreToolUseOutcome::Continue) => String::new(), - Ok(PreToolUseOutcome::Deny) => pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), - Err(error) => { - log_pre_tool_use_fail_closed(logger, "codex_mutation_scope_pre_tool_use", &error); - pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON) - } - } -} - -enum PreToolUseOutcome { - Continue, - Deny, -} - -enum BashPolicyPreflight { - Allowed, - Blocked(String), - EvaluationFailed(anyhow::Error), -} - -fn codex_bash_policy_preflight( - repository_root: &Path, - execution: &CodexToolExecution, - evaluate_bash_policy: BashPolicyEvaluator, -) -> BashPolicyPreflight { - let command = match bash_command_from_tool_input(execution.tool_input.as_ref()) { - Ok(command) => command, - Err(error) => return BashPolicyPreflight::EvaluationFailed(error), - }; - - match evaluate_bash_policy(repository_root, command) { - Ok(CodexBashPolicyDecision::Allowed) => BashPolicyPreflight::Allowed, - Ok(CodexBashPolicyDecision::Blocked(response)) => BashPolicyPreflight::Blocked(response), - Err(error) => BashPolicyPreflight::EvaluationFailed(error), - } -} - -enum Admission { - Admitted(state::AllocatedAttempt), - Denied, -} - -fn sweep_stale_lane_predecessors( - git_dir: &Path, - repository_root: &Path, - key: &AttemptKey, - turn_id: &str, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result<()> { - loop { - let current = state::read_state(git_dir)?; - let Some(stale) = current - .attempts - .iter() - .find(|attempt| { - attempt.in_builtin_lane(&key.session_id, turn_id) - && !attempt_matches_key(attempt, key) - }) - .cloned() - else { - return Ok(()); - }; - abandon_attempt(git_dir, repository_root, &stale, logger, seam)?; - } -} - -fn admit_or_recover( - git_dir: &Path, - repository_root: &Path, - key: &AttemptKey, - turn_id: &str, - tool_name: &str, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result { - match state::admit_tracked_attempt(git_dir, key, turn_id, tool_name)? { - state::AdmitDecision::Admitted(allocated) => Ok(Admission::Admitted(allocated)), - state::AdmitDecision::RecoveryBlocked - | state::AdmitDecision::UncertainAttemptBlocked - | state::AdmitDecision::StalePredecessorBlocked => Ok(Admission::Denied), - state::AdmitDecision::FlushClaimed { generation } => { - match seam(repository_root, &flush_payload(), logger) { - Ok(_) => match state::complete_recovery_flush(git_dir, generation)? { - state::RecoveryFlushCompletion::Cleared => { - readmit_after_flush(git_dir, key, turn_id, tool_name) - } - state::RecoveryFlushCompletion::Superseded => Ok(Admission::Denied), - }, - Err(error) => { - log_pre_tool_use_fail_closed(logger, "recovery_flush", &error); - state::relinquish_recovery_flush(git_dir, generation)?; - Ok(Admission::Denied) - } - } - } - } -} - -fn readmit_after_flush( - git_dir: &Path, - key: &AttemptKey, - turn_id: &str, - tool_name: &str, -) -> Result { - match state::admit_tracked_attempt(git_dir, key, turn_id, tool_name)? { - state::AdmitDecision::Admitted(allocated) => Ok(Admission::Admitted(allocated)), - state::AdmitDecision::FlushClaimed { generation } => { - state::relinquish_recovery_flush(git_dir, generation)?; - Ok(Admission::Denied) - } - state::AdmitDecision::RecoveryBlocked - | state::AdmitDecision::UncertainAttemptBlocked - | state::AdmitDecision::StalePredecessorBlocked => Ok(Admission::Denied), - } -} - -fn establish_start( - git_dir: &Path, - repository_root: &Path, - allocated: &state::AllocatedAttempt, - provenance: &CodexScopeProvenance, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result<()> { - let scope_id = &allocated.attempt.scope_id; - - if allocated.reused && allocated.attempt.phase == state::AttemptPhase::Active { - return Ok(()); - } - - let start_payload = - scope_start_payload(scope_id, &codex_scope_start_event_id(scope_id), provenance); - - seam(repository_root, &start_payload, logger)?; - state::mark_active(git_dir, scope_id)?; - Ok(()) -} - -fn handle_close( - git_dir: &Path, - repository_root: &Path, - key: &AttemptKey, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result { - let current = state::read_state(git_dir)?; - let Some(attempt) = current - .attempts - .iter() - .find(|attempt| attempt_matches_key(attempt, key)) - .cloned() - else { - return Ok(String::new()); - }; - - if attempt.phase == state::AttemptPhase::PendingStart { - abandon_attempt(git_dir, repository_root, &attempt, logger, seam)?; - return Ok(String::new()); - } - - let close_payload = scope_boundary_payload( - "close", - &attempt.scope_id, - &codex_scope_close_event_id(&attempt.scope_id), - ); - - if seam(repository_root, &close_payload, logger).is_ok() { - state::remove_attempt(git_dir, &attempt.scope_id)?; - } else { - abandon_attempt(git_dir, repository_root, &attempt, logger, seam)?; - } - Ok(String::new()) -} - -fn cleanup_attempts_matching( - git_dir: &Path, - repository_root: &Path, - logger: Option<&dyn Logger>, - seam: IngressSeam, - predicate: impl Fn(&state::AdapterAttempt) -> bool, -) -> Result { - let current = state::read_state(git_dir)?; - let stale: Vec = current - .attempts - .into_iter() - .filter(|attempt| predicate(attempt)) - .collect(); - - for attempt in &stale { - abandon_attempt(git_dir, repository_root, attempt, logger, seam)?; - } - - Ok(String::new()) -} - -fn attempt_matches_key(attempt: &state::AdapterAttempt, key: &AttemptKey) -> bool { - attempt.session_id == key.session_id - && attempt.agent_id == key.agent_id - && attempt.tool_use_id == key.tool_use_id -} - -fn abandon_attempt( - git_dir: &Path, - repository_root: &Path, - attempt: &state::AdapterAttempt, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result<()> { - state::arm_recovery(git_dir)?; - - seam(repository_root, &abandon_payload(&attempt.scope_id), logger)?; - state::remove_attempt(git_dir, &attempt.scope_id)?; - Ok(()) -} - -fn scope_boundary_payload(operation: &str, scope_id: &str, event_id: &str) -> String { - json!({ - "operation": operation, - "scope_id": scope_id, - "event_id": event_id, - "actor_kind": ACTOR_KIND_CODEX, - }) - .to_string() -} - -fn scope_start_payload( - scope_id: &str, - event_id: &str, - provenance: &CodexScopeProvenance, -) -> String { - json!({ - "operation": "start", - "scope_id": scope_id, - "event_id": event_id, - "actor_kind": ACTOR_KIND_CODEX, - PROVENANCE_FIELD: { - "session_id": provenance.session_id, - "model_id": provenance.model_id, - }, - }) - .to_string() -} - -fn abandon_payload(scope_id: &str) -> String { - json!({ - "operation": "abandon", - "scope_id": scope_id, - }) - .to_string() -} - -fn flush_payload() -> String { - json!({ "operation": "flush" }).to_string() -} - -fn pre_tool_use_deny_json(reason: &str) -> String { - json!({ - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "deny", - "permissionDecisionReason": reason, - } - }) - .to_string() -} +#[allow(unused_imports)] +use lifecycle::{ + codex_scope_provenance, run_codex_mutation_scope_from_payload_with, + run_codex_mutation_scope_from_payload_with_bash_policy, GitDirResolver, IngressSeam, +}; +#[allow(unused_imports)] +pub(crate) use lifecycle::{ + run_codex_mutation_scope_from_payload, run_codex_mutation_scope_subcommand, +}; +#[allow(unused_imports)] +use payload::{ + abandon_payload, flush_payload, pre_tool_use_deny_json, scope_boundary_payload, + scope_start_payload, +}; #[cfg(test)] -mod tests { - use super::*; - - const PROBE01_SHELL_PRE: &str = - include_str!("fixtures/probe01-apply-patch-and-shell-success.shell.pre_tool_use.json"); - const PROBE01_SHELL_POST: &str = - include_str!("fixtures/probe01-apply-patch-and-shell-success.shell.post_tool_use.json"); - const PROBE01_APPLY_PATCH_PRE: &str = include_str!( - "fixtures/probe01-apply-patch-and-shell-success.apply_patch.pre_tool_use.json" - ); - const PROBE01_STOP: &str = - include_str!("fixtures/probe01-apply-patch-and-shell-success.stop.json"); - const PROBE01_SESSION_END: &str = - include_str!("fixtures/probe01-apply-patch-and-shell-success.session_end.json"); - const PROBE02_FAILED_SHELL_POST: &str = - include_str!("fixtures/probe02-shell-partial-write-then-nonzero-exit.post_tool_use.json"); - const PROBE04_BLOCKED_PRE: &str = include_str!( - "fixtures/probe04-pre-tool-use-hook-hookspecificoutput-deny.pre_tool_use.json" - ); - const PROBE05_SHELL_PRE: &str = - include_str!("fixtures/probe05-tool-vocabulary.shell-read-list-search.pre_tool_use.json"); - const PROBE08_SPAWN_AGENT_PRE: &str = - include_str!("fixtures/probe08-subagent-delegation.spawn_agent.pre_tool_use.json"); - const PROBE08_WAIT_AGENT_PRE: &str = - include_str!("fixtures/probe08-subagent-delegation.wait_agent.pre_tool_use.json"); - const PROBE08_AGENT_APPLY_PATCH_PRE: &str = - include_str!("fixtures/probe08-subagent-delegation.agent-apply-patch.pre_tool_use.json"); - const PROBE08_AGENT_APPLY_PATCH_POST: &str = - include_str!("fixtures/probe08-subagent-delegation.agent-apply-patch.post_tool_use.json"); - const PROBE08_SUBAGENT_STOP: &str = - include_str!("fixtures/probe08-subagent-delegation.subagent_stop.json"); - const PROBE10_WORKTREE_PRE: &str = - include_str!("fixtures/probe10-linked-worktree-cwd.pre_tool_use.json"); - const PROBE11_INTERRUPT: &str = - include_str!("fixtures/probe11-interrupt-event-on-sigint.interrupt.json"); - const PROBE12_MCP_PRE: &str = - include_str!("fixtures/probe12-mcp-mutate-success.pre_tool_use.json"); - const PROBE12_MCP_POST: &str = - include_str!("fixtures/probe12-mcp-mutate-success.post_tool_use.json"); - const PROBE13_MCP_MUTATE_THEN_ERROR_PRE: &str = - include_str!("fixtures/probe13-mcp-mutate-then-error.pre_tool_use.json"); - const PROBE13_MCP_SESSION_END: &str = - include_str!("fixtures/probe13-mcp-mutate-then-error.session_end.json"); - - fn pre_tool_use_json(overrides: &[(&str, Value)]) -> String { - let mut object = Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(HOOK_EVENT_PRE_TOOL_USE.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String("session-1".to_string()), - ); - object.insert( - TURN_ID_FIELD.to_string(), - Value::String("turn-1".to_string()), - ); - object.insert( - CWD_FIELD.to_string(), - Value::String("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/repo/checkout".to_string()), - ); - object.insert( - TOOL_NAME_FIELD.to_string(), - Value::String("Bash".to_string()), - ); - object.insert( - TOOL_USE_ID_FIELD.to_string(), - Value::String("exec-1".to_string()), - ); - object.insert(TOOL_INPUT_FIELD.to_string(), json!({"command": "true"})); - for (field, value) in overrides { - object.insert((*field).to_string(), value.clone()); - } - Value::Object(object).to_string() - } - - fn key(session_id: &str, agent_id: Option<&str>, tool_use_id: &str) -> AttemptKey { - AttemptKey { - session_id: session_id.to_string(), - agent_id: agent_id.map(str::to_string), - tool_use_id: tool_use_id.to_string(), - } - } - - fn pre_tool_use(payload: &str) -> CodexToolExecution { - match parse_codex_hook_event(payload).expect("valid PreToolUse parses") { - CodexHookEvent::PreToolUse(execution) => execution, - other => panic!("expected PreToolUse, got {other:?}"), - } - } - - #[test] - fn ac2_empty_payload_is_rejected() { - let error = parse_codex_hook_event(" ").unwrap_err().to_string(); - assert_eq!( - error, - "Invalid Codex hook event payload from STDIN: expected a JSON object, got an empty payload." - ); - } - - #[test] - fn ac2_non_object_json_is_rejected() { - for payload in ["[]", "\"PreToolUse\"", "42", "null"] { - let error = parse_codex_hook_event(payload).unwrap_err().to_string(); - assert!( - error.contains("expected a JSON object"), - "payload {payload:?} produced {error:?}" - ); - } - } - - #[test] - fn ac2_invalid_json_is_rejected() { - let error = parse_codex_hook_event("{not json").unwrap_err().to_string(); - assert!( - error.contains("Invalid Codex hook event payload from STDIN: expected valid JSON"), - "{error:?}" - ); - } - - #[test] - fn ac2_unsupported_hook_event_name_is_rejected() { - for name in [ - "SessionStart", - "SubagentStart", - "UserPromptSubmit", - "PreCompact", - ] { - let payload = - pre_tool_use_json(&[(HOOK_EVENT_NAME_FIELD, Value::String(name.to_string()))]); - let error = parse_codex_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains(&format!("unsupported hook_event_name '{name}'")), - "{error:?}" - ); - } - } - - #[test] - fn ac2_missing_required_fields_are_rejected_without_fabricating_identity() { - for field in [ - SESSION_ID_FIELD, - TURN_ID_FIELD, - CWD_FIELD, - TOOL_NAME_FIELD, - TOOL_USE_ID_FIELD, - ] { - let mut object: Map = - serde_json::from_str(&pre_tool_use_json(&[])).unwrap(); - object.remove(field); - let payload = Value::Object(object).to_string(); - - let error = parse_codex_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains(&format!("'{field}'")), - "missing {field} produced {error:?}" - ); - } - } - - #[test] - fn ac2_blank_required_fields_are_rejected() { - for field in [SESSION_ID_FIELD, TURN_ID_FIELD, CWD_FIELD, TOOL_NAME_FIELD] { - let payload = pre_tool_use_json(&[(field, Value::String(" ".to_string()))]); - let error = parse_codex_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains(&format!("field '{field}' must be a non-blank string")), - "blank {field} produced {error:?}" - ); - } - } - - #[test] - fn ac2_wrong_typed_fields_are_rejected() { - let payload = pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::Bool(true))]); - let error = parse_codex_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains("field 'tool_use_id' must be a string"), - "{error:?}" - ); - } - - #[test] - fn ac2_wrong_typed_optional_agent_id_is_rejected() { - let payload = pre_tool_use_json(&[(AGENT_ID_FIELD, Value::Bool(false))]); - let error = parse_codex_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains("field 'agent_id' must be null, absent, or a non-blank string"), - "{error:?}" - ); - } - - #[test] - fn ac2_pre_tool_use_fixtures_parse_to_expected_identity() { - let shell = pre_tool_use(PROBE01_SHELL_PRE); - assert_eq!(shell.identity.tool_name, "Bash"); - assert_eq!( - shell.identity.tool_use_id, - "exec-414820f5-555e-457a-92e7-60ddd27d4eec" - ); - assert_eq!( - shell.identity.session_id, - "01a07c1e-e08e-7172-8032-cb9d62af21d9" - ); - assert_eq!( - shell.identity.turn_id, - "01a07c1e-e0cc-75f1-a566-e790c06cb033" - ); - assert_eq!(shell.identity.agent_id, None); - assert!(!shell.identity.is_subagent()); - assert!(shell.identity.cwd.ends_with("/probe-repo")); - - let apply_patch = pre_tool_use(PROBE01_APPLY_PATCH_PRE); - assert_eq!(apply_patch.identity.tool_name, "apply_patch"); - - let vocab = pre_tool_use(PROBE05_SHELL_PRE); - assert_eq!(vocab.identity.tool_name, "Bash"); - - let worktree = pre_tool_use(PROBE10_WORKTREE_PRE); - assert!(worktree.identity.cwd.ends_with("/probe-worktree")); - - let mcp = pre_tool_use(PROBE12_MCP_PRE); - assert_eq!(mcp.identity.tool_name, "mcp__probe__mutate_success"); - - let mcp_err = pre_tool_use(PROBE13_MCP_MUTATE_THEN_ERROR_PRE); - assert!(is_mcp_tool_name(&mcp_err.identity.tool_name)); - } - - #[test] - fn ac2_subagent_pre_tool_use_fixture_carries_agent_identity() { - let execution = pre_tool_use(PROBE08_AGENT_APPLY_PATCH_PRE); - assert_eq!( - execution.identity.agent_id.as_deref(), - Some("01a07c24-bb59-7ca0-80f7-99cf940a486e") - ); - assert!(execution.identity.is_subagent()); - assert_eq!(execution.agent_type.as_deref(), Some("default")); - } - - #[test] - fn ac3_pre_tool_use_fixtures_retain_the_codex_model() { - for fixture in [ - PROBE01_SHELL_PRE, - PROBE01_APPLY_PATCH_PRE, - PROBE05_SHELL_PRE, - PROBE08_AGENT_APPLY_PATCH_PRE, - ] { - assert_eq!(pre_tool_use(fixture).model.as_deref(), Some("gpt-5.6-sol")); - } - } - - #[test] - fn ac3_scope_provenance_canonicalizes_the_session_and_normalizes_the_model() { - let provenance = codex_scope_provenance(&pre_tool_use(PROBE01_SHELL_PRE)); - assert_eq!( - provenance.session_id, - "cx_01a07c1e-e08e-7172-8032-cb9d62af21d9" - ); - assert_eq!(provenance.model_id.as_deref(), Some("gpt-5.6-sol")); - - let apply_patch = codex_scope_provenance(&pre_tool_use(PROBE01_APPLY_PATCH_PRE)); - assert_eq!(apply_patch, provenance); - } - - #[test] - fn ac3_scope_provenance_keeps_an_already_prefixed_session_id() { - let execution = pre_tool_use(&pre_tool_use_json(&[( - SESSION_ID_FIELD, - Value::String("cx_session-1".to_string()), - )])); - assert_eq!( - codex_scope_provenance(&execution).session_id, - "cx_session-1" - ); - } - - #[test] - fn ac3_an_unusable_model_yields_no_model_id_without_rejecting_the_event() { - for model in [ - Value::Null, - Value::String(String::new()), - Value::String(" ".to_string()), - Value::Bool(true), - json!(7), - json!({ "id": "gpt-5.6-sol" }), - ] { - let payload = pre_tool_use_json(&[(MODEL_FIELD, model.clone())]); - let execution = pre_tool_use(&payload); - let provenance = codex_scope_provenance(&execution); - assert_eq!(provenance.model_id, None, "model {model:?}"); - assert_eq!(provenance.session_id, "cx_session-1", "model {model:?}"); - } - - let absent = pre_tool_use(&pre_tool_use_json(&[])); - assert_eq!(absent.model, None); - assert_eq!(codex_scope_provenance(&absent).model_id, None); - } - - #[test] - fn ac2_post_tool_use_fixtures_parse() { - for (payload, tool_name, tool_use_id) in [ - ( - PROBE01_SHELL_POST, - "Bash", - "exec-414820f5-555e-457a-92e7-60ddd27d4eec", - ), - ( - PROBE02_FAILED_SHELL_POST, - "Bash", - "exec-52155265-e98d-423f-87f8-76ee56ff33b1", - ), - ( - PROBE12_MCP_POST, - "mcp__probe__mutate_success", - "exec-00988fad-6707-48ed-81b6-07bb11933886", - ), - ] { - match parse_codex_hook_event(payload).expect("PostToolUse fixture parses") { - CodexHookEvent::PostToolUse(identity) => { - assert_eq!(identity.tool_name, tool_name); - assert_eq!(identity.tool_use_id, tool_use_id); - } - other => panic!("expected PostToolUse, got {other:?}"), - } - } - } - - #[test] - fn ac2_subagent_post_tool_use_ties_to_its_pre_tool_use() { - let CodexHookEvent::PostToolUse(post) = - parse_codex_hook_event(PROBE08_AGENT_APPLY_PATCH_POST).unwrap() - else { - panic!("expected PostToolUse"); - }; - let pre = pre_tool_use(PROBE08_AGENT_APPLY_PATCH_PRE); - assert_eq!(post.attempt_key(), pre.identity.attempt_key()); - assert!(post.attempt_key().agent_id.is_some()); - } - - #[test] - fn ac2_terminal_lifecycle_fixtures_parse() { - assert!(matches!( - parse_codex_hook_event(PROBE01_STOP).unwrap(), - CodexHookEvent::Stop(id) if id.turn_id == "01a07c1e-e0cc-75f1-a566-e790c06cb033" - )); - assert!(matches!( - parse_codex_hook_event(PROBE11_INTERRUPT).unwrap(), - CodexHookEvent::Interrupt(id) if id.session_id == "01a07c2f-ccbf-79f0-afb9-2d2ce919eea7" - )); - assert!(matches!( - parse_codex_hook_event(PROBE08_SUBAGENT_STOP).unwrap(), - CodexHookEvent::SubagentStop(id) - if id.agent_id == "01a07c24-bb59-7ca0-80f7-99cf940a486e" - )); - for session_end in [PROBE01_SESSION_END, PROBE13_MCP_SESSION_END] { - assert!(matches!( - parse_codex_hook_event(session_end).unwrap(), - CodexHookEvent::SessionEnd(_) - )); - } - } - - #[test] - fn ac2_session_end_needs_no_turn_id() { - let CodexHookEvent::SessionEnd(identity) = - parse_codex_hook_event(PROBE01_SESSION_END).unwrap() - else { - panic!("expected SessionEnd"); - }; - assert_eq!(identity.session_id, "01a07c1e-e08e-7172-8032-cb9d62af21d9"); - } - - #[test] - fn ac3_classification_table() { - let cases: &[(&str, ToolClassification)] = &[ - ("Bash", ToolClassification::TrackedMutation), - ("apply_patch", ToolClassification::TrackedMutation), - ("collaborationspawn_agent", ToolClassification::Delegation), - ("collaborationwait_agent", ToolClassification::Delegation), - ("mcp__probe__mutate_success", ToolClassification::Untracked), - ("mcp__probe_par__slow_mutate", ToolClassification::Untracked), - ("mcp__", ToolClassification::Untracked), - ("Read", ToolClassification::Untracked), - ("PowerShell", ToolClassification::Untracked), - ("some_future_codex_tool", ToolClassification::Untracked), - ("", ToolClassification::Untracked), - ]; - for (tool_name, expected) in cases { - assert_eq!( - classify_tool(tool_name), - *expected, - "classify_tool({tool_name:?})" - ); - } - } - - #[test] - fn ac3_classification_is_total_and_single_valued() { - for tool_name in [ - "Bash", - "apply_patch", - "collaborationspawn_agent", - "collaborationwait_agent", - "mcp__x__y", - "unknown", - ] { - let _: ToolClassification = classify_tool(tool_name); - } - } - - #[test] - fn ac3_delegation_and_untracked_tool_fixtures_do_not_yield_a_tracked_scope() { - for payload in [ - PROBE08_SPAWN_AGENT_PRE, - PROBE08_WAIT_AGENT_PRE, - PROBE12_MCP_PRE, - PROBE13_MCP_MUTATE_THEN_ERROR_PRE, - ] { - let execution = pre_tool_use(payload); - let classification = classify_tool(&execution.identity.tool_name); - assert_ne!( - classification, - ToolClassification::TrackedMutation, - "tool {:?} must not be TrackedMutation", - execution.identity.tool_name - ); - } - - assert_eq!( - classify_tool(&pre_tool_use(PROBE04_BLOCKED_PRE).identity.tool_name), - ToolClassification::TrackedMutation - ); - } - - #[test] - fn ac3_is_mcp_tool_name() { - assert!(is_mcp_tool_name("mcp__probe__mutate_success")); - assert!(is_mcp_tool_name("mcp__")); - assert!(!is_mcp_tool_name("Bash")); - assert!(!is_mcp_tool_name("apply_patch")); - assert!(!is_mcp_tool_name("collaborationspawn_agent")); - } - - #[test] - fn ac4_scope_id_is_deterministic_for_the_same_attempt_seq_and_key() { - let k = key("session-1", None, "exec-1"); - assert_eq!(format_codex_scope_id(7, &k), format_codex_scope_id(7, &k)); - - let scope_id = format_codex_scope_id(7, &k); - assert_eq!(scope_id, "cx-tool-v1|n=7|s=9:session-1|a=0:|t=6:exec-1"); - assert_eq!( - codex_scope_start_event_id(&scope_id), - format!("{scope_id}|start") - ); - assert_eq!( - codex_scope_close_event_id(&scope_id), - format!("{scope_id}|close") - ); - } - - #[test] - fn ac4_length_prefix_disambiguates_delimiter_collisions() { - let a = key("a:b", None, "c"); - let b = key("a", None, "b:c"); - assert_ne!(format_codex_scope_id(1, &a), format_codex_scope_id(1, &b)); - } - - #[test] - fn ac4_subagent_key_encodes_the_agent_id() { - let main = key("session-1", None, "exec-1"); - let sub = key("session-1", Some("agent-1"), "exec-1"); - assert_ne!( - format_codex_scope_id(1, &main), - format_codex_scope_id(1, &sub) - ); - assert_eq!( - format_codex_scope_id(1, &sub), - "cx-tool-v1|n=1|s=9:session-1|a=7:agent-1|t=6:exec-1" - ); - } - - #[test] - fn ac5_a_fresh_attempt_seq_yields_a_new_scope_id() { - let k = key("session-1", None, "exec-1"); - assert_ne!(format_codex_scope_id(1, &k), format_codex_scope_id(2, &k)); - assert!(format_codex_scope_id(2, &k).contains("|n=2|")); - } - - #[test] - fn ac5_attempt_key_excludes_turn_id() { - let base = pre_tool_use(&pre_tool_use_json(&[( - TOOL_USE_ID_FIELD, - Value::String("exec-9".to_string()), - )])); - let other_turn = pre_tool_use(&pre_tool_use_json(&[ - (TOOL_USE_ID_FIELD, Value::String("exec-9".to_string())), - (TURN_ID_FIELD, Value::String("turn-99".to_string())), - ])); - assert_eq!( - base.identity.attempt_key(), - other_turn.identity.attempt_key() - ); - } - - mod driver { - use std::cell::RefCell; - use std::path::{Path, PathBuf}; - use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; - use std::sync::mpsc; - use std::sync::{Arc, Mutex}; - use std::thread; - use std::time::Duration; - - use anyhow::{anyhow, Result}; - - use super::*; - use crate::services::observability::traits::Logger; - - const CWD: &str = "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/repo/checkout"; - - static NEXT_TEST_GIT_DIR_ID: AtomicU64 = AtomicU64::new(0); - - fn unique_test_git_dir(label: &str) -> PathBuf { - let id = NEXT_TEST_GIT_DIR_ID.fetch_add(1, Ordering::Relaxed); - std::env::temp_dir().join(format!( - "sce-codex-mutation-scope-driver-{label}-{}-{id}", - std::process::id() - )) - } - - fn remove_test_git_dir(git_dir: &Path) { - let _ = std::fs::remove_dir_all(git_dir); - } - - #[allow(clippy::unnecessary_wraps)] - fn ok_seam(_root: &Path, _payload: &str, _logger: Option<&dyn Logger>) -> Result { - Ok(String::new()) - } - - fn unreachable_seam( - _root: &Path, - payload: &str, - _logger: Option<&dyn Logger>, - ) -> Result { - panic!("the ingress seam must not be called for this payload: {payload}"); - } - - const BLOCKED_BASH_POLICY_RESPONSE: &str = concat!( - r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","#, - r#""permissionDecision":"deny","#, - r#""permissionDecisionReason":"Blocked by SCE bash-tool policy 'no-danger': danger is not allowed"}}"#, - ); - - #[allow(clippy::unnecessary_wraps)] - fn blocking_bash_policy(_root: &Path, _command: &str) -> Result { - Ok(CodexBashPolicyDecision::Blocked( - BLOCKED_BASH_POLICY_RESPONSE.to_string(), - )) - } - - #[allow(clippy::unnecessary_wraps)] - fn allow_bash_policy(_root: &Path, _command: &str) -> Result { - Ok(CodexBashPolicyDecision::Allowed) - } - - fn failing_bash_policy(_root: &Path, _command: &str) -> Result { - Err(anyhow!( - "repository Bash policy configuration is invalid and could not be evaluated" - )) - } - - fn unreachable_bash_policy(_root: &Path, command: &str) -> Result { - panic!("the Bash policy preflight must not run for this event (command: {command})"); - } - - fn seam_failing_on( - operation: &'static str, - ) -> impl Fn(&Path, &str, Option<&dyn Logger>) -> Result { - seam_failing_on_any(vec![operation]) - } - - fn seam_failing_on_any( - operations: Vec<&'static str>, - ) -> impl Fn(&Path, &str, Option<&dyn Logger>) -> Result { - move |_root, payload, _logger| { - if operations - .iter() - .any(|operation| payload.contains(&format!(r#""operation":"{operation}""#))) - { - Err(anyhow!( - "seam failure injected by test for one of {operations:?}" - )) - } else { - Ok(String::new()) - } - } - } - - fn recording_seam( - log: Arc>>, - ) -> impl Fn(&Path, &str, Option<&dyn Logger>) -> Result { - move |_root, payload, _logger| { - log.lock() - .expect("recording seam mutex") - .push(payload.to_string()); - Ok(String::new()) - } - } - - struct SeamGate { - entered: mpsc::Receiver<()>, - release: mpsc::Sender<()>, - } - - impl SeamGate { - fn wait_until_entered(&self) { - self.entered - .recv_timeout(Duration::from_secs(5)) - .expect("gated seam should be entered"); - } - - fn release(&self) { - let _ = self.release.send(()); - } - } - - #[allow(clippy::type_complexity)] - fn gated_seam( - operation: &'static str, - calls: Arc>>, - ) -> ( - impl Fn(&Path, &str, Option<&dyn Logger>) -> Result + Send, - SeamGate, - ) { - let (entered_tx, entered_rx) = mpsc::channel(); - let (release_tx, release_rx) = mpsc::channel(); - let release_rx = Mutex::new(release_rx); - let seam = move |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| { - calls - .lock() - .expect("gated seam mutex") - .push(payload.to_string()); - if payload.contains(&format!(r#""operation":"{operation}""#)) { - entered_tx.send(()).expect("gate entry signal"); - release_rx - .lock() - .expect("gate release mutex") - .recv() - .expect("gate release signal"); - } - Ok(String::new()) - }; - ( - seam, - SeamGate { - entered: entered_rx, - release: release_tx, - }, - ) - } - - fn fixed_resolver(git_dir: PathBuf) -> impl Fn(&str) -> Result + Send + Clone { - move |_cwd| Ok(git_dir.clone()) - } - - fn panicking_resolver(_cwd: &str) -> Result { - panic!("resolve_git_dir must not be called for a non-tracked tool") - } - - #[derive(Clone, Default)] - struct RecordingLogger { - warnings: Arc>>, - } - - impl RecordingLogger { - fn warnings(&self) -> Vec<(String, String)> { - self.warnings - .lock() - .expect("recording logger mutex must not be poisoned") - .clone() - } - } - - impl Logger for RecordingLogger { - fn info(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} - fn debug(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} - - fn warn(&self, event_id: &str, message: &str, _: &[(&str, &str)], _: Option<&str>) { - self.warnings - .lock() - .expect("recording logger mutex must not be poisoned") - .push((event_id.to_string(), message.to_string())); - } - - fn error(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} - - fn log_cli_error(&self, _: &crate::services::error::CliError, _: Option<&str>) {} - } - - fn tool_event_json(event_name: &str, overrides: &[(&str, Value)]) -> String { - let mut merged: Vec<(&str, Value)> = - vec![(HOOK_EVENT_NAME_FIELD, Value::String(event_name.to_string()))]; - merged.extend( - overrides - .iter() - .map(|(field, value)| (*field, value.clone())), - ); - pre_tool_use_json(&merged) - } - - fn post_tool_use_json(overrides: &[(&str, Value)]) -> String { - tool_event_json(HOOK_EVENT_POST_TOOL_USE, overrides) - } - - fn turn_scoped_payload(event_name: &str, session_id: &str, turn_id: &str) -> String { - json!({ - HOOK_EVENT_NAME_FIELD: event_name, - SESSION_ID_FIELD: session_id, - TURN_ID_FIELD: turn_id, - CWD_FIELD: CWD, - }) - .to_string() - } - - fn subagent_stop_payload(session_id: &str, turn_id: &str, agent_id: &str) -> String { - json!({ - HOOK_EVENT_NAME_FIELD: HOOK_EVENT_SUBAGENT_STOP, - SESSION_ID_FIELD: session_id, - TURN_ID_FIELD: turn_id, - CWD_FIELD: CWD, - AGENT_ID_FIELD: agent_id, - }) - .to_string() - } - - fn session_end_payload(session_id: &str) -> String { - json!({ - HOOK_EVENT_NAME_FIELD: HOOK_EVENT_SESSION_END, - SESSION_ID_FIELD: session_id, - CWD_FIELD: CWD, - }) - .to_string() - } - - fn read_state(git_dir: &Path) -> state::AdapterState { - state::read_state(git_dir).expect("adapter state should be readable") - } - - const DRIVER_TURN: &str = "turn-1"; - - fn seed_attempt( - git_dir: &Path, - session_id: &str, - agent_id: Option<&str>, - tool_use_id: &str, - phase: state::AttemptPhase, - ) -> state::AdapterAttempt { - seed_attempt_in_turn( - git_dir, - session_id, - DRIVER_TURN, - agent_id, - tool_use_id, - phase, - ) - } - - fn seed_attempt_in_turn( - git_dir: &Path, - session_id: &str, - turn_id: &str, - agent_id: Option<&str>, - tool_use_id: &str, - phase: state::AttemptPhase, - ) -> state::AdapterAttempt { - state::seed_attempt_for_tests( - git_dir, - &AttemptKey { - session_id: session_id.to_string(), - agent_id: agent_id.map(str::to_string), - tool_use_id: tool_use_id.to_string(), - }, - turn_id, - "Bash", - phase, - ) - } - - fn drive( - payload: &str, - resolver: &(impl Fn(&str) -> Result + ?Sized), - seam: &(impl Fn(&Path, &str, Option<&dyn Logger>) -> Result + ?Sized), - ) -> String { - run_codex_mutation_scope_from_payload_with(payload, None, &resolver, &seam) - .expect("driver should return Ok") - } - - #[test] - fn untracked_mcp_pre_tool_use_creates_no_scope_and_never_touches_seam_or_git_dir() { - let payload = pre_tool_use_json(&[( - TOOL_NAME_FIELD, - Value::String("mcp__probe__mutate_success".to_string()), - )]); - let output = drive(&payload, &panicking_resolver, &unreachable_seam); - assert_eq!(output, ""); - } - - #[test] - fn unknown_and_delegation_pre_tool_use_create_no_scope_ac3() { - for tool in [ - "some_future_codex_tool", - "collaborationspawn_agent", - "collaborationwait_agent", - ] { - let payload = - pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); - assert_eq!(drive(&payload, &panicking_resolver, &unreachable_seam), ""); - } - } - - #[test] - fn untracked_pre_tool_use_leaves_the_state_store_untouched_ac9b() { - let git_dir = unique_test_git_dir("untracked-state-untouched"); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - let resolver = fixed_resolver(git_dir.clone()); - - for tool in ["mcp__probe__mutate_success", "some_future_codex_tool"] { - let payload = - pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); - drive(&payload, &resolver, &ok_seam); - } - - assert!(read_state(&git_dir).attempts.is_empty()); - assert!(read_state(&git_dir).recovery.is_clear()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn tracked_pre_tool_use_writes_ahead_start_then_returns_continue_ac6() { - let git_dir = unique_test_git_dir("tracked-write-ahead"); - let resolver = fixed_resolver(git_dir.clone()); - - let seen: RefCell> = RefCell::new(Vec::new()); - let seam = - |root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { - let phase_is_pending = state::read_state(&git_dir) - .expect("state readable inside seam") - .attempts - .first() - .is_some_and(|attempt| attempt.phase == state::AttemptPhase::PendingStart); - seen.borrow_mut() - .push((payload.to_string(), root == Path::new(CWD))); - assert!( - phase_is_pending, - "AC6: Start driven while attempt is PendingStart" - ); - Ok(String::new()) - }; - - let output = drive(&pre_tool_use_json(&[]), &resolver, &seam); - assert_eq!(output, ""); - - let calls = seen.into_inner(); - assert_eq!(calls.len(), 1); - assert!(calls[0].0.contains(r#""operation":"start""#)); - assert!(calls[0].0.contains(r#""actor_kind":"codex""#)); - assert!( - calls[0].1, - "AC6: the seam receives the raw hook cwd as repository_root" - ); - - let final_state = read_state(&git_dir); - assert_eq!(final_state.attempts.len(), 1); - assert_eq!(final_state.attempts[0].phase, state::AttemptPhase::Active); - - remove_test_git_dir(&git_dir); - } - - fn boundary_payload_field(payload: &str, field: &str) -> Option { - let object: Map = - serde_json::from_str(payload).expect("a boundary payload is a JSON object"); - object.get(field).cloned() - } - - fn start_provenance(payload: &str) -> Value { - assert_eq!( - boundary_payload_field(payload, "operation"), - Some(Value::String("start".to_string())) - ); - boundary_payload_field(payload, PROVENANCE_FIELD) - .expect("a Codex start payload carries provenance") - } - - fn drive_recording_start(label: &str, payload: &str) -> Vec { - let git_dir = unique_test_git_dir(label); - let resolver = fixed_resolver(git_dir.clone()); - let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); - - drive(payload, &resolver, &recording_seam(Arc::clone(&recorded))); - - let calls = recorded.lock().expect("recording seam mutex").clone(); - remove_test_git_dir(&git_dir); - calls - } - - #[test] - fn ac3_tracked_start_carries_scope_provenance_for_both_tracked_tools() { - for tool in TRACKED_MUTATION_TOOL_NAMES { - let payload = pre_tool_use_json(&[ - (TOOL_NAME_FIELD, Value::String((*tool).to_string())), - (MODEL_FIELD, Value::String("gpt-5.6-sol".to_string())), - ]); - let calls = drive_recording_start(&format!("provenance-{tool}"), &payload); - - assert_eq!(calls.len(), 1, "{tool} should drive exactly one boundary"); - assert_eq!( - start_provenance(&calls[0]), - json!({ "session_id": "cx_session-1", "model_id": "gpt-5.6-sol" }), - "AC3: {tool} must carry its canonical session and normalized model" - ); - } - } - - #[test] - fn ac3_a_start_without_a_usable_model_still_carries_its_session() { - let cases: [(&str, Option); 4] = [ - ("absent", None), - ("null", Some(Value::Null)), - ("blank", Some(Value::String(" ".to_string()))), - ("non-string", Some(Value::Bool(true))), - ]; - - for (label, model) in cases { - let overrides = model.map_or_else(Vec::new, |value| vec![(MODEL_FIELD, value)]); - let payload = pre_tool_use_json(&overrides); - let calls = drive_recording_start(&format!("provenance-model-{label}"), &payload); - - assert_eq!(calls.len(), 1, "{label} should drive exactly one boundary"); - assert_eq!( - start_provenance(&calls[0]), - json!({ "session_id": "cx_session-1", "model_id": Value::Null }), - "AC3: a {label} model records no model without losing the session" - ); - } - } - - #[test] - fn ac3_only_the_start_boundary_carries_provenance() { - let git_dir = unique_test_git_dir("provenance-start-only"); - let resolver = fixed_resolver(git_dir.clone()); - let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); - let seam = recording_seam(Arc::clone(&recorded)); - - drive(&pre_tool_use_json(&[]), &resolver, &seam); - drive(&post_tool_use_json(&[]), &resolver, &seam); - - let calls = recorded.lock().expect("recording seam mutex").clone(); - assert_eq!(calls.len(), 2); - assert!(boundary_payload_field(&calls[0], PROVENANCE_FIELD).is_some()); - assert_eq!( - boundary_payload_field(&calls[1], "operation"), - Some(Value::String("close".to_string())) - ); - assert_eq!(boundary_payload_field(&calls[1], PROVENANCE_FIELD), None); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn duplicate_pre_tool_use_reuses_the_same_scope_id_ac4_test_e() { - let git_dir = unique_test_git_dir("duplicate-pre"); - let resolver = fixed_resolver(git_dir.clone()); - - drive(&pre_tool_use_json(&[]), &resolver, &ok_seam); - let scope_id = read_state(&git_dir).attempts[0].scope_id.clone(); - - drive(&pre_tool_use_json(&[]), &resolver, &ok_seam); - - let attempts = read_state(&git_dir).attempts; - assert_eq!( - attempts.len(), - 1, - "AC4/Test E: a replay must not fork a new attempt" - ); - assert_eq!(attempts[0].scope_id, scope_id); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn resolver_failure_denies_with_stable_reason_and_logs_the_detail_ac7() { - let logger = RecordingLogger::default(); - let resolver = |_: &str| -> Result { - Err(anyhow!("boom: git rev-parse --git-dir failed")) - }; - - let output = run_codex_mutation_scope_from_payload_with( - &pre_tool_use_json(&[]), - Some(&logger), - &resolver, - &unreachable_seam, - ) - .expect("a resolver failure must still return Ok with a deny payload"); - - assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); - assert!(!output.contains("boom")); - assert!(!output.contains("allow")); - - let warnings = logger.warnings(); - assert_eq!(warnings.len(), 1); - assert_eq!(warnings[0].0, PRE_TOOL_USE_FAIL_CLOSED_EVENT); - assert!(warnings[0].1.contains("boom")); - } - - #[test] - fn start_seam_failure_denies_and_leaves_the_pending_start_attempt_as_a_barrier_ac7() { - let git_dir = unique_test_git_dir("start-seam-failure"); - let resolver = fixed_resolver(git_dir.clone()); - let logger = RecordingLogger::default(); - let seam = seam_failing_on("start"); - - let output = run_codex_mutation_scope_from_payload_with( - &pre_tool_use_json(&[]), - Some(&logger), - &resolver, - &seam, - ) - .expect("a Start failure must still return Ok with a deny payload"); - - assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); - assert!(!logger.warnings().is_empty()); - - let final_state = read_state(&git_dir); - assert_eq!(final_state.attempts.len(), 1); - assert_eq!( - final_state.attempts[0].phase, - state::AttemptPhase::PendingStart - ); - - let successor = pre_tool_use_json(&[ - ( - TOOL_USE_ID_FIELD, - Value::String("exec-successor".to_string()), - ), - (TURN_ID_FIELD, Value::String("turn-2".to_string())), - ]); - assert_eq!( - run_codex_mutation_scope_from_payload_with( - &successor, - None, - &resolver, - &unreachable_seam, - ) - .expect("successor must return Ok with a deny payload"), - pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), - "I5: an unresolved PendingStart in another lane must block a successor Start", - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn delegation_and_untracked_pre_tool_use_are_never_fail_closed_ac7() { - let resolver = |_: &str| -> Result { Err(anyhow!("must not be called")) }; - for tool in [ - "mcp__probe__mutate_success", - "some_future_codex_tool", - "collaborationspawn_agent", - ] { - let payload = - pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); - assert_eq!( - run_codex_mutation_scope_from_payload_with( - &payload, - None, - &resolver, - &unreachable_seam, - ) - .expect("non-tracked PreToolUse should succeed"), - "", - ); - } - } - - #[test] - fn successful_close_removes_the_attempt_ac8() { - let git_dir = unique_test_git_dir("close-success"); - let resolver = fixed_resolver(git_dir.clone()); - - drive(&pre_tool_use_json(&[]), &resolver, &ok_seam); - - let seen: RefCell> = RefCell::new(Vec::new()); - let seam = - |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { - seen.borrow_mut().push(payload.to_string()); - Ok(String::new()) - }; - assert_eq!(drive(&post_tool_use_json(&[]), &resolver, &seam), ""); - - let calls = seen.into_inner(); - assert_eq!(calls.len(), 1); - assert!(calls[0].contains(r#""operation":"close""#)); - assert!(read_state(&git_dir).attempts.is_empty()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn pending_start_close_abandons_rather_than_late_starting_d11() { - let git_dir = unique_test_git_dir("pending-start-close"); - let resolver = fixed_resolver(git_dir.clone()); - seed_attempt( - &git_dir, - "session-1", - None, - "exec-1", - state::AttemptPhase::PendingStart, - ); - - let seen: RefCell> = RefCell::new(Vec::new()); - let seam = - |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { - seen.borrow_mut().push(payload.to_string()); - Ok(String::new()) - }; - drive(&post_tool_use_json(&[]), &resolver, &seam); - - let calls = seen.into_inner(); - assert_eq!(calls.len(), 1); - assert!(calls[0].contains(r#""operation":"abandon""#)); - - let final_state = read_state(&git_dir); - assert!(final_state.attempts.is_empty()); - assert!(!final_state.recovery.is_clear()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn failed_close_abandons_and_arms_recovery_ac13() { - let git_dir = unique_test_git_dir("failed-close"); - let resolver = fixed_resolver(git_dir.clone()); - - drive(&pre_tool_use_json(&[]), &resolver, &ok_seam); - - let seam = seam_failing_on("close"); - drive(&post_tool_use_json(&[]), &resolver, &seam); - - let final_state = read_state(&git_dir); - assert!(final_state.attempts.is_empty()); - assert!( - !final_state.recovery.is_clear(), - "D11: a failed Close arms recovery" - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn failed_close_and_failed_abandon_keep_the_attempt_tracked_and_recovery_armed_d11() { - let git_dir = unique_test_git_dir("failed-close-and-abandon"); - let resolver = fixed_resolver(git_dir.clone()); - - drive(&pre_tool_use_json(&[]), &resolver, &ok_seam); - - let seam = seam_failing_on_any(vec!["close", "abandon"]); - let error = run_codex_mutation_scope_from_payload_with( - &post_tool_use_json(&[]), - None, - &resolver, - &seam, - ) - .expect_err("a failed Close then failed Abandon must propagate"); - assert!(error.to_string().contains("abandon")); - - let final_state = read_state(&git_dir); - assert_eq!(final_state.attempts.len(), 1); - assert!(!final_state.recovery.is_clear()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn post_tool_use_with_no_matching_attempt_is_a_noop() { - let git_dir = unique_test_git_dir("close-no-attempt"); - let resolver = fixed_resolver(git_dir.clone()); - - assert_eq!( - drive( - &post_tool_use_json(&[(TOOL_NAME_FIELD, Value::String("Bash".to_string()),)]), - &resolver, - &unreachable_seam, - ), - "", - ); - assert!(read_state(&git_dir).attempts.is_empty()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn stop_sweeps_only_main_thread_attempts_d12() { - let git_dir = unique_test_git_dir("stop-sweep"); - let resolver = fixed_resolver(git_dir.clone()); - seed_attempt( - &git_dir, - "session-1", - None, - "exec-main", - state::AttemptPhase::Active, - ); - seed_attempt( - &git_dir, - "session-1", - Some("agent-1"), - "exec-agent", - state::AttemptPhase::Active, - ); - - drive( - &turn_scoped_payload(HOOK_EVENT_STOP, "session-1", "turn-1"), - &resolver, - &ok_seam, - ); - - let attempts = read_state(&git_dir).attempts; - assert_eq!(attempts.len(), 1); - assert_eq!(attempts[0].tool_use_id, "exec-agent"); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn interrupt_sweeps_every_attempt_for_the_session_d12() { - let git_dir = unique_test_git_dir("interrupt-sweep"); - let resolver = fixed_resolver(git_dir.clone()); - seed_attempt( - &git_dir, - "session-1", - None, - "exec-main", - state::AttemptPhase::Active, - ); - seed_attempt( - &git_dir, - "session-1", - Some("agent-1"), - "exec-agent", - state::AttemptPhase::Active, - ); - seed_attempt( - &git_dir, - "session-2", - None, - "exec-other", - state::AttemptPhase::Active, - ); - - drive( - &turn_scoped_payload(HOOK_EVENT_INTERRUPT, "session-1", "turn-1"), - &resolver, - &ok_seam, - ); - - let attempts = read_state(&git_dir).attempts; - assert_eq!(attempts.len(), 1); - assert_eq!(attempts[0].session_id, "session-2"); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn subagent_stop_sweeps_only_the_matching_agent_d12() { - let git_dir = unique_test_git_dir("subagent-stop-sweep"); - let resolver = fixed_resolver(git_dir.clone()); - seed_attempt( - &git_dir, - "session-1", - Some("agent-a"), - "exec-a", - state::AttemptPhase::Active, - ); - seed_attempt( - &git_dir, - "session-1", - Some("agent-b"), - "exec-b", - state::AttemptPhase::Active, - ); - seed_attempt( - &git_dir, - "session-1", - None, - "exec-main", - state::AttemptPhase::Active, - ); - - drive( - &subagent_stop_payload("session-1", "turn-1", "agent-a"), - &resolver, - &ok_seam, - ); - - let mut remaining: Vec = read_state(&git_dir) - .attempts - .into_iter() - .map(|attempt| attempt.tool_use_id) - .collect(); - remaining.sort(); - assert_eq!( - remaining, - vec!["exec-b".to_string(), "exec-main".to_string()] - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn session_end_sweeps_every_attempt_for_the_session_d12() { - let git_dir = unique_test_git_dir("session-end-sweep"); - let resolver = fixed_resolver(git_dir.clone()); - seed_attempt( - &git_dir, - "session-1", - None, - "exec-main", - state::AttemptPhase::Active, - ); - seed_attempt( - &git_dir, - "session-1", - Some("agent-1"), - "exec-agent", - state::AttemptPhase::Active, - ); - - drive(&session_end_payload("session-1"), &resolver, &ok_seam); - - assert!(read_state(&git_dir).attempts.is_empty()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn lifecycle_cleanup_with_a_failed_abandon_keeps_the_attempt_tracked_d12() { - let git_dir = unique_test_git_dir("sweep-failed-abandon"); - let resolver = fixed_resolver(git_dir.clone()); - seed_attempt( - &git_dir, - "session-1", - None, - "exec-main", - state::AttemptPhase::Active, - ); - - let seam = seam_failing_on("abandon"); - let error = run_codex_mutation_scope_from_payload_with( - &session_end_payload("session-1"), - None, - &resolver, - &seam, - ) - .expect_err("a failed abandonment during cleanup must propagate"); - assert!(error.to_string().contains("abandon")); - - let final_state = read_state(&git_dir); - assert_eq!(final_state.attempts.len(), 1); - assert!(!final_state.recovery.is_clear()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn pending_non_empty_recovery_denies_unrelated_admission_without_losing_the_recovery_path() - { - use crate::services::hooks::mutation_scope_health::MutationScopeHealthStatus; - - let git_dir = unique_test_git_dir("health-recovering-unrelated-denied"); - let resolver = fixed_resolver(git_dir.clone()); - seed_attempt( - &git_dir, - "session-1", - None, - "exec-main", - state::AttemptPhase::Active, - ); - - let seam = seam_failing_on("abandon"); - let error = run_codex_mutation_scope_from_payload_with( - &session_end_payload("session-1"), - None, - &resolver, - &seam, - ) - .expect_err("a failed abandonment during cleanup must propagate"); - assert!(error.to_string().contains("abandon")); - - assert_eq!( - health::classify_health(&git_dir).status, - MutationScopeHealthStatus::Recovering, - "a stuck non-empty attempt with recovery armed is Recovering: unrelated \ - admission stays fail-closed, but a same-lane successor can still retry \ - the stale predecessor's abandonment" - ); - - for attempt_number in 1..=2 { - let output = drive( - &pre_tool_use_json(&[ - (SESSION_ID_FIELD, Value::String("session-2".to_string())), - (TURN_ID_FIELD, Value::String("turn-2".to_string())), - ( - TOOL_USE_ID_FIELD, - Value::String("exec-unrelated".to_string()), - ), - ]), - &resolver, - &unreachable_seam, - ); - assert_eq!( - output, - pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), - "PreToolUse call #{attempt_number} for an unrelated session must still be denied" - ); - } - - assert_eq!( - health::classify_health(&git_dir).status, - MutationScopeHealthStatus::Recovering, - "the classifier must still report Recovering after repeated denial from an \ - unrelated session; Recovering does not mean every future call succeeds, only \ - that a proven normal self-healing route exists" - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn same_lane_successor_retries_abandon_and_reaches_healthy_after_a_failed_lifecycle_abandon_ac4( - ) { - use crate::services::hooks::mutation_scope_health::MutationScopeHealthStatus; - - let git_dir = unique_test_git_dir("health-same-lane-self-heal"); - let resolver = fixed_resolver(git_dir.clone()); - - seed_attempt_in_turn( - &git_dir, - "session-1", - "turn-1", - None, - "exec-a", - state::AttemptPhase::Active, - ); - - let failing_abandon_seam = seam_failing_on("abandon"); - let output_b = drive( - &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-b".to_string()))]), - &resolver, - &failing_abandon_seam, - ); - assert_eq!( - output_b, - pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), - "B fails closed when the same-lane sweep's retried abandon of A fails" - ); - - let after_b = read_state(&git_dir); - assert_eq!( - after_b.attempts.len(), - 1, - "A remains persisted after the failed same-lane sweep" - ); - assert_eq!(after_b.attempts[0].tool_use_id, "exec-a"); - assert!(!after_b.recovery.is_clear()); - - assert_eq!( - health::classify_health(&git_dir).status, - MutationScopeHealthStatus::Recovering - ); - - let seam_calls = Arc::new(Mutex::new(Vec::new())); - let recording = recording_seam(Arc::clone(&seam_calls)); - let output_c = drive( - &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-c".to_string()))]), - &resolver, - &recording, - ); - assert_eq!( - output_c, "", - "C's tracked Start proceeds once the same-lane sweep clears A and the \ - quiescent flush completes" - ); - - let operations: Vec = seam_calls - .lock() - .expect("recording seam mutex") - .iter() - .filter_map(|payload| { - serde_json::from_str::(payload) - .ok() - .and_then(|value| { - value - .get("operation") - .and_then(Value::as_str) - .map(str::to_string) - }) - }) - .collect(); - let abandon_index = operations - .iter() - .position(|operation| operation == "abandon"); - let flush_index = operations.iter().position(|operation| operation == "flush"); - let start_index = operations.iter().position(|operation| operation == "start"); - assert!( - abandon_index.is_some() && flush_index.is_some() && start_index.is_some(), - "expected abandon(A), flush, and start(C) seam calls, got {operations:?}" - ); - assert!( - abandon_index < flush_index && flush_index < start_index, - "expected abandon(A) -> flush -> start(C) ordering, got {operations:?}" - ); - - let final_state = read_state(&git_dir); - assert!(final_state.recovery.is_clear()); - assert_eq!(final_state.attempts.len(), 1); - assert_eq!(final_state.attempts[0].tool_use_id, "exec-c"); - - assert_eq!( - health::classify_health(&git_dir).status, - MutationScopeHealthStatus::Healthy - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn health_classifies_recovering_then_healthy_once_the_next_pre_tool_use_flushes_ac4() { - use crate::services::hooks::mutation_scope_health::MutationScopeHealthStatus; - - let git_dir = unique_test_git_dir("health-recovering-then-healthy"); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - let resolver = fixed_resolver(git_dir.clone()); - state::arm_recovery(&git_dir).expect("arming the barrier should succeed"); - - assert_eq!( - health::classify_health(&git_dir).status, - MutationScopeHealthStatus::Recovering - ); - - let output = drive( - &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-new".to_string()))]), - &resolver, - &ok_seam, - ); - assert_eq!(output, ""); - - assert_eq!( - health::classify_health(&git_dir).status, - MutationScopeHealthStatus::Healthy, - "a successful flush clears recovery and returns the adapter to Healthy" - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn recovery_barrier_denies_new_tracked_pre_tool_use_while_attempts_remain_ac12() { - let git_dir = unique_test_git_dir("barrier-attempts-remain"); - let resolver = fixed_resolver(git_dir.clone()); - seed_attempt_in_turn( - &git_dir, - "session-1", - "turn-other", - None, - "exec-live", - state::AttemptPhase::Active, - ); - state::arm_recovery(&git_dir).expect("arming the barrier should succeed"); - - let output = run_codex_mutation_scope_from_payload_with( - &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-new".to_string()))]), - None, - &resolver, - &unreachable_seam, - ) - .expect("the barrier denial still returns Ok with a deny payload"); - - assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn recovery_barrier_does_not_affect_untracked_pre_tool_use_ac12_test_f() { - let git_dir = unique_test_git_dir("barrier-untracked-unaffected"); - let resolver = fixed_resolver(git_dir.clone()); - seed_attempt( - &git_dir, - "session-1", - None, - "exec-live", - state::AttemptPhase::Active, - ); - state::arm_recovery(&git_dir).expect("arming the barrier should succeed"); - - for tool in [ - "mcp__probe__mutate_success", - "some_future_codex_tool", - "collaborationspawn_agent", - ] { - let payload = - pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); - assert_eq!( - run_codex_mutation_scope_from_payload_with( - &payload, - None, - &resolver, - &unreachable_seam, - ) - .expect("an untracked PreToolUse ignores the barrier"), - "", - "Test F: recovery must never deny an untracked tool", - ); - } - - remove_test_git_dir(&git_dir); - } - - #[test] - fn recovery_barrier_flushes_once_quiescent_then_starts_ac12() { - let git_dir = unique_test_git_dir("barrier-flush-success"); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - let resolver = fixed_resolver(git_dir.clone()); - state::arm_recovery(&git_dir).expect("arming the barrier should succeed"); - - let seen: RefCell> = RefCell::new(Vec::new()); - let seam = - |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { - seen.borrow_mut().push(payload.to_string()); - Ok(String::new()) - }; - - let output = drive( - &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-new".to_string()))]), - &resolver, - &seam, - ); - assert_eq!(output, ""); - - let operations = seen.into_inner(); - assert_eq!( - operations.len(), - 2, - "expected flush then start, got {operations:?}" - ); - assert!(operations[0].contains(r#""operation":"flush""#)); - assert!(operations[1].contains(r#""operation":"start""#)); - - let final_state = read_state(&git_dir); - assert!( - final_state.recovery.is_clear(), - "a successful flush clears the barrier" - ); - assert_eq!(final_state.attempts.len(), 1); - assert_eq!(final_state.attempts[0].phase, state::AttemptPhase::Active); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn recovery_barrier_stays_closed_when_flush_fails_ac12() { - let git_dir = unique_test_git_dir("barrier-flush-failure"); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - let resolver = fixed_resolver(git_dir.clone()); - let generation = - state::arm_recovery(&git_dir).expect("arming the barrier should succeed"); - - let seam = seam_failing_on("flush"); - let output = run_codex_mutation_scope_from_payload_with( - &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-new".to_string()))]), - None, - &resolver, - &seam, - ) - .expect("a failed flush still returns Ok with a deny payload"); - - assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); - let final_state = read_state(&git_dir); - assert_eq!( - final_state.recovery, - state::RecoveryState::Pending { generation }, - "a failed flush hands the generation back as Pending so a later PreToolUse retries", - ); - assert!(final_state.attempts.is_empty()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn mcp_mutate_then_error_leaves_no_stale_state_ac9c() { - let git_dir = unique_test_git_dir("mcp-mutate-then-error"); - let resolver = fixed_resolver(git_dir.clone()); - - let mcp_pre = pre_tool_use_json(&[ - ( - TOOL_NAME_FIELD, - Value::String("mcp__probe__mutate_then_error".to_string()), - ), - (TOOL_USE_ID_FIELD, Value::String("exec-mcp".to_string())), - ]); - drive(&mcp_pre, &resolver, &unreachable_seam); - assert!(read_state(&git_dir).attempts.is_empty()); - - drive( - &turn_scoped_payload(HOOK_EVENT_STOP, "session-1", "turn-1"), - &resolver, - &ok_seam, - ); - drive(&session_end_payload("session-1"), &resolver, &ok_seam); - - let final_state = read_state(&git_dir); - assert!(final_state.attempts.is_empty()); - assert!( - final_state.recovery.is_clear(), - "AC9c: no Start => no abandon => recovery stays clear" - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn failed_mcp_then_tracked_successor_starts_clean_ac9d() { - let git_dir = unique_test_git_dir("mcp-then-tracked"); - let resolver = fixed_resolver(git_dir.clone()); - - let mcp_a = pre_tool_use_json(&[ - ( - TOOL_NAME_FIELD, - Value::String("mcp__probe__mutate_then_error".to_string()), - ), - (TOOL_USE_ID_FIELD, Value::String("exec-a".to_string())), - ]); - let bash_b = pre_tool_use_json(&[ - (TOOL_NAME_FIELD, Value::String("Bash".to_string())), - (TOOL_USE_ID_FIELD, Value::String("exec-b".to_string())), - ]); - - drive(&mcp_a, &resolver, &unreachable_seam); - drive(&bash_b, &resolver, &ok_seam); - - let attempts = read_state(&git_dir).attempts; - assert_eq!(attempts.len(), 1); - assert_eq!(attempts[0].tool_use_id, "exec-b"); - assert_eq!(attempts[0].phase, state::AttemptPhase::Active); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn parallel_mcp_executions_create_no_scopes_ac9e() { - let git_dir = unique_test_git_dir("parallel-mcp"); - let resolver = fixed_resolver(git_dir.clone()); - - for tool_use_id in ["exec-par-a", "exec-par-b"] { - let payload = pre_tool_use_json(&[ - ( - TOOL_NAME_FIELD, - Value::String("mcp__probe_par__slow_mutate".to_string()), - ), - (TOOL_USE_ID_FIELD, Value::String(tool_use_id.to_string())), - ]); - drive(&payload, &resolver, &unreachable_seam); - assert!(read_state(&git_dir).attempts.is_empty()); - } - - let final_state = read_state(&git_dir); - assert!(final_state.attempts.is_empty()); - assert!(final_state.recovery.is_clear()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn builtin_failed_a_then_b_never_leaves_a_zombie_scope_ac9a() { - let git_dir = unique_test_git_dir("builtin-failed-a-then-b"); - let resolver = fixed_resolver(git_dir.clone()); - - let predecessor_pre = - pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-a".to_string()))]); - let predecessor_post = - post_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-a".to_string()))]); - let successor_pre = - pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-b".to_string()))]); - - drive(&predecessor_pre, &resolver, &ok_seam); - drive(&predecessor_post, &resolver, &ok_seam); - drive(&successor_pre, &resolver, &ok_seam); - - let attempts = read_state(&git_dir).attempts; - assert_eq!(attempts.len(), 1); - assert_eq!(attempts[0].tool_use_id, "exec-b"); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn malformed_payload_propagates_as_a_real_error_not_fail_open() { - let error = run_codex_mutation_scope_from_payload("not json", None).unwrap_err(); - assert!(error.to_string().contains("valid JSON")); - } - - #[test] - fn unsupported_event_name_propagates_as_a_real_error() { - let payload = json!({ - HOOK_EVENT_NAME_FIELD: "UserPromptSubmit", - SESSION_ID_FIELD: "session-1", - CWD_FIELD: CWD, - }) - .to_string(); - let error = run_codex_mutation_scope_from_payload(&payload, None).unwrap_err(); - assert!(error.to_string().contains("unsupported hook_event_name")); - } - - fn spawn_pre_tool_use( - git_dir: &Path, - tool_use_id: &'static str, - seam: impl Fn(&Path, &str, Option<&dyn Logger>) -> Result + Send + 'static, - ) -> (thread::JoinHandle, mpsc::Receiver<()>) { - spawn_pre_tool_use_in_turn(git_dir, tool_use_id, DRIVER_TURN, seam) - } - - fn spawn_pre_tool_use_in_turn( - git_dir: &Path, - tool_use_id: &'static str, - turn_id: &'static str, - seam: impl Fn(&Path, &str, Option<&dyn Logger>) -> Result + Send + 'static, - ) -> (thread::JoinHandle, mpsc::Receiver<()>) { - let (done_tx, done_rx) = mpsc::channel(); - let resolver = fixed_resolver(git_dir.to_path_buf()); - let handle = thread::spawn(move || { - let output = run_codex_mutation_scope_from_payload_with( - &pre_tool_use_json(&[ - (TOOL_USE_ID_FIELD, Value::String(tool_use_id.to_string())), - (TURN_ID_FIELD, Value::String(turn_id.to_string())), - ]), - None, - &resolver, - &seam, - ) - .expect("PreToolUse should return Ok"); - let _ = done_tx.send(()); - output - }); - (handle, done_rx) - } - - fn assert_still_blocked(done_rx: &mpsc::Receiver<()>, context: &str) { - assert!( - done_rx.recv_timeout(Duration::from_millis(250)).is_err(), - "{context}: the operation must still be blocked on the boundary lock", - ); - } - - fn first_index_of(recorded: &[String], operation: &str) -> Option { - recorded - .iter() - .position(|payload| payload.contains(&format!(r#""operation":"{operation}""#))) - } - - #[test] - fn test_h_cleanup_owning_the_boundary_lock_blocks_admission_until_recovery_is_processed() { - let git_dir = unique_test_git_dir("test-h-cleanup-owns-boundary"); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - seed_attempt( - &git_dir, - "session-1", - None, - "exec-a", - state::AttemptPhase::Active, - ); - - let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); - let (abandon_seam, gate) = gated_seam("abandon", Arc::clone(&recorded)); - - let sweeper = { - let resolver = fixed_resolver(git_dir.clone()); - thread::spawn(move || { - run_codex_mutation_scope_from_payload_with( - &session_end_payload("session-1"), - None, - &resolver, - &abandon_seam, - ) - .expect("SessionEnd cleanup should succeed") - }) - }; - - gate.wait_until_entered(); - assert_eq!( - read_state(&git_dir).recovery, - state::RecoveryState::Pending { generation: 1 }, - "cleanup arms recovery while it owns the boundary lock", - ); - - let (b_handle, b_done) = - spawn_pre_tool_use(&git_dir, "exec-b", recording_seam(Arc::clone(&recorded))); - assert_still_blocked(&b_done, "Test H"); - assert!( - first_index_of(&recorded.lock().unwrap(), "start").is_none(), - "Test H: B must not reach Start while cleanup owns the boundary lock", - ); - - gate.release(); - sweeper.join().expect("sweeper thread should not panic"); - - let b_output = b_handle.join().expect("B thread should not panic"); - assert_eq!( - b_output, "", - "Test H: once recovery is processed B proceeds" - ); - - let recorded = recorded.lock().unwrap().clone(); - let abandon_at = - first_index_of(&recorded, "abandon").expect("cleanup abandoned exec-a"); - let flush_at = first_index_of(&recorded, "flush").expect("B drove the quiescent flush"); - let start_at = first_index_of(&recorded, "start").expect("B reached Start"); - assert!( - abandon_at < flush_at && flush_at < start_at, - "Test H: the serialized order must be abandon -> flush -> start, got {recorded:?}", - ); - - let final_state = read_state(&git_dir); - assert!(final_state.recovery.is_clear()); - assert_eq!(final_state.attempts.len(), 1); - assert_eq!(final_state.attempts[0].tool_use_id, "exec-b"); - assert_eq!(final_state.attempts[0].phase, state::AttemptPhase::Active); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn test_g_admission_completed_recovery_cannot_arm_before_start() { - let git_dir = unique_test_git_dir("test-g-admit-before-start"); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - - let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); - let (start_seam, gate) = gated_seam("start", Arc::clone(&recorded)); - - let p1 = { - let resolver = fixed_resolver(git_dir.clone()); - thread::spawn(move || { - run_codex_mutation_scope_from_payload_with( - &pre_tool_use_json(&[( - TOOL_USE_ID_FIELD, - Value::String("exec-b".to_string()), - )]), - None, - &resolver, - &start_seam, - ) - .expect("P1 PreToolUse should return Ok") - }) - }; - - gate.wait_until_entered(); - let mid = read_state(&git_dir); - assert_eq!(mid.attempts.len(), 1); - assert_eq!(mid.attempts[0].phase, state::AttemptPhase::PendingStart); - assert!( - mid.recovery.is_clear(), - "recovery must still be Clear while P1 holds the boundary lock pre-Start", - ); - - let (p2_handle, p2_done) = spawn_pre_tool_use( - &git_dir, - "exec-cleanup-trigger", - recording_seam(Arc::clone(&recorded)), - ); - - let sweeper = { - let resolver = fixed_resolver(git_dir.clone()); - let recorded = Arc::clone(&recorded); - thread::spawn(move || { - let seam = recording_seam(recorded); - run_codex_mutation_scope_from_payload_with( - &session_end_payload("session-1"), - None, - &resolver, - &seam, - ) - .expect("SessionEnd cleanup should return Ok") - }) - }; - - assert_still_blocked(&p2_done, "Test G"); - assert!( - read_state(&git_dir).recovery.is_clear(), - "Test G: no concurrent process may arm recovery between admit(B) and Start(B)", - ); - - gate.release(); - assert_eq!(p1.join().expect("P1 should not panic"), ""); - sweeper.join().expect("sweeper should not panic"); - p2_handle.join().expect("P2 should not panic"); - - let recorded = recorded.lock().unwrap().clone(); - let start_at = first_index_of(&recorded, "start").expect("P1 drove Start(B)"); - if let Some(abandon_at) = first_index_of(&recorded, "abandon") { - assert!( - start_at < abandon_at, - "Test G: Start(B) must be serialized before any later abandon, got {recorded:?}", - ); - } - - remove_test_git_dir(&git_dir); - } - - #[test] - fn test_j_a_live_flush_owner_is_never_reclaimed_by_a_blocked_process() { - let git_dir = unique_test_git_dir("test-j-live-flush-owner"); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - state::arm_recovery(&git_dir).expect("arm recovery"); - - let flush_count = Arc::new(AtomicUsize::new(0)); - let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); - let (owner_gated, gate) = gated_seam("flush", Arc::clone(&recorded)); - - let owner = { - let resolver = fixed_resolver(git_dir.clone()); - let flush_count = Arc::clone(&flush_count); - thread::spawn(move || { - let seam = move |root: &Path, - payload: &str, - logger: Option<&dyn Logger>| - -> Result { - if payload.contains(r#""operation":"flush""#) { - flush_count.fetch_add(1, Ordering::SeqCst); - } - owner_gated(root, payload, logger) - }; - run_codex_mutation_scope_from_payload_with( - &pre_tool_use_json(&[( - TOOL_USE_ID_FIELD, - Value::String("exec-owner".to_string()), - )]), - None, - &resolver, - &seam, - ) - .expect("owner PreToolUse should return Ok") - }) - }; - - gate.wait_until_entered(); - assert_eq!( - read_state(&git_dir).recovery, - state::RecoveryState::Flushing { generation: 1 }, - ); - - let flush_count_p2 = Arc::clone(&flush_count); - let (p2_handle, p2_done) = - spawn_pre_tool_use_in_turn(&git_dir, "exec-2", "turn-2", move |_r, payload, _l| { - if payload.contains(r#""operation":"flush""#) { - flush_count_p2.fetch_add(1, Ordering::SeqCst); - } - Ok(String::new()) - }); - - assert_still_blocked(&p2_done, "Test J"); - assert_eq!( - read_state(&git_dir).recovery, - state::RecoveryState::Flushing { generation: 1 }, - "Test J: a blocked process must not reclaim the live owner's Flushing(g)", - ); - - gate.release(); - assert_eq!(owner.join().expect("owner should not panic"), ""); - assert_eq!(p2_handle.join().expect("P2 should not panic"), ""); - - assert_eq!( - flush_count.load(Ordering::SeqCst), - 1, - "Test J: exactly one Flush ran — the live owner's, never a reclaim", - ); - let final_state = read_state(&git_dir); - assert!(final_state.recovery.is_clear()); - assert_eq!(final_state.attempts.len(), 2); - assert!(final_state - .attempts - .iter() - .all(|a| a.phase == state::AttemptPhase::Active)); - - remove_test_git_dir(&git_dir); - } - - fn seed_orphaned_flushing(git_dir: &Path) -> u64 { - let generation = state::arm_recovery(git_dir).expect("arm recovery to seed"); - match state::admit_tracked_attempt( - git_dir, - &key("seed", None, "seed"), - "seed-turn", - "Bash", - ) - .expect("seeding admit should not error") - { - state::AdmitDecision::FlushClaimed { - generation: claimed, - } => { - assert_eq!(claimed, generation); - } - other => panic!("expected FlushClaimed while seeding, got {other:?}"), - } - assert_eq!( - read_state(git_dir).recovery, - state::RecoveryState::Flushing { generation }, - "seed left durable Flushing(g) with no live boundary-lock owner", - ); - generation - } - - #[test] - fn test_i_orphaned_flushing_is_reclaimed_and_flush_is_retried_once() { - let git_dir = unique_test_git_dir("test-i-orphaned-flushing"); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - let generation = seed_orphaned_flushing(&git_dir); - let next_generation_before = read_state(&git_dir).next_recovery_generation; - - let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); - let resolver = fixed_resolver(git_dir.clone()); - let output = drive( - &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-x".to_string()))]), - &resolver, - &recording_seam(Arc::clone(&recorded)), - ); - assert_eq!(output, ""); - - let ops = recorded.lock().unwrap().clone(); - assert_eq!( - ops.iter() - .filter(|p| p.contains(r#""operation":"flush""#)) - .count(), - 1, - "Test I: exactly one retry Flush for the reclaimed generation, got {ops:?}", - ); - assert!( - first_index_of(&ops, "flush").unwrap() < first_index_of(&ops, "start").unwrap() - ); - - let final_state = read_state(&git_dir); - assert!( - final_state.recovery.is_clear(), - "Test I: no permanent RecoveryBlocked" - ); - assert_eq!( - final_state.next_recovery_generation, next_generation_before, - "Test I: reclaiming Flushing(g) preserves the generation, never bumps it", - ); - assert_eq!(final_state.attempts.len(), 1); - assert_eq!(final_state.attempts[0].tool_use_id, "exec-x"); - assert_eq!(final_state.attempts[0].phase, state::AttemptPhase::Active); - let _ = generation; - - remove_test_git_dir(&git_dir); - } - - #[test] - fn test_k_crash_after_durable_flush_before_completion_write_converges() { - let git_dir = unique_test_git_dir("test-k-crash-after-flush"); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - seed_orphaned_flushing(&git_dir); - - let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); - let resolver = fixed_resolver(git_dir.clone()); - - let first = drive( - &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-1".to_string()))]), - &resolver, - &recording_seam(Arc::clone(&recorded)), - ); - assert_eq!(first, ""); - assert!(read_state(&git_dir).recovery.is_clear()); - - let second = drive( - &pre_tool_use_json(&[ - (TOOL_USE_ID_FIELD, Value::String("exec-2".to_string())), - (TURN_ID_FIELD, Value::String("turn-2".to_string())), - ]), - &resolver, - &recording_seam(Arc::clone(&recorded)), - ); - assert_eq!(second, ""); - - let ops = recorded.lock().unwrap().clone(); - assert_eq!( - ops.iter() - .filter(|p| p.contains(r#""operation":"flush""#)) - .count(), - 1, - "Test K: the recovery retry Flush runs exactly once across convergence, got {ops:?}", - ); - let final_state = read_state(&git_dir); - assert!(final_state.recovery.is_clear()); - assert_eq!(final_state.attempts.len(), 2); - assert!(final_state - .attempts - .iter() - .all(|a| a.phase == state::AttemptPhase::Active)); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn test_l_duplicate_active_delivery_drives_no_second_start() { - let git_dir = unique_test_git_dir("test-l-duplicate-active"); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - let resolver = fixed_resolver(git_dir.clone()); - let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); - - let first = drive( - &pre_tool_use_json(&[]), - &resolver, - &recording_seam(Arc::clone(&recorded)), - ); - assert_eq!(first, ""); - let scope_id = read_state(&git_dir).attempts[0].scope_id.clone(); - assert_eq!( - read_state(&git_dir).attempts[0].phase, - state::AttemptPhase::Active - ); - - let duplicate = drive( - &pre_tool_use_json(&[]), - &resolver, - &recording_seam(Arc::clone(&recorded)), - ); - assert_eq!(duplicate, ""); - - let ops = recorded.lock().unwrap().clone(); - assert_eq!( - ops.iter() - .filter(|p| p.contains(r#""operation":"start""#)) - .count(), - 1, - "Test L: duplicate delivery of an Active execution drives no second Start, got {ops:?}", - ); - let attempts = read_state(&git_dir).attempts; - assert_eq!(attempts.len(), 1); - assert_eq!(attempts[0].scope_id, scope_id); - assert_eq!(read_state(&git_dir).next_attempt_seq, 2); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn test_m_untracked_tools_never_touch_the_boundary_lock() { - let git_dir = unique_test_git_dir("test-m-untracked-no-boundary"); - let resolver = fixed_resolver(git_dir.clone()); - - for tool in [ - "mcp__probe__mutate_success", - "some_future_codex_tool", - "collaborationspawn_agent", - "collaborationwait_agent", - ] { - let payload = - pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); - assert_eq!( - run_codex_mutation_scope_from_payload_with( - &payload, - None, - &panicking_resolver, - &unreachable_seam, - ) - .expect("an untracked PreToolUse is neutral"), - "", - ); - assert_eq!(drive(&payload, &resolver, &unreachable_seam), ""); - } - - assert!( - !crate::services::hooks::codex_mutation_scope::boundary_lock::boundary_lock_path( - &git_dir - ) - .exists(), - "Test M: no untracked tool may create the adapter boundary lock", - ); - assert!( - !state::adapter_state_dir(&git_dir).exists(), - "Test M: an untracked tool resolves no git dir and touches no adapter state", - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn untracked_post_tool_use_never_touches_mutation_scope_machinery() { - let git_dir = unique_test_git_dir("untracked-post-no-footprint"); - let resolver = fixed_resolver(git_dir.clone()); - - for tool in [ - "mcp__probe__mutate_success", - "some_future_codex_tool", - "collaborationspawn_agent", - "collaborationwait_agent", - ] { - let payload = - post_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); - - assert_eq!( - run_codex_mutation_scope_from_payload_with( - &payload, - None, - &panicking_resolver, - &unreachable_seam, - ) - .expect("an untracked PostToolUse is neutral"), - "", - "untracked PostToolUse for {tool:?} must return neutral", - ); - assert_eq!( - drive(&payload, &resolver, &unreachable_seam), - "", - "untracked PostToolUse for {tool:?} must not call the ingress seam", - ); - } - - assert!( - !state::adapter_state_dir(&git_dir).exists(), - "an untracked PostToolUse resolves no git dir and creates no adapter state directory", - ); - assert!( - !crate::services::hooks::codex_mutation_scope::boundary_lock::boundary_lock_path( - &git_dir - ) - .exists(), - "an untracked PostToolUse must not create the adapter boundary lock", - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn a_complete_successful_mcp_lifecycle_leaves_zero_adapter_footprint() { - let git_dir = unique_test_git_dir("mcp-lifecycle-no-footprint"); - let resolver = fixed_resolver(git_dir.clone()); - - let mcp = &[( - TOOL_NAME_FIELD, - Value::String("mcp__probe__mutate_success".to_string()), - )]; - - assert_eq!( - run_codex_mutation_scope_from_payload_with( - &pre_tool_use_json(mcp), - None, - &panicking_resolver, - &unreachable_seam, - ) - .expect("MCP PreToolUse is neutral"), - "", - ); - assert_eq!( - run_codex_mutation_scope_from_payload_with( - &post_tool_use_json(mcp), - None, - &panicking_resolver, - &unreachable_seam, - ) - .expect("MCP PostToolUse is neutral"), - "", - ); - - assert_eq!( - drive(&pre_tool_use_json(mcp), &resolver, &unreachable_seam), - "" - ); - assert_eq!( - drive(&post_tool_use_json(mcp), &resolver, &unreachable_seam), - "" - ); - - let state = read_state(&git_dir); - assert!(state.attempts.is_empty(), "no attempts recorded"); - assert!(state.recovery.is_clear(), "recovery stays Clear"); - - assert!( - !state::adapter_state_dir(&git_dir).exists(), - "a complete successful MCP lifecycle creates no adapter state directory, \ - state lock, or boundary lock", - ); - assert!( - !crate::services::hooks::codex_mutation_scope::boundary_lock::boundary_lock_path( - &git_dir - ) - .exists(), - "a complete successful MCP lifecycle creates no boundary lock", - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn test_c_recovery_rearmed_while_flush_in_flight_survives_the_stale_completion() { - let git_dir = unique_test_git_dir("race-rearm-during-flush"); - std::fs::create_dir_all(&git_dir).expect("git dir should be created"); - state::arm_recovery(&git_dir).expect("arm g1"); - - let calls: Arc>> = Arc::new(Mutex::new(Vec::new())); - let (flush_seam, gate) = gated_seam("flush", Arc::clone(&calls)); - - let flusher = { - let git_dir = git_dir.clone(); - let resolver = fixed_resolver(git_dir.clone()); - thread::spawn(move || { - run_codex_mutation_scope_from_payload_with( - &pre_tool_use_json(&[( - TOOL_USE_ID_FIELD, - Value::String("exec-flusher".to_string()), - )]), - None, - &resolver, - &flush_seam, - ) - .expect("flusher PreToolUse should return Ok") - }) - }; - - gate.wait_until_entered(); - assert_eq!( - read_state(&git_dir).recovery, - state::RecoveryState::Flushing { generation: 1 }, - ); - - let second_generation = state::arm_recovery(&git_dir).expect("re-arm to g2"); - assert_eq!(second_generation, 2); - - gate.release(); - let flusher_output = flusher.join().expect("flusher thread should not panic"); - assert_eq!( - flusher_output, - pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), - "Test C: the flusher denies because recovery was re-armed under it", - ); - - assert_eq!( - read_state(&git_dir).recovery, - state::RecoveryState::Pending { generation: 2 }, - "Test C: the stale Flush(g1) completion must not clear Pending(g2)", - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn test_d_start_succeeds_but_mark_active_fails_blocks_a_successor_until_recovery() { - let git_dir = unique_test_git_dir("start-then-mark-active-fails"); - let resolver = fixed_resolver(git_dir.clone()); - - state::arm_mark_active_failure_for_tests(); - let logger = RecordingLogger::default(); - let output = run_codex_mutation_scope_from_payload_with( - &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-a".to_string()))]), - Some(&logger), - &resolver, - &ok_seam, - ) - .expect("a mark_active failure still returns Ok with a deny payload"); - assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); - - let after_start = read_state(&git_dir); - assert_eq!(after_start.attempts.len(), 1); - assert_eq!( - after_start.attempts[0].phase, - state::AttemptPhase::PendingStart - ); - assert!(after_start.recovery.is_clear()); - - let successor = pre_tool_use_json(&[ - (TOOL_USE_ID_FIELD, Value::String("exec-b".to_string())), - (TURN_ID_FIELD, Value::String("turn-2".to_string())), - ]); - assert_eq!( - run_codex_mutation_scope_from_payload_with( - &successor, - None, - &resolver, - &unreachable_seam, - ) - .expect("successor returns a deny payload"), - pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), - "Test D: an uncertain PendingStart in another lane blocks a successor Start", - ); - - drive(&session_end_payload("session-1"), &resolver, &ok_seam); - assert!(read_state(&git_dir).attempts.is_empty()); - assert!(!read_state(&git_dir).recovery.is_clear()); - - let recording: Arc>> = Arc::new(Mutex::new(Vec::new())); - let seam = recording_seam(Arc::clone(&recording)); - let recovered = drive(&successor, &resolver, &seam); - assert_eq!(recovered, ""); - - let ops = recording.lock().expect("recording mutex").clone(); - assert_eq!(ops.len(), 2, "expected flush then start, got {ops:?}"); - assert!(ops[0].contains(r#""operation":"flush""#)); - assert!(ops[1].contains(r#""operation":"start""#)); - - let final_state = read_state(&git_dir); - assert!(final_state.recovery.is_clear()); - assert_eq!(final_state.attempts.len(), 1); - assert_eq!(final_state.attempts[0].phase, state::AttemptPhase::Active); - assert_eq!(final_state.attempts[0].tool_use_id, "exec-b"); - - remove_test_git_dir(&git_dir); - } - - fn boundary_lock_exists(git_dir: &Path) -> bool { - crate::services::hooks::codex_mutation_scope::boundary_lock::boundary_lock_path(git_dir) - .exists() - } - - #[test] - fn policy_blocked_bash_pre_tool_use_creates_no_mutation_scope_state() { - let git_dir = unique_test_git_dir("policy-blocked-no-scope"); - - let output = run_codex_mutation_scope_from_payload_with_bash_policy( - &pre_tool_use_json(&[(TOOL_INPUT_FIELD, json!({"command": "danger --now"}))]), - None, - &panicking_resolver, - &unreachable_seam, - &blocking_bash_policy, - ) - .expect("a policy-blocked Bash PreToolUse still returns Ok"); - - assert_eq!( - output, BLOCKED_BASH_POLICY_RESPONSE, - "a policy block returns the Codex-native policy denial verbatim, \ - never the generic mutation-scope deny", - ); - assert!(!output.contains(FAIL_CLOSED_DENY_REASON)); - assert!( - !state::adapter_state_dir(&git_dir).exists(), - "a policy block must leave no adapter state: no PendingStart, Active, \ - Start, Abandon, Flush, or recovery", - ); - assert!( - !boundary_lock_exists(&git_dir), - "a policy block must not even acquire the boundary lock", - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn policy_allowed_bash_pre_tool_use_follows_the_normal_write_ahead_start_path() { - let git_dir = unique_test_git_dir("policy-allowed-start"); - let resolver = fixed_resolver(git_dir.clone()); - let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); - - let output = run_codex_mutation_scope_from_payload_with_bash_policy( - &pre_tool_use_json(&[]), - None, - &resolver, - &recording_seam(Arc::clone(&recorded)), - &allow_bash_policy, - ) - .expect("an allowed Bash PreToolUse returns Ok"); - assert_eq!(output, ""); - - let ops = recorded.lock().unwrap().clone(); - assert_eq!( - ops.iter() - .filter(|payload| payload.contains(r#""operation":"start""#)) - .count(), - 1, - "an allowed Bash still drives exactly one write-ahead Start, got {ops:?}", - ); - let attempts = read_state(&git_dir).attempts; - assert_eq!(attempts.len(), 1); - assert_eq!(attempts[0].phase, state::AttemptPhase::Active); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn policy_evaluation_failure_is_fail_closed_with_no_mutation_scope_state() { - let git_dir = unique_test_git_dir("policy-eval-failure"); - let logger = RecordingLogger::default(); - - let output = run_codex_mutation_scope_from_payload_with_bash_policy( - &pre_tool_use_json(&[]), - Some(&logger), - &panicking_resolver, - &unreachable_seam, - &failing_bash_policy, - ) - .expect("a Bash policy evaluation failure still returns Ok"); - - assert_eq!( - output, - pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), - "a policy evaluation failure fails closed with the generic mutation-scope deny", - ); - let warnings = logger.warnings(); - assert_eq!(warnings.len(), 1); - assert_eq!(warnings[0].0, PRE_TOOL_USE_FAIL_CLOSED_EVENT); - assert!(warnings[0].1.contains("could not be evaluated")); - assert!( - !state::adapter_state_dir(&git_dir).exists(), - "a fail-closed policy evaluation must leave no adapter state", - ); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn apply_patch_pre_tool_use_never_evaluates_bash_policy() { - let git_dir = unique_test_git_dir("apply-patch-no-policy"); - let resolver = fixed_resolver(git_dir.clone()); - - let output = run_codex_mutation_scope_from_payload_with_bash_policy( - &pre_tool_use_json(&[ - (TOOL_NAME_FIELD, Value::String("apply_patch".to_string())), - (TOOL_INPUT_FIELD, Value::Null), - ]), - None, - &resolver, - &ok_seam, - &unreachable_bash_policy, - ) - .expect("an apply_patch PreToolUse returns Ok"); - assert_eq!(output, ""); - - let attempts = read_state(&git_dir).attempts; - assert_eq!(attempts.len(), 1, "apply_patch still establishes a scope"); - assert_eq!(attempts[0].phase, state::AttemptPhase::Active); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn malformed_bash_tool_input_is_fail_closed_before_the_policy_evaluator_runs() { - let git_dir = unique_test_git_dir("malformed-bash-tool-input"); - let logger = RecordingLogger::default(); - - let output = run_codex_mutation_scope_from_payload_with_bash_policy( - &pre_tool_use_json(&[(TOOL_INPUT_FIELD, json!({"not_command": "x"}))]), - Some(&logger), - &panicking_resolver, - &unreachable_seam, - &unreachable_bash_policy, - ) - .expect("a malformed Bash tool_input still returns Ok"); - - assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); - let warnings = logger.warnings(); - assert_eq!(warnings.len(), 1); - assert_eq!(warnings[0].0, PRE_TOOL_USE_FAIL_CLOSED_EVENT); - assert!( - warnings[0].1.contains("tool_input.command"), - "extraction reuses the shared bash_command_from_tool_input semantics", - ); - assert!(!state::adapter_state_dir(&git_dir).exists()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn production_bash_policy_evaluator_blocks_a_repo_denied_command_before_any_start() { - let repo = unique_test_git_dir("prod-policy-regression"); - std::fs::create_dir_all(repo.join(".sce")).expect("create .sce dir"); - std::fs::write( - repo.join(".sce").join("config.json"), - concat!( - r#"{"policies":{"bash":{"custom":[{"id":"no-rm","#, - r#""match":{"argv_prefix":["rm"]},"#, - r#""message":"rm is blocked in this repository"}]}}}"#, - ), - ) - .expect("write repo bash policy config"); - - let real_evaluator = - |root: &Path, command: &str| evaluate_codex_bash_policy(root, command); - - let blocked = run_codex_mutation_scope_from_payload_with_bash_policy( - &pre_tool_use_json(&[ - ( - CWD_FIELD, - Value::String(repo.to_string_lossy().into_owned()), - ), - (TOOL_INPUT_FIELD, json!({"command": "rm -rf build"})), - ]), - None, - &panicking_resolver, - &unreachable_seam, - &real_evaluator, - ) - .expect("a repo-denied Bash command still returns Ok"); - - assert!(blocked.contains(r#""permissionDecision":"deny""#)); - assert!(blocked.contains("no-rm")); - assert!(blocked.contains("rm is blocked in this repository")); - assert!( - !blocked.contains(FAIL_CLOSED_DENY_REASON), - "a real policy block keeps the policy-specific UX, not the generic deny", - ); - - let allowed = run_codex_mutation_scope_from_payload_with_bash_policy( - &pre_tool_use_json(&[ - ( - CWD_FIELD, - Value::String(repo.to_string_lossy().into_owned()), - ), - (TOOL_INPUT_FIELD, json!({"command": "echo ok > ok.txt"})), - (TOOL_USE_ID_FIELD, Value::String("exec-allowed".to_string())), - ]), - None, - &fixed_resolver(repo.join(".git")), - &ok_seam, - &real_evaluator, - ) - .expect("an allowed Bash command still returns Ok"); - assert_eq!( - allowed, "", - "the same repo config lets a non-denied command through to the normal path", - ); - - remove_test_git_dir(&repo); - } - - fn operations(recorded: &[String]) -> Vec { - recorded - .iter() - .filter_map(|payload| { - for op in ["start", "close", "abandon", "flush"] { - if payload.contains(&format!(r#""operation":"{op}""#)) { - return Some(op.to_string()); - } - } - None - }) - .collect() - } - - fn pre(tool_use_id: &str, tool_name: &str, overrides: &[(&str, Value)]) -> String { - let mut merged: Vec<(&str, Value)> = vec![ - (TOOL_USE_ID_FIELD, Value::String(tool_use_id.to_string())), - (TOOL_NAME_FIELD, Value::String(tool_name.to_string())), - ]; - if tool_name != CODEX_TRACKED_TOOL_BASH { - merged.push((TOOL_INPUT_FIELD, Value::Null)); - } - merged.extend( - overrides - .iter() - .map(|(field, value)| (*field, value.clone())), - ); - pre_tool_use_json(&merged) - } - - fn assert_zombie_then_successor_sweep( - a_tool: &str, - b_tool: &str, - b_overrides: &[(&str, Value)], - ) { - let git_dir = unique_test_git_dir(&format!("zombie-successor-{a_tool}-{b_tool}")); - let resolver = fixed_resolver(git_dir.clone()); - let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); - - let started = drive( - &pre("exec-a", a_tool, &[]), - &resolver, - &recording_seam(Arc::clone(&recorded)), - ); - assert_eq!(started, ""); - assert_eq!( - read_state(&git_dir).attempts[0].phase, - state::AttemptPhase::Active, - ); - - let successor = drive( - &pre("exec-b", b_tool, b_overrides), - &resolver, - &recording_seam(Arc::clone(&recorded)), - ); - assert_eq!(successor, ""); - - let ops = operations(&recorded.lock().unwrap()); - assert_eq!( - ops, - vec![ - "start".to_string(), - "abandon".to_string(), - "flush".to_string(), - "start".to_string(), - ], - "successor sequence must be Start(A) -> Abandon(A) -> Flush -> Start(B), never Start(A) -> Start(B) -> Abandon(A)", - ); - - let final_state = read_state(&git_dir); - assert_eq!(final_state.attempts.len(), 1); - assert_eq!(final_state.attempts[0].tool_use_id, "exec-b"); - assert_eq!(final_state.attempts[0].phase, state::AttemptPhase::Active); - assert!(final_state.recovery.is_clear()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn regression1_arbitrary_blocker_zombie_then_tracked_successor() { - assert_zombie_then_successor_sweep("Bash", "Bash", &[]); - } - - #[test] - fn regression2_apply_patch_successor_variants() { - assert_zombie_then_successor_sweep("Bash", "apply_patch", &[]); - assert_zombie_then_successor_sweep("apply_patch", "Bash", &[]); - assert_zombie_then_successor_sweep("apply_patch", "apply_patch", &[]); - } - - #[test] - fn regression3_parent_then_subagent_same_lane_is_swept() { - assert_zombie_then_successor_sweep( - "Bash", - "Bash", - &[(AGENT_ID_FIELD, Value::String("agent-1".to_string()))], - ); - } - - #[test] - fn regression3_subagent_then_parent_same_lane_is_swept() { - let git_dir = unique_test_git_dir("subagent-then-parent"); - let resolver = fixed_resolver(git_dir.clone()); - let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); - - drive( - &pre( - "exec-a", - "Bash", - &[(AGENT_ID_FIELD, Value::String("agent-1".to_string()))], - ), - &resolver, - &recording_seam(Arc::clone(&recorded)), - ); - drive( - &pre("exec-b", "Bash", &[]), - &resolver, - &recording_seam(Arc::clone(&recorded)), - ); - - assert_eq!( - operations(&recorded.lock().unwrap()), - vec![ - "start".to_string(), - "abandon".to_string(), - "flush".to_string(), - "start".to_string(), - ], - ); - let final_state = read_state(&git_dir); - assert_eq!(final_state.attempts.len(), 1); - assert_eq!(final_state.attempts[0].tool_use_id, "exec-b"); - assert!(final_state.attempts[0].agent_id.is_none()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn regression4_different_session_is_not_swept() { - let git_dir = unique_test_git_dir("different-session-not-swept"); - let resolver = fixed_resolver(git_dir.clone()); - seed_attempt_in_turn( - &git_dir, - "session-1", - "turn-1", - None, - "exec-a", - state::AttemptPhase::Active, - ); - let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); - - let output = drive( - &pre( - "exec-b", - "Bash", - &[(SESSION_ID_FIELD, Value::String("session-2".to_string()))], - ), - &resolver, - &recording_seam(Arc::clone(&recorded)), - ); - assert_eq!(output, ""); - - assert_eq!( - operations(&recorded.lock().unwrap()), - vec!["start".to_string()] - ); - let final_state = read_state(&git_dir); - assert_eq!(final_state.attempts.len(), 2); - assert!(final_state - .attempts - .iter() - .any(|attempt| attempt.tool_use_id == "exec-a")); - assert!(final_state.recovery.is_clear()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn regression5_different_turn_is_not_swept_by_case_b_inference() { - let git_dir = unique_test_git_dir("different-turn-not-swept"); - let resolver = fixed_resolver(git_dir.clone()); - seed_attempt_in_turn( - &git_dir, - "session-1", - "turn-1", - None, - "exec-a", - state::AttemptPhase::Active, - ); - let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); - - let output = drive( - &pre( - "exec-b", - "Bash", - &[(TURN_ID_FIELD, Value::String("turn-2".to_string()))], - ), - &resolver, - &recording_seam(Arc::clone(&recorded)), - ); - assert_eq!(output, ""); - - assert_eq!( - operations(&recorded.lock().unwrap()), - vec!["start".to_string()] - ); - let final_state = read_state(&git_dir); - assert_eq!(final_state.attempts.len(), 2); - assert!(final_state - .attempts - .iter() - .any(|attempt| attempt.tool_use_id == "exec-a")); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn regression6_duplicate_same_attempt_key_is_not_swept() { - let git_dir = unique_test_git_dir("duplicate-not-swept"); - let resolver = fixed_resolver(git_dir.clone()); - let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); - - drive( - &pre("exec-a", "Bash", &[]), - &resolver, - &recording_seam(Arc::clone(&recorded)), - ); - let scope_id = read_state(&git_dir).attempts[0].scope_id.clone(); - let next_seq = read_state(&git_dir).next_attempt_seq; - - drive( - &pre("exec-a", "Bash", &[]), - &resolver, - &recording_seam(Arc::clone(&recorded)), - ); - - assert_eq!( - operations(&recorded.lock().unwrap()), - vec!["start".to_string()] - ); - let final_state = read_state(&git_dir); - assert_eq!(final_state.attempts.len(), 1); - assert_eq!(final_state.attempts[0].scope_id, scope_id); - assert_eq!(final_state.next_attempt_seq, next_seq); - assert!(final_state.recovery.is_clear()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn regression7_pending_start_predecessor_is_swept() { - let git_dir = unique_test_git_dir("pending-start-predecessor-swept"); - let resolver = fixed_resolver(git_dir.clone()); - seed_attempt_in_turn( - &git_dir, - "session-1", - "turn-1", - None, - "exec-a", - state::AttemptPhase::PendingStart, - ); - let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); - - let output = drive( - &pre("exec-b", "Bash", &[]), - &resolver, - &recording_seam(Arc::clone(&recorded)), - ); - assert_eq!(output, ""); - - assert_eq!( - operations(&recorded.lock().unwrap()), - vec![ - "abandon".to_string(), - "flush".to_string(), - "start".to_string(), - ], - ); - let final_state = read_state(&git_dir); - assert_eq!(final_state.attempts.len(), 1); - assert_eq!(final_state.attempts[0].tool_use_id, "exec-b"); - assert!(final_state.recovery.is_clear()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn regression8_abandon_failure_during_sweep_is_fail_closed() { - let git_dir = unique_test_git_dir("sweep-abandon-failure"); - let resolver = fixed_resolver(git_dir.clone()); - seed_attempt_in_turn( - &git_dir, - "session-1", - "turn-1", - None, - "exec-a", - state::AttemptPhase::Active, - ); - - let output = run_codex_mutation_scope_from_payload_with( - &pre("exec-b", "Bash", &[]), - None, - &resolver, - &seam_failing_on("abandon"), - ) - .expect("a failed sweep abandon still returns Ok with a deny payload"); - assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); - - let final_state = read_state(&git_dir); - assert_eq!(final_state.attempts.len(), 1); - assert_eq!(final_state.attempts[0].tool_use_id, "exec-a"); - assert!(!final_state.recovery.is_clear()); - - remove_test_git_dir(&git_dir); - } - - #[test] - fn regression9_flush_failure_after_sweep_is_fail_closed() { - let git_dir = unique_test_git_dir("sweep-flush-failure"); - let resolver = fixed_resolver(git_dir.clone()); - seed_attempt_in_turn( - &git_dir, - "session-1", - "turn-1", - None, - "exec-a", - state::AttemptPhase::Active, - ); - - let output = run_codex_mutation_scope_from_payload_with( - &pre("exec-b", "Bash", &[]), - None, - &resolver, - &seam_failing_on("flush"), - ) - .expect("a failed post-sweep flush still returns Ok with a deny payload"); - assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); - - let final_state = read_state(&git_dir); - assert!(final_state - .attempts - .iter() - .all(|attempt| attempt.tool_use_id != "exec-b")); - assert!(!final_state.recovery.is_clear()); - - remove_test_git_dir(&git_dir); - } - } - - mod production_regressions { - use std::fs; - use std::path::{Path, PathBuf}; - use std::process::Command; - - use super::*; - use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; - use crate::services::agent_trace_storage::{ - resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, - }; - use crate::services::mutation_trace::runtime::{resolve_git_dir, resolve_worktree_id}; - use crate::services::mutation_trace::store::decode_revision; - - const PROBE01_APPLY_PATCH_POST: &str = include_str!( - "fixtures/probe01-apply-patch-and-shell-success.apply_patch.post_tool_use.json" - ); - const PROBE02_FAILED_SHELL_PRE: &str = include_str!( - "fixtures/probe02-shell-partial-write-then-nonzero-exit.pre_tool_use.json" - ); - const PROBE06_APPLY_PATCH_FAILURE_PRE: &str = include_str!( - "fixtures/probe06-apply-patch-verification-failure-no-post.pre_tool_use.json" - ); - const PROBE09_DETACHED_PRE: &str = - include_str!("fixtures/probe09-self-detaching-descendant.pre_tool_use.json"); - const PROBE09_DETACHED_POST: &str = - include_str!("fixtures/probe09-self-detaching-descendant.post_tool_use.json"); - const PROBE11_INTERRUPT_PRE: &str = - include_str!("fixtures/probe11-interrupt-event-on-sigint.pre_tool_use.json"); - const PROBE13_MCP_STOP: &str = - include_str!("fixtures/probe13-mcp-mutate-then-error.stop.json"); - const PROBE14_MCP_FAILED_PRE: &str = - include_str!("fixtures/probe14-mcp-failed-then-successor.failed.pre_tool_use.json"); - const PROBE14_MCP_SUCCESSOR_PRE: &str = - include_str!("fixtures/probe14-mcp-failed-then-successor.successor.pre_tool_use.json"); - const PROBE14_MCP_SUCCESSOR_POST: &str = - include_str!("fixtures/probe14-mcp-failed-then-successor.successor.post_tool_use.json"); - const PROBE16_MCP_PARALLEL_A_PRE: &str = - include_str!("fixtures/probe16-mcp-parallel-server-optin.a.pre_tool_use.json"); - const PROBE16_MCP_PARALLEL_A_POST: &str = - include_str!("fixtures/probe16-mcp-parallel-server-optin.a.post_tool_use.json"); - const PROBE16_MCP_PARALLEL_B_PRE: &str = - include_str!("fixtures/probe16-mcp-parallel-server-optin.b.pre_tool_use.json"); - const PROBE16_MCP_PARALLEL_B_POST: &str = - include_str!("fixtures/probe16-mcp-parallel-server-optin.b.post_tool_use.json"); - - const OTHER_HARNESS_ACTOR_KIND: &str = "claude_code"; - - fn git(dir: &Path, args: &[&str]) -> String { - let output = Command::new("git") - .args(args) - .current_dir(dir) - .output() - .expect("git should spawn"); - assert!( - output.status.success(), - "git {args:?} failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - String::from_utf8(output.stdout).expect("git output should be UTF-8") - } - - struct CodexRepo { - temp: tempfile::TempDir, - root: PathBuf, - state_root: PathBuf, - } - - impl CodexRepo { - fn new(label: &str) -> Self { - let temp = tempfile::Builder::new() - .prefix(&format!("sce-codex-mutation-scope-regression-{label}-")) - .tempdir() - .expect("temp dir should be created"); - let root = temp.path().join("repo"); - fs::create_dir_all(&root).expect("repo dir should be created"); - git(&root, &["init", "-q"]); - git(&root, &["config", "user.email", "test@example.invalid"]); - git(&root, &["config", "user.name", "SCE Test"]); - git( - &root, - &["remote", "add", "origin", "git@github.com:acme/widgets.git"], - ); - fs::write(root.join("file.txt"), "one\n").expect("seed file should write"); - git(&root, &["add", "-A"]); - git(&root, &["commit", "-qm", "base"]); - - let state_root = temp.path().join("state"); - fs::create_dir_all(&state_root).expect("state root should be created"); - resolve_agent_trace_storage_at_state_root( - &AgentTraceStorageContext { - repository_root: &root, - explicit_repository_id: None, - repository_remote: "origin", - }, - &state_root, - ) - .expect("state-root storage should initialize the repository DB"); - - Self { - temp, - root, - state_root, - } - } - - fn drive(&self, payload: &str) -> Result { - run_codex_mutation_scope_from_payload_at_state_root(&self.state_root, payload, None) - } - - fn drive_generic(&self, payload: &str) -> Result { - self.drive_generic_at(&self.root, payload) - } - - fn drive_generic_at(&self, repository_root: &Path, payload: &str) -> Result { - crate::services::hooks::mutation_scope::run_mutation_scope_from_payload_at_state_root( - repository_root, - &self.state_root, - payload, - None, - ) - } - - fn drive_flush(&self) -> Result { - self.drive_generic(&flush_payload()) - } - - fn db(&self) -> RepositoryAgentTraceDb { - crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( - &self.root, - &self.state_root, - "codex mutation-scope regression test assertions", - ) - .expect("assertion DB should open") - } - - fn cwd(&self) -> String { - Self::cwd_at(&self.root) - } - - fn cwd_at(root: &Path) -> String { - root.to_string_lossy().into_owned() - } - - fn working_tree_at(root: &Path) -> String { - git(root, &["add", "-A"]); - git(root, &["write-tree"]).trim().to_owned() - } - - fn working_tree(&self) -> String { - Self::working_tree_at(&self.root) - } - - fn git_dir_at(root: &Path) -> PathBuf { - resolve_git_dir(root).expect("git dir should resolve") - } - - fn git_dir(&self) -> PathBuf { - Self::git_dir_at(&self.root) - } - - fn adapter_state_at(root: &Path) -> state::AdapterState { - state::read_state(&Self::git_dir_at(root)) - .expect("adapter state should be readable") - } - - fn adapter_state(&self) -> state::AdapterState { - Self::adapter_state_at(&self.root) - } - - fn adapter_state_file_exists(&self) -> bool { - state::adapter_state_dir(&self.git_dir()) - .join("codex-mutation-scope-state.json") - .exists() - } - - fn worktree_id_at(root: &Path) -> String { - resolve_worktree_id(root) - .expect("worktree id should resolve") - .0 - } - - fn worktree_id(&self) -> String { - Self::worktree_id_at(&self.root) - } - - fn add_worktree(&self, name: &str) -> PathBuf { - let worktree_path = self.temp.path().join(name); - git( - &self.root, - &[ - "worktree", - "add", - "-q", - worktree_path.to_str().expect("utf-8 worktree path"), - ], - ); - worktree_path - } - - fn write(&self, name: &str, contents: &str) { - fs::write(self.root.join(name), contents).expect("regression write should succeed"); - } - - fn live_scope_id(&self) -> String { - let state = self.adapter_state(); - assert_eq!(state.attempts.len(), 1, "exactly one live attempt expected"); - state.attempts[0].scope_id.clone() - } - } - - fn count(db: &RepositoryAgentTraceDb, table: &str) -> i64 { - db.query_map(&format!("SELECT COUNT(*) FROM {table}"), (), |row| { - row.get::(0).map_err(anyhow::Error::from) - }) - .expect("count query should succeed") - .into_iter() - .next() - .expect("a count row should exist") - } - - fn raw_agent_trace_row_counts(db: &RepositoryAgentTraceDb) -> [i64; 5] { - [ - count(db, "diff_traces"), - count(db, "post_commit_patch_intersections"), - count(db, "agent_traces"), - count(db, "messages"), - count(db, "parts"), - ] - } - - fn assert_raw_agent_trace_tables_untouched(db: &RepositoryAgentTraceDb) { - assert_eq!( - raw_agent_trace_row_counts(db), - [0, 0, 0, 0, 0], - "AC20: the mutation-scope adapter must never write the raw Agent Trace tables" - ); - } - - fn worktree_row( - db: &RepositoryAgentTraceDb, - worktree_id: &str, - ) -> Option<(u64, String, bool)> { - db.query_map( - "SELECT revision, cursor_tree, needs_rebaseline FROM mutation_trace_worktrees \ - WHERE worktree_id = ?1", - (worktree_id,), - |row| { - let blob: Vec = row.get(0).map_err(anyhow::Error::from)?; - let revision = decode_revision(&blob)?; - let cursor_tree = row.get::(1).map_err(anyhow::Error::from)?; - let needs_rebaseline = row.get::(2).map_err(anyhow::Error::from)? != 0; - Ok((revision, cursor_tree, needs_rebaseline)) - }, - ) - .expect("worktree-row query should succeed") - .into_iter() - .next() - } - - fn processed_events(db: &RepositoryAgentTraceDb) -> Vec<(String, String)> { - db.query_map( - "SELECT scope_id, event_id FROM mutation_trace_processed_events \ - ORDER BY scope_id, event_id", - (), - |row| { - let scope_id = row.get::(0).map_err(anyhow::Error::from)?; - let event_id = row.get::(1).map_err(anyhow::Error::from)?; - Ok((scope_id, event_id)) - }, - ) - .expect("processed-events query should succeed") - } - - fn scope_status(db: &RepositoryAgentTraceDb, scope_id: &str) -> Option<(String, String)> { - db.query_map( - "SELECT actor_kind, status FROM mutation_trace_scopes WHERE scope_id = ?1", - (scope_id,), - |row| { - let actor_kind = row.get::(0).map_err(anyhow::Error::from)?; - let status = row.get::(1).map_err(anyhow::Error::from)?; - Ok((actor_kind, status)) - }, - ) - .expect("scope query should succeed") - .into_iter() - .next() - } - - fn mutation_events_for( - db: &RepositoryAgentTraceDb, - worktree_id: &str, - ) -> Vec<(String, Option, String)> { - db.query_map( - "SELECT attribution_kind, attribution_scope_id, boundary_kind \ - FROM mutation_trace_events WHERE worktree_id = ?1 ORDER BY revision", - (worktree_id,), - |row| { - let attribution_kind = row.get::(0).map_err(anyhow::Error::from)?; - let attribution_scope_id = - row.get::>(1).map_err(anyhow::Error::from)?; - let boundary_kind = row.get::(2).map_err(anyhow::Error::from)?; - Ok((attribution_kind, attribution_scope_id, boundary_kind)) - }, - ) - .expect("mutation-events query should succeed") - } - - fn scope_provenance( - db: &RepositoryAgentTraceDb, - scope_id: &str, - ) -> Option<(String, Option)> { - db.query_map( - "SELECT session_id, model_id FROM mutation_trace_scope_provenance \ - WHERE scope_id = ?1", - (scope_id,), - |row| { - let session_id = row.get::(0).map_err(anyhow::Error::from)?; - let model_id = row.get::>(1).map_err(anyhow::Error::from)?; - Ok((session_id, model_id)) - }, - ) - .expect("scope-provenance query should succeed") - .into_iter() - .next() - } - - fn active_scopes_for(db: &RepositoryAgentTraceDb, worktree_id: &str) -> Vec { - db.query_map( - "SELECT scope_id FROM mutation_trace_event_active_scopes \ - WHERE worktree_id = ?1 ORDER BY revision, scope_id", - (worktree_id,), - |row| row.get::(0).map_err(anyhow::Error::from), - ) - .expect("active-scopes query should succeed") - } - - fn fixture_at(fixture: &str, cwd: &str) -> String { - let mut object: Map = - serde_json::from_str(fixture).expect("a fixture payload is a JSON object"); - object.insert(CWD_FIELD.to_string(), Value::String(cwd.to_string())); - Value::Object(object).to_string() - } - - struct ToolEvent<'a> { - event_name: &'a str, - cwd: &'a str, - session_id: &'a str, - turn_id: &'a str, - tool_name: &'a str, - tool_use_id: &'a str, - agent_id: Option<&'a str>, - } - - fn tool_event_json(event: &ToolEvent, tool_input: Option) -> String { - let mut object = Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(event.event_name.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String(event.session_id.to_string()), - ); - object.insert( - TURN_ID_FIELD.to_string(), - Value::String(event.turn_id.to_string()), - ); - object.insert(CWD_FIELD.to_string(), Value::String(event.cwd.to_string())); - object.insert( - TOOL_NAME_FIELD.to_string(), - Value::String(event.tool_name.to_string()), - ); - object.insert( - TOOL_USE_ID_FIELD.to_string(), - Value::String(event.tool_use_id.to_string()), - ); - if let Some(agent_id) = event.agent_id { - object.insert( - AGENT_ID_FIELD.to_string(), - Value::String(agent_id.to_string()), - ); - } - if let Some(tool_input) = tool_input { - object.insert(TOOL_INPUT_FIELD.to_string(), tool_input); - } - Value::Object(object).to_string() - } - - struct TrackedCall<'a> { - cwd: &'a str, - session_id: &'a str, - turn_id: &'a str, - tool_name: &'a str, - tool_use_id: &'a str, - agent_id: Option<&'a str>, - } - - impl TrackedCall<'_> { - fn pre(&self) -> String { - tool_event_json( - &ToolEvent { - event_name: HOOK_EVENT_PRE_TOOL_USE, - cwd: self.cwd, - session_id: self.session_id, - turn_id: self.turn_id, - tool_name: self.tool_name, - tool_use_id: self.tool_use_id, - agent_id: self.agent_id, - }, - Some(json!({ "command": "echo regression >> file.txt" })), - ) - } - - fn post(&self) -> String { - tool_event_json( - &ToolEvent { - event_name: HOOK_EVENT_POST_TOOL_USE, - cwd: self.cwd, - session_id: self.session_id, - turn_id: self.turn_id, - tool_name: self.tool_name, - tool_use_id: self.tool_use_id, - agent_id: self.agent_id, - }, - None, - ) - } - } - - fn bash_call<'a>( - cwd: &'a str, - session_id: &'a str, - tool_use_id: &'a str, - ) -> TrackedCall<'a> { - TrackedCall { - cwd, - session_id, - turn_id: "turn-1", - tool_name: CODEX_TRACKED_TOOL_BASH, - tool_use_id, - agent_id: None, - } - } - - fn turn_event_json(event_name: &str, cwd: &str, session_id: &str, turn_id: &str) -> String { - json!({ - HOOK_EVENT_NAME_FIELD: event_name, - SESSION_ID_FIELD: session_id, - TURN_ID_FIELD: turn_id, - CWD_FIELD: cwd, - }) - .to_string() - } - - fn session_end_json(cwd: &str, session_id: &str) -> String { - json!({ - HOOK_EVENT_NAME_FIELD: HOOK_EVENT_SESSION_END, - SESSION_ID_FIELD: session_id, - CWD_FIELD: cwd, - }) - .to_string() - } - - fn other_harness_payload(operation: &str, scope_id: &str) -> String { - json!({ - "operation": operation, - "scope_id": scope_id, - "event_id": format!("{scope_id}|{operation}"), - "actor_kind": OTHER_HARNESS_ACTOR_KIND, - }) - .to_string() - } - - #[test] - fn test1_tracked_bash_success_closes_ai_exclusive_ac8() { - let repo = CodexRepo::new("test1-bash-success"); - let cwd = repo.cwd(); - let pre = fixture_at(PROBE01_SHELL_PRE, &cwd); - let post = fixture_at(PROBE01_SHELL_POST, &cwd); - - assert_eq!( - repo.drive(&pre).expect("PreToolUse should succeed"), - "", - "a tracked PreToolUse that established Start returns the neutral response" - ); - let scope_id = repo.live_scope_id(); - - repo.write("file.txt", "one\ntwo\n"); - - assert_eq!(repo.drive(&post).expect("PostToolUse should succeed"), ""); - assert!( - repo.adapter_state().attempts.is_empty(), - "the closed attempt must be removed from adapter bookkeeping" - ); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &scope_id), - Some(("codex".to_string(), "closed".to_string())) - ); - let worktree_id = repo.worktree_id(); - assert_eq!( - mutation_events_for(&db, &worktree_id), - vec![( - "ai_exclusive".to_string(), - Some(scope_id.clone()), - "close".to_string(), - )] - ); - assert_eq!( - worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), - Some(repo.working_tree()) - ); - assert_eq!( - processed_events(&db), - vec![ - (scope_id.clone(), codex_scope_close_event_id(&scope_id)), - (scope_id.clone(), codex_scope_start_event_id(&scope_id)), - ], - "rows are ordered by (scope_id, event_id), and 'close' sorts before 'start'" - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test2_failed_bash_partial_write_still_closes_ai_exclusive_ac9() { - let repo = CodexRepo::new("test2-failed-bash"); - let cwd = repo.cwd(); - - repo.drive(&fixture_at(PROBE02_FAILED_SHELL_PRE, &cwd)) - .expect("PreToolUse should succeed"); - let scope_id = repo.live_scope_id(); - - repo.write("file.txt", "one\npartial\n"); - - assert_eq!( - repo.drive(&fixture_at(PROBE02_FAILED_SHELL_POST, &cwd)) - .expect("a non-zero-exit shell still fires PostToolUse"), - "" - ); - assert!(repo.adapter_state().attempts.is_empty()); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &scope_id), - Some(("codex".to_string(), "closed".to_string())), - "D10: a Bash tool that partially mutated then exited non-zero closes its scope" - ); - let worktree_id = repo.worktree_id(); - assert_eq!( - mutation_events_for(&db, &worktree_id), - vec![( - "ai_exclusive".to_string(), - Some(scope_id), - "close".to_string(), - )], - "the partial mutation is attributed to the failed tool's own scope" - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test3_apply_patch_success_closes_ai_exclusive_ac8() { - let repo = CodexRepo::new("test3-apply-patch-success"); - let cwd = repo.cwd(); - - repo.drive(&fixture_at(PROBE01_APPLY_PATCH_PRE, &cwd)) - .expect("PreToolUse should succeed"); - let scope_id = repo.live_scope_id(); - - repo.write("alpha.txt", "alpha one\n"); - - repo.drive(&fixture_at(PROBE01_APPLY_PATCH_POST, &cwd)) - .expect("PostToolUse should succeed"); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &scope_id), - Some(("codex".to_string(), "closed".to_string())) - ); - let worktree_id = repo.worktree_id(); - assert_eq!( - mutation_events_for(&db, &worktree_id), - vec![( - "ai_exclusive".to_string(), - Some(scope_id), - "close".to_string(), - )] - ); - assert_eq!( - worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), - Some(repo.working_tree()) - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test4_apply_patch_verification_failure_mutates_nothing_and_is_swept_ac9() { - let repo = CodexRepo::new("test4-apply-patch-failure"); - let cwd = repo.cwd(); - let pre = fixture_at(PROBE06_APPLY_PATCH_FAILURE_PRE, &cwd); - let tree_before = repo.working_tree(); - - repo.drive(&pre).expect("PreToolUse should succeed"); - let scope_id = repo.live_scope_id(); - - let stop = { - let execution = pre_tool_use(&pre); - turn_event_json( - HOOK_EVENT_STOP, - &cwd, - &execution.identity.session_id, - &execution.identity.turn_id, - ) - }; - repo.drive(&stop) - .expect("Stop should retire the attempt that never received PostToolUse"); - - assert!(repo.adapter_state().attempts.is_empty()); - assert!(!repo.adapter_state().recovery.is_clear()); - assert_eq!( - repo.working_tree(), - tree_before, - "D10: apply_patch verification failure never touches the working tree" - ); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &scope_id), - Some(("codex".to_string(), "abandoned".to_string())) - ); - let worktree_id = repo.worktree_id(); - assert_eq!( - mutation_events_for(&db, &worktree_id), - vec![], - "no mutation happened, so nothing is attributed" - ); - assert!(worktree_row(&db, &worktree_id) - .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline)); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test5_duplicate_tracked_lifecycle_is_idempotent_ac4() { - let repo = CodexRepo::new("test5-duplicate-lifecycle"); - let cwd = repo.cwd(); - let pre = fixture_at(PROBE01_SHELL_PRE, &cwd); - let post = fixture_at(PROBE01_SHELL_POST, &cwd); - - repo.drive(&pre).expect("first PreToolUse should succeed"); - let scope_id = repo.live_scope_id(); - assert_eq!( - repo.drive(&pre) - .expect("duplicate PreToolUse should be idempotent"), - "" - ); - assert_eq!( - repo.live_scope_id(), - scope_id, - "AC4: duplicate delivery of a live PreToolUse reuses the same ScopeId" - ); - - repo.write("file.txt", "one\ntwo\n"); - repo.drive(&post).expect("first PostToolUse should succeed"); - - let db = repo.db(); - let (revision_before, events_before, processed_before) = ( - worktree_row(&db, &repo.worktree_id()) - .map(|(revision, _, _)| revision) - .expect("a worktree row should exist"), - count(&db, "mutation_trace_events"), - count(&db, "mutation_trace_processed_events"), - ); - - assert_eq!( - repo.drive(&post) - .expect("duplicate PostToolUse delivery must be a safe no-op"), - "" - ); - - let db = repo.db(); - assert_eq!( - worktree_row(&db, &repo.worktree_id()).map(|(revision, _, _)| revision), - Some(revision_before) - ); - assert_eq!(count(&db, "mutation_trace_events"), events_before); - assert_eq!( - count(&db, "mutation_trace_processed_events"), - processed_before - ); - assert_eq!( - processed_events(&db) - .into_iter() - .filter(|(scope, event)| scope == &scope_id - && event == &codex_scope_close_event_id(&scope_id)) - .count(), - 1 - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test6_interrupted_tracked_execution_is_retired_by_interrupt_ac11() { - let repo = CodexRepo::new("test6-interrupt"); - let cwd = repo.cwd(); - - repo.drive(&fixture_at(PROBE11_INTERRUPT_PRE, &cwd)) - .expect("PreToolUse should succeed"); - let scope_id = repo.live_scope_id(); - - repo.write("file.txt", "one\ninterrupted\n"); - - assert_eq!( - repo.drive(&fixture_at(PROBE11_INTERRUPT, &cwd)) - .expect("Interrupt should succeed"), - "" - ); - - assert!( - repo.adapter_state().attempts.is_empty(), - "AC11: Interrupt is a proven cleanup signal for the interrupted turn" - ); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &scope_id), - Some(("codex".to_string(), "abandoned".to_string())) - ); - let worktree_id = repo.worktree_id(); - assert!(worktree_row(&db, &worktree_id) - .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline)); - assert_eq!(mutation_events_for(&db, &worktree_id), vec![]); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test6b_session_end_is_the_load_bearing_backstop_ac11() { - let repo = CodexRepo::new("test6b-session-end"); - let cwd = repo.cwd(); - - repo.drive(&fixture_at(PROBE01_SHELL_PRE, &cwd)) - .expect("PreToolUse should succeed"); - let scope_id = repo.live_scope_id(); - - repo.write("file.txt", "one\nstranded\n"); - - assert_eq!( - repo.drive(&fixture_at(PROBE01_SESSION_END, &cwd)) - .expect("SessionEnd should succeed"), - "" - ); - - assert!( - repo.adapter_state().attempts.is_empty(), - "D12: SessionEnd is the load-bearing whole-session backstop" - ); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &scope_id), - Some(("codex".to_string(), "abandoned".to_string())) - ); - assert!(worktree_row(&db, &repo.worktree_id()) - .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline)); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test7_subagent_tracked_tool_gets_its_own_scope_identity() { - let repo = CodexRepo::new("test7-subagent"); - let cwd = repo.cwd(); - let subagent_pre = fixture_at(PROBE08_AGENT_APPLY_PATCH_PRE, &cwd); - let subagent_identity = pre_tool_use(&subagent_pre).identity; - let agent_id = subagent_identity - .agent_id - .clone() - .expect("probe08 carries a delegated-agent identity"); - let main_thread = TrackedCall { - cwd: &cwd, - session_id: &subagent_identity.session_id, - turn_id: "main-turn", - tool_name: CODEX_TRACKED_TOOL_BASH, - tool_use_id: "exec-main-thread", - agent_id: None, - }; - - repo.drive(&main_thread.pre()) - .expect("main-thread PreToolUse should succeed"); - repo.drive(&subagent_pre) - .expect("subagent PreToolUse should succeed"); - - let state = repo.adapter_state(); - assert_eq!(state.attempts.len(), 2); - let subagent_scope_id = state - .attempts - .iter() - .find(|attempt| attempt.agent_id.as_deref() == Some(agent_id.as_str())) - .map(|attempt| attempt.scope_id.clone()) - .expect("the subagent attempt carries its agent_id"); - let main_scope_id = state - .attempts - .iter() - .find(|attempt| attempt.agent_id.is_none()) - .map(|attempt| attempt.scope_id.clone()) - .expect("the main-thread attempt has no agent_id"); - assert_ne!(subagent_scope_id, main_scope_id); - assert!(subagent_scope_id.contains(&agent_id)); - - repo.drive(&fixture_at(PROBE08_SUBAGENT_STOP, &cwd)) - .expect("SubagentStop should succeed"); - - let remaining = repo.adapter_state(); - assert_eq!( - remaining - .attempts - .iter() - .map(|attempt| attempt.scope_id.clone()) - .collect::>(), - vec![main_scope_id.clone()], - "D12: SubagentStop sweeps only the ending agent's attempts" - ); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &subagent_scope_id).map(|(_, status)| status), - Some("abandoned".to_string()) - ); - assert_eq!( - scope_status(&db, &main_scope_id).map(|(_, status)| status), - Some("active".to_string()) - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test8_linked_worktree_advances_only_its_own_cursor_ac14() { - let repo = CodexRepo::new("test8-linked-worktree"); - let worktree_path = repo.add_worktree("codex-worktree"); - let worktree_cwd = CodexRepo::cwd_at(&worktree_path); - - let main_worktree_id = repo.worktree_id(); - let linked_worktree_id = CodexRepo::worktree_id_at(&worktree_path); - assert_ne!(main_worktree_id, linked_worktree_id); - - repo.drive_flush() - .expect("main-checkout baseline flush should succeed"); - let main_cursor_before = worktree_row(&repo.db(), &main_worktree_id) - .map(|(_, cursor_tree, _)| cursor_tree) - .expect("main checkout should have a baseline worktree row"); - - let pre = fixture_at(PROBE10_WORKTREE_PRE, &worktree_cwd); - let post = { - let identity = pre_tool_use(&pre).identity; - tool_event_json( - &ToolEvent { - event_name: HOOK_EVENT_POST_TOOL_USE, - cwd: &worktree_cwd, - session_id: &identity.session_id, - turn_id: &identity.turn_id, - tool_name: &identity.tool_name, - tool_use_id: &identity.tool_use_id, - agent_id: None, - }, - None, - ) - }; - - repo.drive(&pre) - .expect("linked-worktree PreToolUse should succeed"); - let scope_id = CodexRepo::adapter_state_at(&worktree_path).attempts[0] - .scope_id - .clone(); - fs::write(worktree_path.join("wt.txt"), "worktree-write\n") - .expect("the linked worktree's own write should succeed"); - repo.drive(&post) - .expect("linked-worktree PostToolUse should succeed"); - - let db = repo.db(); - assert_eq!( - worktree_row(&db, &main_worktree_id).map(|(_, cursor_tree, _)| cursor_tree), - Some(main_cursor_before), - "AC14: the main checkout's cursor must not move" - ); - let linked_row = - worktree_row(&db, &linked_worktree_id).expect("linked worktree row should exist"); - assert_eq!( - linked_row.1, - CodexRepo::working_tree_at(&worktree_path), - "AC14: the linked worktree's own cursor advances" - ); - assert_eq!( - mutation_events_for(&db, &linked_worktree_id), - vec![( - "ai_exclusive".to_string(), - Some(scope_id), - "close".to_string(), - )] - ); - assert_eq!(mutation_events_for(&db, &main_worktree_id), vec![]); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test9_successful_mcp_lifecycle_creates_no_mutation_scope_ac9b() { - let repo = CodexRepo::new("test9-mcp-success"); - let cwd = repo.cwd(); - - assert_eq!( - repo.drive(&fixture_at(PROBE12_MCP_PRE, &cwd)) - .expect("an MCP PreToolUse is allowed"), - "", - "AC9b: an Untracked tool gets the Codex-neutral continue response" - ); - repo.write("mcp_a.txt", "written by the MCP server\n"); - assert_eq!( - repo.drive(&fixture_at(PROBE12_MCP_POST, &cwd)) - .expect("an MCP PostToolUse is ignored"), - "" - ); - - assert!( - !repo.adapter_state_file_exists(), - "AC9b: an Untracked lifecycle writes no adapter bookkeeping at all" - ); - - let db = repo.db(); - assert_eq!(count(&db, "mutation_trace_scopes"), 0); - assert_eq!(count(&db, "mutation_trace_events"), 0); - assert_eq!(count(&db, "mutation_trace_processed_events"), 0); - assert_eq!(count(&db, "mutation_trace_worktrees"), 0); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test10_mcp_mutate_then_error_leaves_no_zombie_state_ac9c() { - let repo = CodexRepo::new("test10-mcp-mutate-then-error"); - let cwd = repo.cwd(); - - repo.drive(&fixture_at(PROBE13_MCP_MUTATE_THEN_ERROR_PRE, &cwd)) - .expect("an MCP PreToolUse is allowed"); - repo.write("mcp_b.txt", "mutated before the MCP error\n"); - - repo.drive(&fixture_at(PROBE13_MCP_STOP, &cwd)) - .expect("Stop should find nothing to retire"); - repo.drive(&fixture_at(PROBE13_MCP_SESSION_END, &cwd)) - .expect("SessionEnd should find nothing to retire"); - - assert!( - !repo.adapter_state_file_exists(), - "AC9c: no Start occurred, so there is no stale attempt and no recovery to arm" - ); - - let db = repo.db(); - assert_eq!(count(&db, "mutation_trace_scopes"), 0); - assert_eq!(count(&db, "mutation_trace_events"), 0); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test11_failed_mcp_then_tracked_successor_starts_clean_ac9d() { - let repo = CodexRepo::new("test11-mcp-then-tracked"); - let cwd = repo.cwd(); - let failed_mcp = fixture_at(PROBE14_MCP_FAILED_PRE, &cwd); - let failed_identity = pre_tool_use(&failed_mcp).identity; - - repo.drive(&failed_mcp) - .expect("the failing MCP PreToolUse is allowed"); - repo.write("mcp_c1.txt", "mutated by the failing MCP tool\n"); - repo.drive(&fixture_at(PROBE14_MCP_SUCCESSOR_PRE, &cwd)) - .expect("the MCP successor is also Untracked"); - repo.drive(&fixture_at(PROBE14_MCP_SUCCESSOR_POST, &cwd)) - .expect("the MCP successor's PostToolUse is ignored"); - - let tracked_successor = TrackedCall { - cwd: &cwd, - session_id: &failed_identity.session_id, - turn_id: &failed_identity.turn_id, - tool_name: CODEX_TRACKED_TOOL_BASH, - tool_use_id: "exec-tracked-successor", - agent_id: None, - }; - repo.drive(&tracked_successor.pre()) - .expect("the tracked successor should Start normally"); - let scope_id = repo.live_scope_id(); - assert!( - repo.adapter_state().recovery.is_clear(), - "AC9d: no MCP attempt existed, so no successor barrier runs" - ); - - repo.write("file.txt", "one\ntracked-successor\n"); - repo.drive(&tracked_successor.post()) - .expect("the tracked successor's PostToolUse should close its scope"); - - let db = repo.db(); - let worktree_id = repo.worktree_id(); - assert_eq!(count(&db, "mutation_trace_scopes"), 1); - assert_eq!( - mutation_events_for(&db, &worktree_id), - vec![( - "ai_exclusive".to_string(), - Some(scope_id), - "close".to_string(), - )], - "AC9d: the tracked successor is the only live scope — no false AiContended" - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test12_parallel_mcp_executions_create_no_scopes_ac9e() { - let repo = CodexRepo::new("test12-parallel-mcp"); - let cwd = repo.cwd(); - - repo.drive(&fixture_at(PROBE16_MCP_PARALLEL_A_PRE, &cwd)) - .expect("parallel MCP A is allowed"); - repo.drive(&fixture_at(PROBE16_MCP_PARALLEL_B_PRE, &cwd)) - .expect("parallel MCP B is allowed"); - assert!( - !repo.adapter_state_file_exists(), - "AC9e: neither overlapping MCP execution creates adapter state" - ); - - repo.write("mcp_parallel_a.txt", "a\n"); - repo.write("mcp_parallel_b.txt", "b\n"); - - repo.drive(&fixture_at(PROBE16_MCP_PARALLEL_A_POST, &cwd)) - .expect("parallel MCP A PostToolUse is ignored"); - repo.drive(&fixture_at(PROBE16_MCP_PARALLEL_B_POST, &cwd)) - .expect("parallel MCP B PostToolUse is ignored"); - - assert!(!repo.adapter_state_file_exists()); - - let db = repo.db(); - assert_eq!( - count(&db, "mutation_trace_scopes"), - 0, - "AC9e: overlapping MCP executions produce no scopes and therefore no AiContended" - ); - assert_eq!(count(&db, "mutation_trace_events"), 0); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test13_tracked_scope_overlapping_an_mcp_mutation_is_tracked_exclusivity_ac9f() { - let repo = CodexRepo::new("test13-tracked-plus-mcp"); - let cwd = repo.cwd(); - - repo.drive(&fixture_at(PROBE01_SHELL_PRE, &cwd)) - .expect("the tracked Bash PreToolUse should Start"); - let scope_id = repo.live_scope_id(); - - repo.drive(&fixture_at(PROBE12_MCP_PRE, &cwd)) - .expect("the overlapping MCP call is allowed and untracked"); - repo.write("mcp_a.txt", "written by the MCP server, not by Bash\n"); - repo.drive(&fixture_at(PROBE12_MCP_POST, &cwd)) - .expect("the MCP PostToolUse is ignored"); - - repo.drive(&fixture_at(PROBE01_SHELL_POST, &cwd)) - .expect("the tracked Bash PostToolUse should close its scope"); - - let db = repo.db(); - let worktree_id = repo.worktree_id(); - assert_eq!(count(&db, "mutation_trace_scopes"), 1); - assert_eq!( - mutation_events_for(&db, &worktree_id), - vec![( - "ai_exclusive".to_string(), - Some(scope_id), - "close".to_string(), - )], - "AC9f: ai_exclusive means exactly one TRACKED scope was live in the interval, \ - not that the tracked scope authored every mutation — the MCP call did mutate \ - mcp_a.txt inside this interval and remains unattributed (D14/D23)" - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test14_unknown_tool_is_allowed_untracked_ac9b() { - let repo = CodexRepo::new("test14-unknown-tool"); - let cwd = repo.cwd(); - let unknown = TrackedCall { - cwd: &cwd, - session_id: "session-unknown", - turn_id: "turn-1", - tool_name: "some_future_codex_tool", - tool_use_id: "exec-unknown", - agent_id: None, - }; - - assert_eq!( - repo.drive(&unknown.pre()) - .expect("an unknown tool is never denied for being untracked"), - "" - ); - repo.write("unknown_tool_output.txt", "the unknown tool mutated\n"); - assert_eq!( - repo.drive(&unknown.post()) - .expect("an unknown tool's PostToolUse is ignored"), - "" - ); - - assert!(!repo.adapter_state_file_exists()); - - let db = repo.db(); - assert_eq!(count(&db, "mutation_trace_scopes"), 0); - assert_eq!(count(&db, "mutation_trace_events"), 0); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test15_regression_matrix_leaves_raw_agent_trace_tables_untouched_ac20() { - let repo = CodexRepo::new("test15-raw-tables"); - let cwd = repo.cwd(); - - let before = raw_agent_trace_row_counts(&repo.db()); - assert_eq!(before, [0, 0, 0, 0, 0]); - - repo.drive(&fixture_at(PROBE01_SHELL_PRE, &cwd)) - .expect("tracked PreToolUse should succeed"); - repo.write("file.txt", "one\ntwo\n"); - repo.drive(&fixture_at(PROBE01_SHELL_POST, &cwd)) - .expect("tracked PostToolUse should succeed"); - repo.drive(&fixture_at(PROBE12_MCP_PRE, &cwd)) - .expect("MCP PreToolUse should succeed"); - repo.write("mcp_a.txt", "mcp\n"); - repo.drive(&fixture_at(PROBE12_MCP_POST, &cwd)) - .expect("MCP PostToolUse should succeed"); - repo.drive(&fixture_at(PROBE01_APPLY_PATCH_PRE, &cwd)) - .expect("apply_patch PreToolUse should succeed"); - repo.drive(&fixture_at(PROBE01_STOP, &cwd)) - .expect("Stop should succeed"); - - let db = repo.db(); - assert_eq!( - raw_agent_trace_row_counts(&db), - before, - "AC20: mutation-scope-only regressions leave diff_traces, \ - post_commit_patch_intersections, agent_traces, messages and parts unchanged" - ); - assert!(count(&db, "mutation_trace_scopes") > 0); - assert!( - state::adapter_state_dir(&repo.git_dir()).starts_with(repo.git_dir()), - "AC20: adapter state lives only below /sce/" - ); - } - - #[test] - fn test16_arbitrary_blocker_zombie_then_same_lane_successor_ac9a() { - let repo = CodexRepo::new("test16-zombie-successor"); - let cwd = repo.cwd(); - let zombie = bash_call(&cwd, "session-lane", "exec-zombie"); - let successor = bash_call(&cwd, "session-lane", "exec-successor"); - - repo.drive(&zombie.pre()) - .expect("the first tracked PreToolUse should Start"); - let zombie_scope_id = repo.live_scope_id(); - repo.write("file.txt", "one\nzombie-partial\n"); - - repo.drive(&successor.pre()) - .expect("the same-lane successor should sweep, flush, then Start"); - let successor_scope_id = repo.live_scope_id(); - assert_ne!(zombie_scope_id, successor_scope_id); - assert!( - repo.adapter_state().recovery.is_clear(), - "the quiescent flush must clear the barrier before Start(B)" - ); - - repo.write("file.txt", "one\nzombie-partial\nsuccessor\n"); - repo.drive(&successor.post()) - .expect("the successor's PostToolUse should close its scope"); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &zombie_scope_id), - Some(("codex".to_string(), "abandoned".to_string())), - "AC9a: the stale same-lane predecessor is abandoned, never closed" - ); - assert_eq!( - scope_status(&db, &successor_scope_id), - Some(("codex".to_string(), "closed".to_string())) - ); - let worktree_id = repo.worktree_id(); - let events = mutation_events_for(&db, &worktree_id); - assert!( - events.iter().all(|(kind, scope, _)| kind != "ai_contended" - && scope.as_deref() != Some(zombie_scope_id.as_str())), - "AC9a: no false AiContended and nothing attributed to the zombie: {events:?}" - ); - assert_eq!( - events.last(), - Some(&( - "ai_exclusive".to_string(), - Some(successor_scope_id), - "close".to_string() - )), - "the successor is the only live scope at its own Close" - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test17_crash_before_start_commit_is_recovered_conservatively_ac21a() { - let repo = CodexRepo::new("test17-crash-before-start"); - let cwd = repo.cwd(); - let git_dir = repo.git_dir(); - let crashed = bash_call(&cwd, "session-crash", "exec-crashed"); - let key = key("session-crash", None, "exec-crashed"); - - let attempt = state::seed_attempt_for_tests( - &git_dir, - &key, - "turn-1", - CODEX_TRACKED_TOOL_BASH, - state::AttemptPhase::PendingStart, - ); - - repo.drive(&crashed.post()) - .expect("D11: a pending_start attempt must abandon, not late-Start"); - - assert!(repo.adapter_state().attempts.is_empty()); - assert!(!repo.adapter_state().recovery.is_clear()); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &attempt.scope_id), - None, - "AC21a: a Start that never committed must never appear as a real scope" - ); - assert_eq!(count(&db, "mutation_trace_events"), 0); - - let fresh = bash_call(&cwd, "session-crash", "exec-fresh"); - repo.drive(&fresh.pre()) - .expect("the next tracked PreToolUse proceeds after the quiescent flush"); - assert!(repo.adapter_state().recovery.is_clear()); - assert_eq!(repo.adapter_state().attempts.len(), 1); - - assert_raw_agent_trace_tables_untouched(&repo.db()); - } - - #[test] - fn test18_start_committed_before_state_settlement_is_abandoned_ac21b() { - let repo = CodexRepo::new("test18-crash-after-start"); - let cwd = repo.cwd(); - let git_dir = repo.git_dir(); - let crashed = bash_call(&cwd, "session-crash", "exec-crashed"); - let key = key("session-crash", None, "exec-crashed"); - - let attempt = state::seed_attempt_for_tests( - &git_dir, - &key, - "turn-1", - CODEX_TRACKED_TOOL_BASH, - state::AttemptPhase::PendingStart, - ); - let scope_id = attempt.scope_id.clone(); - - repo.drive_generic(&scope_boundary_payload( - "start", - &scope_id, - &codex_scope_start_event_id(&scope_id), - )) - .expect("the runtime Start should commit durably"); - assert_eq!( - repo.adapter_state().attempts[0].phase, - state::AttemptPhase::PendingStart - ); - - repo.drive(&crashed.post()) - .expect("D11: a committed Start with unsettled bookkeeping must be abandoned"); - - assert!(repo.adapter_state().attempts.is_empty()); - assert!(!repo.adapter_state().recovery.is_clear()); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &scope_id), - Some(("codex".to_string(), "abandoned".to_string())), - "AC21b: the committed Start settles as a real abandonment, not a late Start" - ); - assert!(worktree_row(&db, &repo.worktree_id()) - .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline)); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test19_close_committed_before_state_cleanup_is_replay_safe_ac21c() { - let repo = CodexRepo::new("test19-crash-after-close"); - let cwd = repo.cwd(); - let call = bash_call(&cwd, "session-close", "exec-close"); - - repo.drive(&call.pre()).expect("PreToolUse should succeed"); - let scope_id = repo.live_scope_id(); - repo.write("file.txt", "one\ntwo\n"); - - repo.drive_generic(&scope_boundary_payload( - "close", - &scope_id, - &codex_scope_close_event_id(&scope_id), - )) - .expect("the runtime Close should commit durably"); - assert_eq!(repo.adapter_state().attempts.len(), 1); - - let db = repo.db(); - let (revision_before, events_before) = ( - worktree_row(&db, &repo.worktree_id()) - .map(|(revision, _, _)| revision) - .expect("a worktree row should exist"), - count(&db, "mutation_trace_events"), - ); - - repo.drive(&call.post()) - .expect("a replayed Close against an already-durable commit must be safe"); - - assert!( - repo.adapter_state().attempts.is_empty(), - "the stale bookkeeping is finally cleared" - ); - - let db = repo.db(); - assert_eq!( - worktree_row(&db, &repo.worktree_id()).map(|(revision, _, _)| revision), - Some(revision_before), - "AC21c: a durably completed Close is never re-applied as a second transition" - ); - assert_eq!(count(&db, "mutation_trace_events"), events_before); - assert_eq!( - scope_status(&db, &scope_id).map(|(_, status)| status), - Some("closed".to_string()) - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test20_recovery_pending_blocks_a_tracked_successor_until_recovery_succeeds_ac12() { - let repo = CodexRepo::new("test20-recovery-barrier"); - let cwd = repo.cwd(); - let first = bash_call(&cwd, "session-a", "exec-a"); - let second = bash_call(&cwd, "session-b", "exec-b"); - let blocked = bash_call(&cwd, "session-c", "exec-c"); - - repo.drive(&first.pre()).expect("session-a Start"); - repo.drive(&second.pre()).expect("session-b Start"); - assert_eq!(repo.adapter_state().attempts.len(), 2); - - repo.write("file.txt", "one\nabandoned\n"); - repo.drive(&turn_event_json( - HOOK_EVENT_INTERRUPT, - &cwd, - "session-a", - "turn-1", - )) - .expect("Interrupt should retire session-a's attempt"); - assert!(!repo.adapter_state().recovery.is_clear()); - assert_eq!(repo.adapter_state().attempts.len(), 1); - - assert_eq!( - repo.drive(&blocked.pre()) - .expect("a barred PreToolUse still returns Ok with a deny payload"), - pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), - "AC12: while recovery is armed and attempts remain, a tracked successor is denied" - ); - assert!( - repo.adapter_state() - .attempts - .iter() - .all(|attempt| attempt.tool_use_id != "exec-c"), - "the denied successor must never be admitted" - ); - - repo.drive(&session_end_json(&cwd, "session-b")) - .expect("SessionEnd should retire session-b's attempt"); - assert!(repo.adapter_state().attempts.is_empty()); - assert!(!repo.adapter_state().recovery.is_clear()); - - repo.drive(&blocked.pre()) - .expect("once quiescent, the flush runs and the successor Starts"); - assert!( - repo.adapter_state().recovery.is_clear(), - "AC12: recovery_pending clears only on durable flush success" - ); - let scope_id = repo.live_scope_id(); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &scope_id).map(|(_, status)| status), - Some("active".to_string()) - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test21_reused_tool_use_id_after_terminal_gets_a_fresh_scope_id_ac5() { - let repo = CodexRepo::new("test21-reused-identifier"); - let cwd = repo.cwd(); - let call = bash_call(&cwd, "session-reuse", "exec-reused"); - - repo.drive(&call.pre()).expect("first PreToolUse"); - let first_scope_id = repo.live_scope_id(); - repo.write("file.txt", "one\nfirst\n"); - repo.drive(&call.post()).expect("first PostToolUse"); - assert!(repo.adapter_state().attempts.is_empty()); - - repo.drive(&call.pre()) - .expect("a later attempt reusing the same tool_use_id"); - let second_scope_id = repo.live_scope_id(); - assert_ne!( - first_scope_id, second_scope_id, - "AC5: a terminal ScopeId is never reused" - ); - - repo.write("file.txt", "one\nfirst\nsecond\n"); - repo.drive(&call.post()).expect("second PostToolUse"); - - let db = repo.db(); - assert_eq!( - scope_status(&db, &first_scope_id).map(|(_, status)| status), - Some("closed".to_string()) - ); - assert_eq!( - scope_status(&db, &second_scope_id).map(|(_, status)| status), - Some("closed".to_string()) - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test22_self_detaching_descendant_write_is_not_folded_into_the_closed_scope_ac15() { - let repo = CodexRepo::new("test22-detached-descendant"); - let cwd = repo.cwd(); - - repo.drive(&fixture_at(PROBE09_DETACHED_PRE, &cwd)) - .expect("PreToolUse should succeed"); - let scope_id = repo.live_scope_id(); - - repo.write("file.txt", "one\nforeground\n"); - let tree_at_close = repo.working_tree(); - repo.drive(&fixture_at(PROBE09_DETACHED_POST, &cwd)) - .expect("PostToolUse should succeed"); - - let db = repo.db(); - let worktree_id = repo.worktree_id(); - assert_eq!( - worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), - Some(tree_at_close.clone()) - ); - let events_before_flush = mutation_events_for(&db, &worktree_id); - - repo.write("file.txt", "one\nforeground\ndetached-descendant\n"); - let tree_after_descendant = repo.working_tree(); - assert_ne!(tree_after_descendant, tree_at_close); - - repo.drive_flush() - .expect("a later diagnostic flush should succeed"); - - let db = repo.db(); - let events_after_flush = mutation_events_for(&db, &worktree_id); - assert_eq!(events_after_flush.len(), events_before_flush.len() + 1); - let (attribution_kind, attribution_scope_id, _) = events_after_flush - .last() - .expect("a flush event should exist"); - assert_eq!( - attribution_kind, "ineligible_unscoped", - "AC15/D16: SCE does not supervise self-detaching descendants; \ - a post-terminal write is never folded into the closed tool scope" - ); - assert_ne!(attribution_scope_id.as_deref(), Some(scope_id.as_str())); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test23_denied_tracked_execution_leaves_no_untracked_start_ac7() { - let repo = CodexRepo::new("test23-policy-denied"); - let cwd = repo.cwd(); - fs::create_dir_all(repo.root.join(".sce")).expect(".sce dir should be created"); - fs::write( - repo.root.join(".sce").join("config.json"), - concat!( - r#"{"policies":{"bash":{"custom":[{"id":"no-rm","#, - r#""match":{"argv_prefix":["rm"]},"#, - r#""message":"rm is blocked in this repository"}]}}}"#, - ), - ) - .expect("repo bash policy config should write"); - - let denied = tool_event_json( - &ToolEvent { - event_name: HOOK_EVENT_PRE_TOOL_USE, - cwd: &cwd, - session_id: "session-denied", - turn_id: "turn-1", - tool_name: CODEX_TRACKED_TOOL_BASH, - tool_use_id: "exec-denied", - agent_id: None, - }, - Some(json!({ "command": "rm -rf build" })), - ); - - let response = repo - .drive(&denied) - .expect("a policy-denied Bash command still returns Ok with a deny payload"); - assert!(response.contains(r#""permissionDecision":"deny""#)); - - assert!( - !repo.adapter_state_file_exists(), - "AC7: a denied tracked execution must never leave an untracked Start behind" - ); - - let db = repo.db(); - assert_eq!(count(&db, "mutation_trace_scopes"), 0); - assert_eq!(count(&db, "mutation_trace_events"), 0); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test24_cross_harness_overlap_at_a_non_confirming_boundary_is_ineligible_ac10() { - let repo = CodexRepo::new("test24-cross-harness-ineligible"); - let cwd = repo.cwd(); - let codex_call = bash_call(&cwd, "session-cross", "exec-codex"); - let other_scope_id = "claude-scope-1"; - - repo.drive_generic(&other_harness_payload("start", other_scope_id)) - .expect("the other harness's Start should commit"); - repo.drive(&codex_call.pre()) - .expect("the Codex PreToolUse should Start"); - let codex_scope_id = repo.live_scope_id(); - - repo.write("file.txt", "one\ncontended\n"); - - repo.drive_generic(&other_harness_payload("close", other_scope_id)) - .expect("the other harness's Close should commit"); - - let db = repo.db(); - let worktree_id = repo.worktree_id(); - assert_eq!( - mutation_events_for(&db, &worktree_id), - vec![("ineligible_unscoped".to_string(), None, "close".to_string())], - "AC10/D14: an unconfirmed live Codex scope forces IneligibleUnscoped at a \ - boundary that does not confirm it — never AiContended" - ); - let mut active = active_scopes_for(&db, &worktree_id); - active.sort(); - let mut expected = vec![codex_scope_id, other_scope_id.to_string()]; - expected.sort(); - assert_eq!( - active, expected, - "active_scopes still records the complete live set; only eligibility changes" - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test25_cross_harness_overlap_at_the_codex_close_is_contended_ac10() { - let repo = CodexRepo::new("test25-cross-harness-contended"); - let cwd = repo.cwd(); - let codex_call = bash_call(&cwd, "session-cross", "exec-codex"); - let other_scope_id = "claude-scope-1"; - - repo.drive_generic(&other_harness_payload("start", other_scope_id)) - .expect("the other harness's Start should commit"); - repo.drive(&codex_call.pre()) - .expect("the Codex PreToolUse should Start"); - let codex_scope_id = repo.live_scope_id(); - - repo.write("file.txt", "one\ncontended\n"); - - repo.drive(&codex_call.post()) - .expect("the Codex PostToolUse should close its scope"); - - let db = repo.db(); - let worktree_id = repo.worktree_id(); - assert_eq!( - mutation_events_for(&db, &worktree_id), - vec![("ai_contended".to_string(), None, "close".to_string())], - "AC10/D14: the Codex scope's own Close confirms it, so the overlap with the \ - other harness's live scope is attributed AiContended" - ); - assert_eq!( - scope_status(&db, &codex_scope_id).map(|(_, status)| status), - Some("closed".to_string()) - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test26_a_second_unconfirmed_codex_scope_suppresses_contention_ac10() { - let repo = CodexRepo::new("test26-second-codex-scope"); - let cwd = repo.cwd(); - let confirmed = bash_call(&cwd, "session-one", "exec-one"); - let unconfirmed = bash_call(&cwd, "session-two", "exec-two"); - let other_scope_id = "claude-scope-1"; - - repo.drive_generic(&other_harness_payload("start", other_scope_id)) - .expect("the other harness's Start should commit"); - repo.drive(&confirmed.pre()) - .expect("the first Codex PreToolUse should Start"); - repo.drive(&unconfirmed.pre()) - .expect("a second Codex lane's PreToolUse should Start"); - assert_eq!(repo.adapter_state().attempts.len(), 2); - - repo.write("file.txt", "one\ncontended\n"); - - repo.drive(&confirmed.post()) - .expect("the first Codex scope's Close should commit"); - - let db = repo.db(); - let worktree_id = repo.worktree_id(); - assert_eq!( - mutation_events_for(&db, &worktree_id), - vec![("ineligible_unscoped".to_string(), None, "close".to_string())], - "AC10/D14: a second unconfirmed live Codex scope suppresses attribution back to \ - IneligibleUnscoped even at a confirming Codex Close" - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test27_tracked_fixtures_persist_scope_provenance_ac3() { - for (label, fixture) in [ - ("bash", PROBE01_SHELL_PRE), - ("apply-patch", PROBE01_APPLY_PATCH_PRE), - ] { - let repo = CodexRepo::new(&format!("test27-provenance-{label}")); - let cwd = repo.cwd(); - - repo.drive(&fixture_at(fixture, &cwd)) - .expect("a tracked PreToolUse should Start"); - let scope_id = repo.live_scope_id(); - - let db = repo.db(); - assert_eq!( - scope_provenance(&db, &scope_id), - Some(( - "cx_01a07c1e-e08e-7172-8032-cb9d62af21d9".to_string(), - Some("gpt-5.6-sol".to_string()) - )), - "AC3: the {label} fixture must persist its cx_ session and normalized model" - ); - assert_eq!(count(&db, "mutation_trace_scope_provenance"), 1); - assert_eq!( - scope_status(&db, &scope_id), - Some(("codex".to_string(), "active".to_string())) - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - } - - #[test] - fn test28_a_tracked_execution_without_a_model_persists_a_null_model_ac3() { - let repo = CodexRepo::new("test28-provenance-no-model"); - let cwd = repo.cwd(); - let call = bash_call(&cwd, "session-no-model", "exec-no-model"); - - repo.drive(&call.pre()) - .expect("a tracked PreToolUse without a model should still Start"); - let scope_id = repo.live_scope_id(); - - let db = repo.db(); - assert_eq!( - scope_provenance(&db, &scope_id), - Some(("cx_session-no-model".to_string(), None)), - "AC3: a missing model records model_id = NULL without losing the session" - ); - - assert_raw_agent_trace_tables_untouched(&db); - } - - #[test] - fn test29_untracked_and_delegation_tools_persist_no_provenance_ac3() { - let repo = CodexRepo::new("test29-untracked-no-provenance"); - let cwd = repo.cwd(); - - for fixture in [ - PROBE12_MCP_PRE, - PROBE08_SPAWN_AGENT_PRE, - PROBE08_WAIT_AGENT_PRE, - ] { - assert_eq!( - repo.drive(&fixture_at(fixture, &cwd)) - .expect("an untracked or delegation PreToolUse should succeed"), - "" - ); - } - - let db = repo.db(); - assert_eq!(count(&db, "mutation_trace_scopes"), 0); - assert_eq!(count(&db, "mutation_trace_scope_provenance"), 0); - - assert_raw_agent_trace_tables_untouched(&db); - } - } -} +mod tests; diff --git a/cli/src/services/hooks/codex_mutation_scope/payload.rs b/cli/src/services/hooks/codex_mutation_scope/payload.rs new file mode 100644 index 000000000..9d4a67346 --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/payload.rs @@ -0,0 +1,55 @@ +use serde_json::json; + +use super::events::PROVENANCE_FIELD; +use super::lifecycle::{CodexScopeProvenance, ACTOR_KIND_CODEX}; + +pub(super) fn scope_boundary_payload(operation: &str, scope_id: &str, event_id: &str) -> String { + json!({ + "operation": operation, + "scope_id": scope_id, + "event_id": event_id, + "actor_kind": ACTOR_KIND_CODEX, + }) + .to_string() +} + +pub(super) fn scope_start_payload( + scope_id: &str, + event_id: &str, + provenance: &CodexScopeProvenance, +) -> String { + json!({ + "operation": "start", + "scope_id": scope_id, + "event_id": event_id, + "actor_kind": ACTOR_KIND_CODEX, + PROVENANCE_FIELD: { + "session_id": provenance.session_id, + "model_id": provenance.model_id, + }, + }) + .to_string() +} + +pub(super) fn abandon_payload(scope_id: &str) -> String { + json!({ + "operation": "abandon", + "scope_id": scope_id, + }) + .to_string() +} + +pub(super) fn flush_payload() -> String { + json!({ "operation": "flush" }).to_string() +} + +pub(super) fn pre_tool_use_deny_json(reason: &str) -> String { + json!({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } + }) + .to_string() +} diff --git a/cli/src/services/hooks/codex_mutation_scope/tests.rs b/cli/src/services/hooks/codex_mutation_scope/tests.rs new file mode 100644 index 000000000..5aa72d25e --- /dev/null +++ b/cli/src/services/hooks/codex_mutation_scope/tests.rs @@ -0,0 +1,4754 @@ +use super::*; + +const PROBE01_SHELL_PRE: &str = + include_str!("fixtures/probe01-apply-patch-and-shell-success.shell.pre_tool_use.json"); +const PROBE01_SHELL_POST: &str = + include_str!("fixtures/probe01-apply-patch-and-shell-success.shell.post_tool_use.json"); +const PROBE01_APPLY_PATCH_PRE: &str = + include_str!("fixtures/probe01-apply-patch-and-shell-success.apply_patch.pre_tool_use.json"); +const PROBE01_STOP: &str = include_str!("fixtures/probe01-apply-patch-and-shell-success.stop.json"); +const PROBE01_SESSION_END: &str = + include_str!("fixtures/probe01-apply-patch-and-shell-success.session_end.json"); +const PROBE02_FAILED_SHELL_POST: &str = + include_str!("fixtures/probe02-shell-partial-write-then-nonzero-exit.post_tool_use.json"); +const PROBE04_BLOCKED_PRE: &str = + include_str!("fixtures/probe04-pre-tool-use-hook-hookspecificoutput-deny.pre_tool_use.json"); +const PROBE05_SHELL_PRE: &str = + include_str!("fixtures/probe05-tool-vocabulary.shell-read-list-search.pre_tool_use.json"); +const PROBE08_SPAWN_AGENT_PRE: &str = + include_str!("fixtures/probe08-subagent-delegation.spawn_agent.pre_tool_use.json"); +const PROBE08_WAIT_AGENT_PRE: &str = + include_str!("fixtures/probe08-subagent-delegation.wait_agent.pre_tool_use.json"); +const PROBE08_AGENT_APPLY_PATCH_PRE: &str = + include_str!("fixtures/probe08-subagent-delegation.agent-apply-patch.pre_tool_use.json"); +const PROBE08_AGENT_APPLY_PATCH_POST: &str = + include_str!("fixtures/probe08-subagent-delegation.agent-apply-patch.post_tool_use.json"); +const PROBE08_SUBAGENT_STOP: &str = + include_str!("fixtures/probe08-subagent-delegation.subagent_stop.json"); +const PROBE10_WORKTREE_PRE: &str = + include_str!("fixtures/probe10-linked-worktree-cwd.pre_tool_use.json"); +const PROBE11_INTERRUPT: &str = + include_str!("fixtures/probe11-interrupt-event-on-sigint.interrupt.json"); +const PROBE12_MCP_PRE: &str = include_str!("fixtures/probe12-mcp-mutate-success.pre_tool_use.json"); +const PROBE12_MCP_POST: &str = + include_str!("fixtures/probe12-mcp-mutate-success.post_tool_use.json"); +const PROBE13_MCP_MUTATE_THEN_ERROR_PRE: &str = + include_str!("fixtures/probe13-mcp-mutate-then-error.pre_tool_use.json"); +const PROBE13_MCP_SESSION_END: &str = + include_str!("fixtures/probe13-mcp-mutate-then-error.session_end.json"); + +fn pre_tool_use_json(overrides: &[(&str, Value)]) -> String { + let mut object = Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(HOOK_EVENT_PRE_TOOL_USE.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("session-1".to_string()), + ); + object.insert( + TURN_ID_FIELD.to_string(), + Value::String("turn-1".to_string()), + ); + object.insert( + CWD_FIELD.to_string(), + Value::String("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/repo/checkout".to_string()), + ); + object.insert( + TOOL_NAME_FIELD.to_string(), + Value::String("Bash".to_string()), + ); + object.insert( + TOOL_USE_ID_FIELD.to_string(), + Value::String("exec-1".to_string()), + ); + object.insert(TOOL_INPUT_FIELD.to_string(), json!({"command": "true"})); + for (field, value) in overrides { + object.insert((*field).to_string(), value.clone()); + } + Value::Object(object).to_string() +} + +fn key(session_id: &str, agent_id: Option<&str>, tool_use_id: &str) -> AttemptKey { + AttemptKey { + session_id: session_id.to_string(), + agent_id: agent_id.map(str::to_string), + tool_use_id: tool_use_id.to_string(), + } +} + +fn pre_tool_use(payload: &str) -> CodexToolExecution { + match parse_codex_hook_event(payload).expect("valid PreToolUse parses") { + CodexHookEvent::PreToolUse(execution) => execution, + other => panic!("expected PreToolUse, got {other:?}"), + } +} + +#[test] +fn ac2_empty_payload_is_rejected() { + let error = parse_codex_hook_event(" ").unwrap_err().to_string(); + assert_eq!( + error, + "Invalid Codex hook event payload from STDIN: expected a JSON object, got an empty payload." + ); +} + +#[test] +fn ac2_non_object_json_is_rejected() { + for payload in ["[]", "\"PreToolUse\"", "42", "null"] { + let error = parse_codex_hook_event(payload).unwrap_err().to_string(); + assert!( + error.contains("expected a JSON object"), + "payload {payload:?} produced {error:?}" + ); + } +} + +#[test] +fn ac2_invalid_json_is_rejected() { + let error = parse_codex_hook_event("{not json").unwrap_err().to_string(); + assert!( + error.contains("Invalid Codex hook event payload from STDIN: expected valid JSON"), + "{error:?}" + ); +} + +#[test] +fn ac2_unsupported_hook_event_name_is_rejected() { + for name in [ + "SessionStart", + "SubagentStart", + "UserPromptSubmit", + "PreCompact", + ] { + let payload = + pre_tool_use_json(&[(HOOK_EVENT_NAME_FIELD, Value::String(name.to_string()))]); + let error = parse_codex_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains(&format!("unsupported hook_event_name '{name}'")), + "{error:?}" + ); + } +} + +#[test] +fn ac2_missing_required_fields_are_rejected_without_fabricating_identity() { + for field in [ + SESSION_ID_FIELD, + TURN_ID_FIELD, + CWD_FIELD, + TOOL_NAME_FIELD, + TOOL_USE_ID_FIELD, + ] { + let mut object: Map = serde_json::from_str(&pre_tool_use_json(&[])).unwrap(); + object.remove(field); + let payload = Value::Object(object).to_string(); + + let error = parse_codex_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains(&format!("'{field}'")), + "missing {field} produced {error:?}" + ); + } +} + +#[test] +fn ac2_blank_required_fields_are_rejected() { + for field in [SESSION_ID_FIELD, TURN_ID_FIELD, CWD_FIELD, TOOL_NAME_FIELD] { + let payload = pre_tool_use_json(&[(field, Value::String(" ".to_string()))]); + let error = parse_codex_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains(&format!("field '{field}' must be a non-blank string")), + "blank {field} produced {error:?}" + ); + } +} + +#[test] +fn ac2_wrong_typed_fields_are_rejected() { + let payload = pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::Bool(true))]); + let error = parse_codex_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains("field 'tool_use_id' must be a string"), + "{error:?}" + ); +} + +#[test] +fn ac2_wrong_typed_optional_agent_id_is_rejected() { + let payload = pre_tool_use_json(&[(AGENT_ID_FIELD, Value::Bool(false))]); + let error = parse_codex_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains("field 'agent_id' must be null, absent, or a non-blank string"), + "{error:?}" + ); +} + +#[test] +fn ac2_pre_tool_use_fixtures_parse_to_expected_identity() { + let shell = pre_tool_use(PROBE01_SHELL_PRE); + assert_eq!(shell.identity.tool_name, "Bash"); + assert_eq!( + shell.identity.tool_use_id, + "exec-414820f5-555e-457a-92e7-60ddd27d4eec" + ); + assert_eq!( + shell.identity.session_id, + "01a07c1e-e08e-7172-8032-cb9d62af21d9" + ); + assert_eq!( + shell.identity.turn_id, + "01a07c1e-e0cc-75f1-a566-e790c06cb033" + ); + assert_eq!(shell.identity.agent_id, None); + assert!(!shell.identity.is_subagent()); + assert!(shell.identity.cwd.ends_with("/probe-repo")); + + let apply_patch = pre_tool_use(PROBE01_APPLY_PATCH_PRE); + assert_eq!(apply_patch.identity.tool_name, "apply_patch"); + + let vocab = pre_tool_use(PROBE05_SHELL_PRE); + assert_eq!(vocab.identity.tool_name, "Bash"); + + let worktree = pre_tool_use(PROBE10_WORKTREE_PRE); + assert!(worktree.identity.cwd.ends_with("/probe-worktree")); + + let mcp = pre_tool_use(PROBE12_MCP_PRE); + assert_eq!(mcp.identity.tool_name, "mcp__probe__mutate_success"); + + let mcp_err = pre_tool_use(PROBE13_MCP_MUTATE_THEN_ERROR_PRE); + assert!(is_mcp_tool_name(&mcp_err.identity.tool_name)); +} + +#[test] +fn ac2_subagent_pre_tool_use_fixture_carries_agent_identity() { + let execution = pre_tool_use(PROBE08_AGENT_APPLY_PATCH_PRE); + assert_eq!( + execution.identity.agent_id.as_deref(), + Some("01a07c24-bb59-7ca0-80f7-99cf940a486e") + ); + assert!(execution.identity.is_subagent()); + assert_eq!(execution.agent_type.as_deref(), Some("default")); +} + +#[test] +fn ac3_pre_tool_use_fixtures_retain_the_codex_model() { + for fixture in [ + PROBE01_SHELL_PRE, + PROBE01_APPLY_PATCH_PRE, + PROBE05_SHELL_PRE, + PROBE08_AGENT_APPLY_PATCH_PRE, + ] { + assert_eq!(pre_tool_use(fixture).model.as_deref(), Some("gpt-5.6-sol")); + } +} + +#[test] +fn ac3_scope_provenance_canonicalizes_the_session_and_normalizes_the_model() { + let provenance = codex_scope_provenance(&pre_tool_use(PROBE01_SHELL_PRE)); + assert_eq!( + provenance.session_id, + "cx_01a07c1e-e08e-7172-8032-cb9d62af21d9" + ); + assert_eq!(provenance.model_id.as_deref(), Some("gpt-5.6-sol")); + + let apply_patch = codex_scope_provenance(&pre_tool_use(PROBE01_APPLY_PATCH_PRE)); + assert_eq!(apply_patch, provenance); +} + +#[test] +fn ac3_scope_provenance_keeps_an_already_prefixed_session_id() { + let execution = pre_tool_use(&pre_tool_use_json(&[( + SESSION_ID_FIELD, + Value::String("cx_session-1".to_string()), + )])); + assert_eq!( + codex_scope_provenance(&execution).session_id, + "cx_session-1" + ); +} + +#[test] +fn ac3_an_unusable_model_yields_no_model_id_without_rejecting_the_event() { + for model in [ + Value::Null, + Value::String(String::new()), + Value::String(" ".to_string()), + Value::Bool(true), + json!(7), + json!({ "id": "gpt-5.6-sol" }), + ] { + let payload = pre_tool_use_json(&[(MODEL_FIELD, model.clone())]); + let execution = pre_tool_use(&payload); + let provenance = codex_scope_provenance(&execution); + assert_eq!(provenance.model_id, None, "model {model:?}"); + assert_eq!(provenance.session_id, "cx_session-1", "model {model:?}"); + } + + let absent = pre_tool_use(&pre_tool_use_json(&[])); + assert_eq!(absent.model, None); + assert_eq!(codex_scope_provenance(&absent).model_id, None); +} + +#[test] +fn ac2_post_tool_use_fixtures_parse() { + for (payload, tool_name, tool_use_id) in [ + ( + PROBE01_SHELL_POST, + "Bash", + "exec-414820f5-555e-457a-92e7-60ddd27d4eec", + ), + ( + PROBE02_FAILED_SHELL_POST, + "Bash", + "exec-52155265-e98d-423f-87f8-76ee56ff33b1", + ), + ( + PROBE12_MCP_POST, + "mcp__probe__mutate_success", + "exec-00988fad-6707-48ed-81b6-07bb11933886", + ), + ] { + match parse_codex_hook_event(payload).expect("PostToolUse fixture parses") { + CodexHookEvent::PostToolUse(identity) => { + assert_eq!(identity.tool_name, tool_name); + assert_eq!(identity.tool_use_id, tool_use_id); + } + other => panic!("expected PostToolUse, got {other:?}"), + } + } +} + +#[test] +fn ac2_subagent_post_tool_use_ties_to_its_pre_tool_use() { + let CodexHookEvent::PostToolUse(post) = + parse_codex_hook_event(PROBE08_AGENT_APPLY_PATCH_POST).unwrap() + else { + panic!("expected PostToolUse"); + }; + let pre = pre_tool_use(PROBE08_AGENT_APPLY_PATCH_PRE); + assert_eq!(post.attempt_key(), pre.identity.attempt_key()); + assert!(post.attempt_key().agent_id.is_some()); +} + +#[test] +fn ac2_terminal_lifecycle_fixtures_parse() { + assert!(matches!( + parse_codex_hook_event(PROBE01_STOP).unwrap(), + CodexHookEvent::Stop(id) if id.turn_id == "01a07c1e-e0cc-75f1-a566-e790c06cb033" + )); + assert!(matches!( + parse_codex_hook_event(PROBE11_INTERRUPT).unwrap(), + CodexHookEvent::Interrupt(id) if id.session_id == "01a07c2f-ccbf-79f0-afb9-2d2ce919eea7" + )); + assert!(matches!( + parse_codex_hook_event(PROBE08_SUBAGENT_STOP).unwrap(), + CodexHookEvent::SubagentStop(id) + if id.agent_id == "01a07c24-bb59-7ca0-80f7-99cf940a486e" + )); + for session_end in [PROBE01_SESSION_END, PROBE13_MCP_SESSION_END] { + assert!(matches!( + parse_codex_hook_event(session_end).unwrap(), + CodexHookEvent::SessionEnd(_) + )); + } +} + +#[test] +fn ac2_session_end_needs_no_turn_id() { + let CodexHookEvent::SessionEnd(identity) = parse_codex_hook_event(PROBE01_SESSION_END).unwrap() + else { + panic!("expected SessionEnd"); + }; + assert_eq!(identity.session_id, "01a07c1e-e08e-7172-8032-cb9d62af21d9"); +} + +#[test] +fn ac3_classification_table() { + let cases: &[(&str, ToolClassification)] = &[ + ("Bash", ToolClassification::TrackedMutation), + ("apply_patch", ToolClassification::TrackedMutation), + ("collaborationspawn_agent", ToolClassification::Delegation), + ("collaborationwait_agent", ToolClassification::Delegation), + ("mcp__probe__mutate_success", ToolClassification::Untracked), + ("mcp__probe_par__slow_mutate", ToolClassification::Untracked), + ("mcp__", ToolClassification::Untracked), + ("Read", ToolClassification::Untracked), + ("PowerShell", ToolClassification::Untracked), + ("some_future_codex_tool", ToolClassification::Untracked), + ("", ToolClassification::Untracked), + ]; + for (tool_name, expected) in cases { + assert_eq!( + classify_tool(tool_name), + *expected, + "classify_tool({tool_name:?})" + ); + } +} + +#[test] +fn ac3_classification_is_total_and_single_valued() { + for tool_name in [ + "Bash", + "apply_patch", + "collaborationspawn_agent", + "collaborationwait_agent", + "mcp__x__y", + "unknown", + ] { + let _: ToolClassification = classify_tool(tool_name); + } +} + +#[test] +fn ac3_delegation_and_untracked_tool_fixtures_do_not_yield_a_tracked_scope() { + for payload in [ + PROBE08_SPAWN_AGENT_PRE, + PROBE08_WAIT_AGENT_PRE, + PROBE12_MCP_PRE, + PROBE13_MCP_MUTATE_THEN_ERROR_PRE, + ] { + let execution = pre_tool_use(payload); + let classification = classify_tool(&execution.identity.tool_name); + assert_ne!( + classification, + ToolClassification::TrackedMutation, + "tool {:?} must not be TrackedMutation", + execution.identity.tool_name + ); + } + + assert_eq!( + classify_tool(&pre_tool_use(PROBE04_BLOCKED_PRE).identity.tool_name), + ToolClassification::TrackedMutation + ); +} + +#[test] +fn ac3_is_mcp_tool_name() { + assert!(is_mcp_tool_name("mcp__probe__mutate_success")); + assert!(is_mcp_tool_name("mcp__")); + assert!(!is_mcp_tool_name("Bash")); + assert!(!is_mcp_tool_name("apply_patch")); + assert!(!is_mcp_tool_name("collaborationspawn_agent")); +} + +#[test] +fn ac4_scope_id_is_deterministic_for_the_same_attempt_seq_and_key() { + let k = key("session-1", None, "exec-1"); + assert_eq!(format_codex_scope_id(7, &k), format_codex_scope_id(7, &k)); + + let scope_id = format_codex_scope_id(7, &k); + assert_eq!(scope_id, "cx-tool-v1|n=7|s=9:session-1|a=0:|t=6:exec-1"); + assert_eq!( + codex_scope_start_event_id(&scope_id), + format!("{scope_id}|start") + ); + assert_eq!( + codex_scope_close_event_id(&scope_id), + format!("{scope_id}|close") + ); +} + +#[test] +fn ac4_length_prefix_disambiguates_delimiter_collisions() { + let a = key("a:b", None, "c"); + let b = key("a", None, "b:c"); + assert_ne!(format_codex_scope_id(1, &a), format_codex_scope_id(1, &b)); +} + +#[test] +fn ac4_subagent_key_encodes_the_agent_id() { + let main = key("session-1", None, "exec-1"); + let sub = key("session-1", Some("agent-1"), "exec-1"); + assert_ne!( + format_codex_scope_id(1, &main), + format_codex_scope_id(1, &sub) + ); + assert_eq!( + format_codex_scope_id(1, &sub), + "cx-tool-v1|n=1|s=9:session-1|a=7:agent-1|t=6:exec-1" + ); +} + +#[test] +fn ac5_a_fresh_attempt_seq_yields_a_new_scope_id() { + let k = key("session-1", None, "exec-1"); + assert_ne!(format_codex_scope_id(1, &k), format_codex_scope_id(2, &k)); + assert!(format_codex_scope_id(2, &k).contains("|n=2|")); +} + +#[test] +fn ac5_attempt_key_excludes_turn_id() { + let base = pre_tool_use(&pre_tool_use_json(&[( + TOOL_USE_ID_FIELD, + Value::String("exec-9".to_string()), + )])); + let other_turn = pre_tool_use(&pre_tool_use_json(&[ + (TOOL_USE_ID_FIELD, Value::String("exec-9".to_string())), + (TURN_ID_FIELD, Value::String("turn-99".to_string())), + ])); + assert_eq!( + base.identity.attempt_key(), + other_turn.identity.attempt_key() + ); +} + +mod driver { + use std::cell::RefCell; + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; + use std::sync::mpsc; + use std::sync::{Arc, Mutex}; + use std::thread; + use std::time::Duration; + + use anyhow::{anyhow, Result}; + + use super::*; + use crate::services::observability::traits::Logger; + + const CWD: &str = "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/repo/checkout"; + + static NEXT_TEST_GIT_DIR_ID: AtomicU64 = AtomicU64::new(0); + + fn unique_test_git_dir(label: &str) -> PathBuf { + let id = NEXT_TEST_GIT_DIR_ID.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "sce-codex-mutation-scope-driver-{label}-{}-{id}", + std::process::id() + )) + } + + fn remove_test_git_dir(git_dir: &Path) { + let _ = std::fs::remove_dir_all(git_dir); + } + + #[allow(clippy::unnecessary_wraps)] + fn ok_seam(_root: &Path, _payload: &str, _logger: Option<&dyn Logger>) -> Result { + Ok(String::new()) + } + + fn unreachable_seam( + _root: &Path, + payload: &str, + _logger: Option<&dyn Logger>, + ) -> Result { + panic!("the ingress seam must not be called for this payload: {payload}"); + } + + const BLOCKED_BASH_POLICY_RESPONSE: &str = concat!( + r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","#, + r#""permissionDecision":"deny","#, + r#""permissionDecisionReason":"Blocked by SCE bash-tool policy 'no-danger': danger is not allowed"}}"#, + ); + + #[allow(clippy::unnecessary_wraps)] + fn blocking_bash_policy(_root: &Path, _command: &str) -> Result { + Ok(CodexBashPolicyDecision::Blocked( + BLOCKED_BASH_POLICY_RESPONSE.to_string(), + )) + } + + #[allow(clippy::unnecessary_wraps)] + fn allow_bash_policy(_root: &Path, _command: &str) -> Result { + Ok(CodexBashPolicyDecision::Allowed) + } + + fn failing_bash_policy(_root: &Path, _command: &str) -> Result { + Err(anyhow!( + "repository Bash policy configuration is invalid and could not be evaluated" + )) + } + + fn unreachable_bash_policy(_root: &Path, command: &str) -> Result { + panic!("the Bash policy preflight must not run for this event (command: {command})"); + } + + fn seam_failing_on( + operation: &'static str, + ) -> impl Fn(&Path, &str, Option<&dyn Logger>) -> Result { + seam_failing_on_any(vec![operation]) + } + + fn seam_failing_on_any( + operations: Vec<&'static str>, + ) -> impl Fn(&Path, &str, Option<&dyn Logger>) -> Result { + move |_root, payload, _logger| { + if operations + .iter() + .any(|operation| payload.contains(&format!(r#""operation":"{operation}""#))) + { + Err(anyhow!( + "seam failure injected by test for one of {operations:?}" + )) + } else { + Ok(String::new()) + } + } + } + + fn recording_seam( + log: Arc>>, + ) -> impl Fn(&Path, &str, Option<&dyn Logger>) -> Result { + move |_root, payload, _logger| { + log.lock() + .expect("recording seam mutex") + .push(payload.to_string()); + Ok(String::new()) + } + } + + struct SeamGate { + entered: mpsc::Receiver<()>, + release: mpsc::Sender<()>, + } + + impl SeamGate { + fn wait_until_entered(&self) { + self.entered + .recv_timeout(Duration::from_secs(5)) + .expect("gated seam should be entered"); + } + + fn release(&self) { + let _ = self.release.send(()); + } + } + + #[allow(clippy::type_complexity)] + fn gated_seam( + operation: &'static str, + calls: Arc>>, + ) -> ( + impl Fn(&Path, &str, Option<&dyn Logger>) -> Result + Send, + SeamGate, + ) { + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let release_rx = Mutex::new(release_rx); + let seam = move |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| { + calls + .lock() + .expect("gated seam mutex") + .push(payload.to_string()); + if payload.contains(&format!(r#""operation":"{operation}""#)) { + entered_tx.send(()).expect("gate entry signal"); + release_rx + .lock() + .expect("gate release mutex") + .recv() + .expect("gate release signal"); + } + Ok(String::new()) + }; + ( + seam, + SeamGate { + entered: entered_rx, + release: release_tx, + }, + ) + } + + fn fixed_resolver(git_dir: PathBuf) -> impl Fn(&str) -> Result + Send + Clone { + move |_cwd| Ok(git_dir.clone()) + } + + fn panicking_resolver(_cwd: &str) -> Result { + panic!("resolve_git_dir must not be called for a non-tracked tool") + } + + #[derive(Clone, Default)] + struct RecordingLogger { + warnings: Arc>>, + } + + impl RecordingLogger { + fn warnings(&self) -> Vec<(String, String)> { + self.warnings + .lock() + .expect("recording logger mutex must not be poisoned") + .clone() + } + } + + impl Logger for RecordingLogger { + fn info(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} + fn debug(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} + + fn warn(&self, event_id: &str, message: &str, _: &[(&str, &str)], _: Option<&str>) { + self.warnings + .lock() + .expect("recording logger mutex must not be poisoned") + .push((event_id.to_string(), message.to_string())); + } + + fn error(&self, _: &str, _: &str, _: &[(&str, &str)], _: Option<&str>) {} + + fn log_cli_error(&self, _: &crate::services::error::CliError, _: Option<&str>) {} + } + + fn tool_event_json(event_name: &str, overrides: &[(&str, Value)]) -> String { + let mut merged: Vec<(&str, Value)> = + vec![(HOOK_EVENT_NAME_FIELD, Value::String(event_name.to_string()))]; + merged.extend( + overrides + .iter() + .map(|(field, value)| (*field, value.clone())), + ); + pre_tool_use_json(&merged) + } + + fn post_tool_use_json(overrides: &[(&str, Value)]) -> String { + tool_event_json(HOOK_EVENT_POST_TOOL_USE, overrides) + } + + fn turn_scoped_payload(event_name: &str, session_id: &str, turn_id: &str) -> String { + json!({ + HOOK_EVENT_NAME_FIELD: event_name, + SESSION_ID_FIELD: session_id, + TURN_ID_FIELD: turn_id, + CWD_FIELD: CWD, + }) + .to_string() + } + + fn subagent_stop_payload(session_id: &str, turn_id: &str, agent_id: &str) -> String { + json!({ + HOOK_EVENT_NAME_FIELD: HOOK_EVENT_SUBAGENT_STOP, + SESSION_ID_FIELD: session_id, + TURN_ID_FIELD: turn_id, + CWD_FIELD: CWD, + AGENT_ID_FIELD: agent_id, + }) + .to_string() + } + + fn session_end_payload(session_id: &str) -> String { + json!({ + HOOK_EVENT_NAME_FIELD: HOOK_EVENT_SESSION_END, + SESSION_ID_FIELD: session_id, + CWD_FIELD: CWD, + }) + .to_string() + } + + fn read_state(git_dir: &Path) -> state::AdapterState { + state::read_state(git_dir).expect("adapter state should be readable") + } + + const DRIVER_TURN: &str = "turn-1"; + + fn seed_attempt( + git_dir: &Path, + session_id: &str, + agent_id: Option<&str>, + tool_use_id: &str, + phase: state::AttemptPhase, + ) -> state::AdapterAttempt { + seed_attempt_in_turn( + git_dir, + session_id, + DRIVER_TURN, + agent_id, + tool_use_id, + phase, + ) + } + + fn seed_attempt_in_turn( + git_dir: &Path, + session_id: &str, + turn_id: &str, + agent_id: Option<&str>, + tool_use_id: &str, + phase: state::AttemptPhase, + ) -> state::AdapterAttempt { + state::seed_attempt_for_tests( + git_dir, + &AttemptKey { + session_id: session_id.to_string(), + agent_id: agent_id.map(str::to_string), + tool_use_id: tool_use_id.to_string(), + }, + turn_id, + "Bash", + phase, + ) + } + + fn drive( + payload: &str, + resolver: &(impl Fn(&str) -> Result + ?Sized), + seam: &(impl Fn(&Path, &str, Option<&dyn Logger>) -> Result + ?Sized), + ) -> String { + run_codex_mutation_scope_from_payload_with(payload, None, &resolver, &seam) + .expect("driver should return Ok") + } + + #[test] + fn untracked_mcp_pre_tool_use_creates_no_scope_and_never_touches_seam_or_git_dir() { + let payload = pre_tool_use_json(&[( + TOOL_NAME_FIELD, + Value::String("mcp__probe__mutate_success".to_string()), + )]); + let output = drive(&payload, &panicking_resolver, &unreachable_seam); + assert_eq!(output, ""); + } + + #[test] + fn unknown_and_delegation_pre_tool_use_create_no_scope_ac3() { + for tool in [ + "some_future_codex_tool", + "collaborationspawn_agent", + "collaborationwait_agent", + ] { + let payload = pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); + assert_eq!(drive(&payload, &panicking_resolver, &unreachable_seam), ""); + } + } + + #[test] + fn untracked_pre_tool_use_leaves_the_state_store_untouched_ac9b() { + let git_dir = unique_test_git_dir("untracked-state-untouched"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let resolver = fixed_resolver(git_dir.clone()); + + for tool in ["mcp__probe__mutate_success", "some_future_codex_tool"] { + let payload = pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); + drive(&payload, &resolver, &ok_seam); + } + + assert!(read_state(&git_dir).attempts.is_empty()); + assert!(read_state(&git_dir).recovery.is_clear()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn tracked_pre_tool_use_writes_ahead_start_then_returns_continue_ac6() { + let git_dir = unique_test_git_dir("tracked-write-ahead"); + let resolver = fixed_resolver(git_dir.clone()); + + let seen: RefCell> = RefCell::new(Vec::new()); + let seam = |root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + let phase_is_pending = state::read_state(&git_dir) + .expect("state readable inside seam") + .attempts + .first() + .is_some_and(|attempt| attempt.phase == state::AttemptPhase::PendingStart); + seen.borrow_mut() + .push((payload.to_string(), root == Path::new(CWD))); + assert!( + phase_is_pending, + "AC6: Start driven while attempt is PendingStart" + ); + Ok(String::new()) + }; + + let output = drive(&pre_tool_use_json(&[]), &resolver, &seam); + assert_eq!(output, ""); + + let calls = seen.into_inner(); + assert_eq!(calls.len(), 1); + assert!(calls[0].0.contains(r#""operation":"start""#)); + assert!(calls[0].0.contains(r#""actor_kind":"codex""#)); + assert!( + calls[0].1, + "AC6: the seam receives the raw hook cwd as repository_root" + ); + + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].phase, state::AttemptPhase::Active); + + remove_test_git_dir(&git_dir); + } + + fn boundary_payload_field(payload: &str, field: &str) -> Option { + let object: Map = + serde_json::from_str(payload).expect("a boundary payload is a JSON object"); + object.get(field).cloned() + } + + fn start_provenance(payload: &str) -> Value { + assert_eq!( + boundary_payload_field(payload, "operation"), + Some(Value::String("start".to_string())) + ); + boundary_payload_field(payload, PROVENANCE_FIELD) + .expect("a Codex start payload carries provenance") + } + + fn drive_recording_start(label: &str, payload: &str) -> Vec { + let git_dir = unique_test_git_dir(label); + let resolver = fixed_resolver(git_dir.clone()); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + + drive(payload, &resolver, &recording_seam(Arc::clone(&recorded))); + + let calls = recorded.lock().expect("recording seam mutex").clone(); + remove_test_git_dir(&git_dir); + calls + } + + #[test] + fn ac3_tracked_start_carries_scope_provenance_for_both_tracked_tools() { + for tool in TRACKED_MUTATION_TOOL_NAMES { + let payload = pre_tool_use_json(&[ + (TOOL_NAME_FIELD, Value::String((*tool).to_string())), + (MODEL_FIELD, Value::String("gpt-5.6-sol".to_string())), + ]); + let calls = drive_recording_start(&format!("provenance-{tool}"), &payload); + + assert_eq!(calls.len(), 1, "{tool} should drive exactly one boundary"); + assert_eq!( + start_provenance(&calls[0]), + json!({ "session_id": "cx_session-1", "model_id": "gpt-5.6-sol" }), + "AC3: {tool} must carry its canonical session and normalized model" + ); + } + } + + #[test] + fn ac3_a_start_without_a_usable_model_still_carries_its_session() { + let cases: [(&str, Option); 4] = [ + ("absent", None), + ("null", Some(Value::Null)), + ("blank", Some(Value::String(" ".to_string()))), + ("non-string", Some(Value::Bool(true))), + ]; + + for (label, model) in cases { + let overrides = model.map_or_else(Vec::new, |value| vec![(MODEL_FIELD, value)]); + let payload = pre_tool_use_json(&overrides); + let calls = drive_recording_start(&format!("provenance-model-{label}"), &payload); + + assert_eq!(calls.len(), 1, "{label} should drive exactly one boundary"); + assert_eq!( + start_provenance(&calls[0]), + json!({ "session_id": "cx_session-1", "model_id": Value::Null }), + "AC3: a {label} model records no model without losing the session" + ); + } + } + + #[test] + fn ac3_only_the_start_boundary_carries_provenance() { + let git_dir = unique_test_git_dir("provenance-start-only"); + let resolver = fixed_resolver(git_dir.clone()); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + let seam = recording_seam(Arc::clone(&recorded)); + + drive(&pre_tool_use_json(&[]), &resolver, &seam); + drive(&post_tool_use_json(&[]), &resolver, &seam); + + let calls = recorded.lock().expect("recording seam mutex").clone(); + assert_eq!(calls.len(), 2); + assert!(boundary_payload_field(&calls[0], PROVENANCE_FIELD).is_some()); + assert_eq!( + boundary_payload_field(&calls[1], "operation"), + Some(Value::String("close".to_string())) + ); + assert_eq!(boundary_payload_field(&calls[1], PROVENANCE_FIELD), None); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn duplicate_pre_tool_use_reuses_the_same_scope_id_ac4_test_e() { + let git_dir = unique_test_git_dir("duplicate-pre"); + let resolver = fixed_resolver(git_dir.clone()); + + drive(&pre_tool_use_json(&[]), &resolver, &ok_seam); + let scope_id = read_state(&git_dir).attempts[0].scope_id.clone(); + + drive(&pre_tool_use_json(&[]), &resolver, &ok_seam); + + let attempts = read_state(&git_dir).attempts; + assert_eq!( + attempts.len(), + 1, + "AC4/Test E: a replay must not fork a new attempt" + ); + assert_eq!(attempts[0].scope_id, scope_id); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn resolver_failure_denies_with_stable_reason_and_logs_the_detail_ac7() { + let logger = RecordingLogger::default(); + let resolver = + |_: &str| -> Result { Err(anyhow!("boom: git rev-parse --git-dir failed")) }; + + let output = run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[]), + Some(&logger), + &resolver, + &unreachable_seam, + ) + .expect("a resolver failure must still return Ok with a deny payload"); + + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + assert!(!output.contains("boom")); + assert!(!output.contains("allow")); + + let warnings = logger.warnings(); + assert_eq!(warnings.len(), 1); + assert_eq!(warnings[0].0, PRE_TOOL_USE_FAIL_CLOSED_EVENT); + assert!(warnings[0].1.contains("boom")); + } + + #[test] + fn start_seam_failure_denies_and_leaves_the_pending_start_attempt_as_a_barrier_ac7() { + let git_dir = unique_test_git_dir("start-seam-failure"); + let resolver = fixed_resolver(git_dir.clone()); + let logger = RecordingLogger::default(); + let seam = seam_failing_on("start"); + + let output = run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[]), + Some(&logger), + &resolver, + &seam, + ) + .expect("a Start failure must still return Ok with a deny payload"); + + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + assert!(!logger.warnings().is_empty()); + + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!( + final_state.attempts[0].phase, + state::AttemptPhase::PendingStart + ); + + let successor = pre_tool_use_json(&[ + ( + TOOL_USE_ID_FIELD, + Value::String("exec-successor".to_string()), + ), + (TURN_ID_FIELD, Value::String("turn-2".to_string())), + ]); + assert_eq!( + run_codex_mutation_scope_from_payload_with( + &successor, + None, + &resolver, + &unreachable_seam, + ) + .expect("successor must return Ok with a deny payload"), + pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), + "I5: an unresolved PendingStart in another lane must block a successor Start", + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn delegation_and_untracked_pre_tool_use_are_never_fail_closed_ac7() { + let resolver = |_: &str| -> Result { Err(anyhow!("must not be called")) }; + for tool in [ + "mcp__probe__mutate_success", + "some_future_codex_tool", + "collaborationspawn_agent", + ] { + let payload = pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); + assert_eq!( + run_codex_mutation_scope_from_payload_with( + &payload, + None, + &resolver, + &unreachable_seam, + ) + .expect("non-tracked PreToolUse should succeed"), + "", + ); + } + } + + #[test] + fn successful_close_removes_the_attempt_ac8() { + let git_dir = unique_test_git_dir("close-success"); + let resolver = fixed_resolver(git_dir.clone()); + + drive(&pre_tool_use_json(&[]), &resolver, &ok_seam); + + let seen: RefCell> = RefCell::new(Vec::new()); + let seam = |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + seen.borrow_mut().push(payload.to_string()); + Ok(String::new()) + }; + assert_eq!(drive(&post_tool_use_json(&[]), &resolver, &seam), ""); + + let calls = seen.into_inner(); + assert_eq!(calls.len(), 1); + assert!(calls[0].contains(r#""operation":"close""#)); + assert!(read_state(&git_dir).attempts.is_empty()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn pending_start_close_abandons_rather_than_late_starting_d11() { + let git_dir = unique_test_git_dir("pending-start-close"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt( + &git_dir, + "session-1", + None, + "exec-1", + state::AttemptPhase::PendingStart, + ); + + let seen: RefCell> = RefCell::new(Vec::new()); + let seam = |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + seen.borrow_mut().push(payload.to_string()); + Ok(String::new()) + }; + drive(&post_tool_use_json(&[]), &resolver, &seam); + + let calls = seen.into_inner(); + assert_eq!(calls.len(), 1); + assert!(calls[0].contains(r#""operation":"abandon""#)); + + let final_state = read_state(&git_dir); + assert!(final_state.attempts.is_empty()); + assert!(!final_state.recovery.is_clear()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn failed_close_abandons_and_arms_recovery_ac13() { + let git_dir = unique_test_git_dir("failed-close"); + let resolver = fixed_resolver(git_dir.clone()); + + drive(&pre_tool_use_json(&[]), &resolver, &ok_seam); + + let seam = seam_failing_on("close"); + drive(&post_tool_use_json(&[]), &resolver, &seam); + + let final_state = read_state(&git_dir); + assert!(final_state.attempts.is_empty()); + assert!( + !final_state.recovery.is_clear(), + "D11: a failed Close arms recovery" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn failed_close_and_failed_abandon_keep_the_attempt_tracked_and_recovery_armed_d11() { + let git_dir = unique_test_git_dir("failed-close-and-abandon"); + let resolver = fixed_resolver(git_dir.clone()); + + drive(&pre_tool_use_json(&[]), &resolver, &ok_seam); + + let seam = seam_failing_on_any(vec!["close", "abandon"]); + let error = run_codex_mutation_scope_from_payload_with( + &post_tool_use_json(&[]), + None, + &resolver, + &seam, + ) + .expect_err("a failed Close then failed Abandon must propagate"); + assert!(error.to_string().contains("abandon")); + + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 1); + assert!(!final_state.recovery.is_clear()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn post_tool_use_with_no_matching_attempt_is_a_noop() { + let git_dir = unique_test_git_dir("close-no-attempt"); + let resolver = fixed_resolver(git_dir.clone()); + + assert_eq!( + drive( + &post_tool_use_json(&[(TOOL_NAME_FIELD, Value::String("Bash".to_string()),)]), + &resolver, + &unreachable_seam, + ), + "", + ); + assert!(read_state(&git_dir).attempts.is_empty()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn stop_sweeps_only_main_thread_attempts_d12() { + let git_dir = unique_test_git_dir("stop-sweep"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt( + &git_dir, + "session-1", + None, + "exec-main", + state::AttemptPhase::Active, + ); + seed_attempt( + &git_dir, + "session-1", + Some("agent-1"), + "exec-agent", + state::AttemptPhase::Active, + ); + + drive( + &turn_scoped_payload(HOOK_EVENT_STOP, "session-1", "turn-1"), + &resolver, + &ok_seam, + ); + + let attempts = read_state(&git_dir).attempts; + assert_eq!(attempts.len(), 1); + assert_eq!(attempts[0].tool_use_id, "exec-agent"); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn interrupt_sweeps_every_attempt_for_the_session_d12() { + let git_dir = unique_test_git_dir("interrupt-sweep"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt( + &git_dir, + "session-1", + None, + "exec-main", + state::AttemptPhase::Active, + ); + seed_attempt( + &git_dir, + "session-1", + Some("agent-1"), + "exec-agent", + state::AttemptPhase::Active, + ); + seed_attempt( + &git_dir, + "session-2", + None, + "exec-other", + state::AttemptPhase::Active, + ); + + drive( + &turn_scoped_payload(HOOK_EVENT_INTERRUPT, "session-1", "turn-1"), + &resolver, + &ok_seam, + ); + + let attempts = read_state(&git_dir).attempts; + assert_eq!(attempts.len(), 1); + assert_eq!(attempts[0].session_id, "session-2"); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn subagent_stop_sweeps_only_the_matching_agent_d12() { + let git_dir = unique_test_git_dir("subagent-stop-sweep"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt( + &git_dir, + "session-1", + Some("agent-a"), + "exec-a", + state::AttemptPhase::Active, + ); + seed_attempt( + &git_dir, + "session-1", + Some("agent-b"), + "exec-b", + state::AttemptPhase::Active, + ); + seed_attempt( + &git_dir, + "session-1", + None, + "exec-main", + state::AttemptPhase::Active, + ); + + drive( + &subagent_stop_payload("session-1", "turn-1", "agent-a"), + &resolver, + &ok_seam, + ); + + let mut remaining: Vec = read_state(&git_dir) + .attempts + .into_iter() + .map(|attempt| attempt.tool_use_id) + .collect(); + remaining.sort(); + assert_eq!( + remaining, + vec!["exec-b".to_string(), "exec-main".to_string()] + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn session_end_sweeps_every_attempt_for_the_session_d12() { + let git_dir = unique_test_git_dir("session-end-sweep"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt( + &git_dir, + "session-1", + None, + "exec-main", + state::AttemptPhase::Active, + ); + seed_attempt( + &git_dir, + "session-1", + Some("agent-1"), + "exec-agent", + state::AttemptPhase::Active, + ); + + drive(&session_end_payload("session-1"), &resolver, &ok_seam); + + assert!(read_state(&git_dir).attempts.is_empty()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn lifecycle_cleanup_with_a_failed_abandon_keeps_the_attempt_tracked_d12() { + let git_dir = unique_test_git_dir("sweep-failed-abandon"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt( + &git_dir, + "session-1", + None, + "exec-main", + state::AttemptPhase::Active, + ); + + let seam = seam_failing_on("abandon"); + let error = run_codex_mutation_scope_from_payload_with( + &session_end_payload("session-1"), + None, + &resolver, + &seam, + ) + .expect_err("a failed abandonment during cleanup must propagate"); + assert!(error.to_string().contains("abandon")); + + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 1); + assert!(!final_state.recovery.is_clear()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn pending_non_empty_recovery_denies_unrelated_admission_without_losing_the_recovery_path() { + use crate::services::hooks::mutation_scope_health::MutationScopeHealthStatus; + + let git_dir = unique_test_git_dir("health-recovering-unrelated-denied"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt( + &git_dir, + "session-1", + None, + "exec-main", + state::AttemptPhase::Active, + ); + + let seam = seam_failing_on("abandon"); + let error = run_codex_mutation_scope_from_payload_with( + &session_end_payload("session-1"), + None, + &resolver, + &seam, + ) + .expect_err("a failed abandonment during cleanup must propagate"); + assert!(error.to_string().contains("abandon")); + + assert_eq!( + health::classify_health(&git_dir).status, + MutationScopeHealthStatus::Recovering, + "a stuck non-empty attempt with recovery armed is Recovering: unrelated \ + admission stays fail-closed, but a same-lane successor can still retry \ + the stale predecessor's abandonment" + ); + + for attempt_number in 1..=2 { + let output = drive( + &pre_tool_use_json(&[ + (SESSION_ID_FIELD, Value::String("session-2".to_string())), + (TURN_ID_FIELD, Value::String("turn-2".to_string())), + ( + TOOL_USE_ID_FIELD, + Value::String("exec-unrelated".to_string()), + ), + ]), + &resolver, + &unreachable_seam, + ); + assert_eq!( + output, + pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), + "PreToolUse call #{attempt_number} for an unrelated session must still be denied" + ); + } + + assert_eq!( + health::classify_health(&git_dir).status, + MutationScopeHealthStatus::Recovering, + "the classifier must still report Recovering after repeated denial from an \ + unrelated session; Recovering does not mean every future call succeeds, only \ + that a proven normal self-healing route exists" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn same_lane_successor_retries_abandon_and_reaches_healthy_after_a_failed_lifecycle_abandon_ac4( + ) { + use crate::services::hooks::mutation_scope_health::MutationScopeHealthStatus; + + let git_dir = unique_test_git_dir("health-same-lane-self-heal"); + let resolver = fixed_resolver(git_dir.clone()); + + seed_attempt_in_turn( + &git_dir, + "session-1", + "turn-1", + None, + "exec-a", + state::AttemptPhase::Active, + ); + + let failing_abandon_seam = seam_failing_on("abandon"); + let output_b = drive( + &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-b".to_string()))]), + &resolver, + &failing_abandon_seam, + ); + assert_eq!( + output_b, + pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), + "B fails closed when the same-lane sweep's retried abandon of A fails" + ); + + let after_b = read_state(&git_dir); + assert_eq!( + after_b.attempts.len(), + 1, + "A remains persisted after the failed same-lane sweep" + ); + assert_eq!(after_b.attempts[0].tool_use_id, "exec-a"); + assert!(!after_b.recovery.is_clear()); + + assert_eq!( + health::classify_health(&git_dir).status, + MutationScopeHealthStatus::Recovering + ); + + let seam_calls = Arc::new(Mutex::new(Vec::new())); + let recording = recording_seam(Arc::clone(&seam_calls)); + let output_c = drive( + &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-c".to_string()))]), + &resolver, + &recording, + ); + assert_eq!( + output_c, "", + "C's tracked Start proceeds once the same-lane sweep clears A and the \ + quiescent flush completes" + ); + + let operations: Vec = seam_calls + .lock() + .expect("recording seam mutex") + .iter() + .filter_map(|payload| { + serde_json::from_str::(payload) + .ok() + .and_then(|value| { + value + .get("operation") + .and_then(Value::as_str) + .map(str::to_string) + }) + }) + .collect(); + let abandon_index = operations + .iter() + .position(|operation| operation == "abandon"); + let flush_index = operations.iter().position(|operation| operation == "flush"); + let start_index = operations.iter().position(|operation| operation == "start"); + assert!( + abandon_index.is_some() && flush_index.is_some() && start_index.is_some(), + "expected abandon(A), flush, and start(C) seam calls, got {operations:?}" + ); + assert!( + abandon_index < flush_index && flush_index < start_index, + "expected abandon(A) -> flush -> start(C) ordering, got {operations:?}" + ); + + let final_state = read_state(&git_dir); + assert!(final_state.recovery.is_clear()); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].tool_use_id, "exec-c"); + + assert_eq!( + health::classify_health(&git_dir).status, + MutationScopeHealthStatus::Healthy + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn health_classifies_recovering_then_healthy_once_the_next_pre_tool_use_flushes_ac4() { + use crate::services::hooks::mutation_scope_health::MutationScopeHealthStatus; + + let git_dir = unique_test_git_dir("health-recovering-then-healthy"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let resolver = fixed_resolver(git_dir.clone()); + state::arm_recovery(&git_dir).expect("arming the barrier should succeed"); + + assert_eq!( + health::classify_health(&git_dir).status, + MutationScopeHealthStatus::Recovering + ); + + let output = drive( + &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-new".to_string()))]), + &resolver, + &ok_seam, + ); + assert_eq!(output, ""); + + assert_eq!( + health::classify_health(&git_dir).status, + MutationScopeHealthStatus::Healthy, + "a successful flush clears recovery and returns the adapter to Healthy" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn recovery_barrier_denies_new_tracked_pre_tool_use_while_attempts_remain_ac12() { + let git_dir = unique_test_git_dir("barrier-attempts-remain"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt_in_turn( + &git_dir, + "session-1", + "turn-other", + None, + "exec-live", + state::AttemptPhase::Active, + ); + state::arm_recovery(&git_dir).expect("arming the barrier should succeed"); + + let output = run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-new".to_string()))]), + None, + &resolver, + &unreachable_seam, + ) + .expect("the barrier denial still returns Ok with a deny payload"); + + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn recovery_barrier_does_not_affect_untracked_pre_tool_use_ac12_test_f() { + let git_dir = unique_test_git_dir("barrier-untracked-unaffected"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt( + &git_dir, + "session-1", + None, + "exec-live", + state::AttemptPhase::Active, + ); + state::arm_recovery(&git_dir).expect("arming the barrier should succeed"); + + for tool in [ + "mcp__probe__mutate_success", + "some_future_codex_tool", + "collaborationspawn_agent", + ] { + let payload = pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); + assert_eq!( + run_codex_mutation_scope_from_payload_with( + &payload, + None, + &resolver, + &unreachable_seam, + ) + .expect("an untracked PreToolUse ignores the barrier"), + "", + "Test F: recovery must never deny an untracked tool", + ); + } + + remove_test_git_dir(&git_dir); + } + + #[test] + fn recovery_barrier_flushes_once_quiescent_then_starts_ac12() { + let git_dir = unique_test_git_dir("barrier-flush-success"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let resolver = fixed_resolver(git_dir.clone()); + state::arm_recovery(&git_dir).expect("arming the barrier should succeed"); + + let seen: RefCell> = RefCell::new(Vec::new()); + let seam = |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| -> Result { + seen.borrow_mut().push(payload.to_string()); + Ok(String::new()) + }; + + let output = drive( + &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-new".to_string()))]), + &resolver, + &seam, + ); + assert_eq!(output, ""); + + let operations = seen.into_inner(); + assert_eq!( + operations.len(), + 2, + "expected flush then start, got {operations:?}" + ); + assert!(operations[0].contains(r#""operation":"flush""#)); + assert!(operations[1].contains(r#""operation":"start""#)); + + let final_state = read_state(&git_dir); + assert!( + final_state.recovery.is_clear(), + "a successful flush clears the barrier" + ); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].phase, state::AttemptPhase::Active); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn recovery_barrier_stays_closed_when_flush_fails_ac12() { + let git_dir = unique_test_git_dir("barrier-flush-failure"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let resolver = fixed_resolver(git_dir.clone()); + let generation = state::arm_recovery(&git_dir).expect("arming the barrier should succeed"); + + let seam = seam_failing_on("flush"); + let output = run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-new".to_string()))]), + None, + &resolver, + &seam, + ) + .expect("a failed flush still returns Ok with a deny payload"); + + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + let final_state = read_state(&git_dir); + assert_eq!( + final_state.recovery, + state::RecoveryState::Pending { generation }, + "a failed flush hands the generation back as Pending so a later PreToolUse retries", + ); + assert!(final_state.attempts.is_empty()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn mcp_mutate_then_error_leaves_no_stale_state_ac9c() { + let git_dir = unique_test_git_dir("mcp-mutate-then-error"); + let resolver = fixed_resolver(git_dir.clone()); + + let mcp_pre = pre_tool_use_json(&[ + ( + TOOL_NAME_FIELD, + Value::String("mcp__probe__mutate_then_error".to_string()), + ), + (TOOL_USE_ID_FIELD, Value::String("exec-mcp".to_string())), + ]); + drive(&mcp_pre, &resolver, &unreachable_seam); + assert!(read_state(&git_dir).attempts.is_empty()); + + drive( + &turn_scoped_payload(HOOK_EVENT_STOP, "session-1", "turn-1"), + &resolver, + &ok_seam, + ); + drive(&session_end_payload("session-1"), &resolver, &ok_seam); + + let final_state = read_state(&git_dir); + assert!(final_state.attempts.is_empty()); + assert!( + final_state.recovery.is_clear(), + "AC9c: no Start => no abandon => recovery stays clear" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn failed_mcp_then_tracked_successor_starts_clean_ac9d() { + let git_dir = unique_test_git_dir("mcp-then-tracked"); + let resolver = fixed_resolver(git_dir.clone()); + + let mcp_a = pre_tool_use_json(&[ + ( + TOOL_NAME_FIELD, + Value::String("mcp__probe__mutate_then_error".to_string()), + ), + (TOOL_USE_ID_FIELD, Value::String("exec-a".to_string())), + ]); + let bash_b = pre_tool_use_json(&[ + (TOOL_NAME_FIELD, Value::String("Bash".to_string())), + (TOOL_USE_ID_FIELD, Value::String("exec-b".to_string())), + ]); + + drive(&mcp_a, &resolver, &unreachable_seam); + drive(&bash_b, &resolver, &ok_seam); + + let attempts = read_state(&git_dir).attempts; + assert_eq!(attempts.len(), 1); + assert_eq!(attempts[0].tool_use_id, "exec-b"); + assert_eq!(attempts[0].phase, state::AttemptPhase::Active); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn parallel_mcp_executions_create_no_scopes_ac9e() { + let git_dir = unique_test_git_dir("parallel-mcp"); + let resolver = fixed_resolver(git_dir.clone()); + + for tool_use_id in ["exec-par-a", "exec-par-b"] { + let payload = pre_tool_use_json(&[ + ( + TOOL_NAME_FIELD, + Value::String("mcp__probe_par__slow_mutate".to_string()), + ), + (TOOL_USE_ID_FIELD, Value::String(tool_use_id.to_string())), + ]); + drive(&payload, &resolver, &unreachable_seam); + assert!(read_state(&git_dir).attempts.is_empty()); + } + + let final_state = read_state(&git_dir); + assert!(final_state.attempts.is_empty()); + assert!(final_state.recovery.is_clear()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn builtin_failed_a_then_b_never_leaves_a_zombie_scope_ac9a() { + let git_dir = unique_test_git_dir("builtin-failed-a-then-b"); + let resolver = fixed_resolver(git_dir.clone()); + + let predecessor_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-a".to_string()))]); + let predecessor_post = + post_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-a".to_string()))]); + let successor_pre = + pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-b".to_string()))]); + + drive(&predecessor_pre, &resolver, &ok_seam); + drive(&predecessor_post, &resolver, &ok_seam); + drive(&successor_pre, &resolver, &ok_seam); + + let attempts = read_state(&git_dir).attempts; + assert_eq!(attempts.len(), 1); + assert_eq!(attempts[0].tool_use_id, "exec-b"); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn malformed_payload_propagates_as_a_real_error_not_fail_open() { + let error = run_codex_mutation_scope_from_payload("not json", None).unwrap_err(); + assert!(error.to_string().contains("valid JSON")); + } + + #[test] + fn unsupported_event_name_propagates_as_a_real_error() { + let payload = json!({ + HOOK_EVENT_NAME_FIELD: "UserPromptSubmit", + SESSION_ID_FIELD: "session-1", + CWD_FIELD: CWD, + }) + .to_string(); + let error = run_codex_mutation_scope_from_payload(&payload, None).unwrap_err(); + assert!(error.to_string().contains("unsupported hook_event_name")); + } + + fn spawn_pre_tool_use( + git_dir: &Path, + tool_use_id: &'static str, + seam: impl Fn(&Path, &str, Option<&dyn Logger>) -> Result + Send + 'static, + ) -> (thread::JoinHandle, mpsc::Receiver<()>) { + spawn_pre_tool_use_in_turn(git_dir, tool_use_id, DRIVER_TURN, seam) + } + + fn spawn_pre_tool_use_in_turn( + git_dir: &Path, + tool_use_id: &'static str, + turn_id: &'static str, + seam: impl Fn(&Path, &str, Option<&dyn Logger>) -> Result + Send + 'static, + ) -> (thread::JoinHandle, mpsc::Receiver<()>) { + let (done_tx, done_rx) = mpsc::channel(); + let resolver = fixed_resolver(git_dir.to_path_buf()); + let handle = thread::spawn(move || { + let output = run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[ + (TOOL_USE_ID_FIELD, Value::String(tool_use_id.to_string())), + (TURN_ID_FIELD, Value::String(turn_id.to_string())), + ]), + None, + &resolver, + &seam, + ) + .expect("PreToolUse should return Ok"); + let _ = done_tx.send(()); + output + }); + (handle, done_rx) + } + + fn assert_still_blocked(done_rx: &mpsc::Receiver<()>, context: &str) { + assert!( + done_rx.recv_timeout(Duration::from_millis(250)).is_err(), + "{context}: the operation must still be blocked on the boundary lock", + ); + } + + fn first_index_of(recorded: &[String], operation: &str) -> Option { + recorded + .iter() + .position(|payload| payload.contains(&format!(r#""operation":"{operation}""#))) + } + + #[test] + fn test_h_cleanup_owning_the_boundary_lock_blocks_admission_until_recovery_is_processed() { + let git_dir = unique_test_git_dir("test-h-cleanup-owns-boundary"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + seed_attempt( + &git_dir, + "session-1", + None, + "exec-a", + state::AttemptPhase::Active, + ); + + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (abandon_seam, gate) = gated_seam("abandon", Arc::clone(&recorded)); + + let sweeper = { + let resolver = fixed_resolver(git_dir.clone()); + thread::spawn(move || { + run_codex_mutation_scope_from_payload_with( + &session_end_payload("session-1"), + None, + &resolver, + &abandon_seam, + ) + .expect("SessionEnd cleanup should succeed") + }) + }; + + gate.wait_until_entered(); + assert_eq!( + read_state(&git_dir).recovery, + state::RecoveryState::Pending { generation: 1 }, + "cleanup arms recovery while it owns the boundary lock", + ); + + let (b_handle, b_done) = + spawn_pre_tool_use(&git_dir, "exec-b", recording_seam(Arc::clone(&recorded))); + assert_still_blocked(&b_done, "Test H"); + assert!( + first_index_of(&recorded.lock().unwrap(), "start").is_none(), + "Test H: B must not reach Start while cleanup owns the boundary lock", + ); + + gate.release(); + sweeper.join().expect("sweeper thread should not panic"); + + let b_output = b_handle.join().expect("B thread should not panic"); + assert_eq!( + b_output, "", + "Test H: once recovery is processed B proceeds" + ); + + let recorded = recorded.lock().unwrap().clone(); + let abandon_at = first_index_of(&recorded, "abandon").expect("cleanup abandoned exec-a"); + let flush_at = first_index_of(&recorded, "flush").expect("B drove the quiescent flush"); + let start_at = first_index_of(&recorded, "start").expect("B reached Start"); + assert!( + abandon_at < flush_at && flush_at < start_at, + "Test H: the serialized order must be abandon -> flush -> start, got {recorded:?}", + ); + + let final_state = read_state(&git_dir); + assert!(final_state.recovery.is_clear()); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].tool_use_id, "exec-b"); + assert_eq!(final_state.attempts[0].phase, state::AttemptPhase::Active); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn test_g_admission_completed_recovery_cannot_arm_before_start() { + let git_dir = unique_test_git_dir("test-g-admit-before-start"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (start_seam, gate) = gated_seam("start", Arc::clone(&recorded)); + + let p1 = { + let resolver = fixed_resolver(git_dir.clone()); + thread::spawn(move || { + run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-b".to_string()))]), + None, + &resolver, + &start_seam, + ) + .expect("P1 PreToolUse should return Ok") + }) + }; + + gate.wait_until_entered(); + let mid = read_state(&git_dir); + assert_eq!(mid.attempts.len(), 1); + assert_eq!(mid.attempts[0].phase, state::AttemptPhase::PendingStart); + assert!( + mid.recovery.is_clear(), + "recovery must still be Clear while P1 holds the boundary lock pre-Start", + ); + + let (p2_handle, p2_done) = spawn_pre_tool_use( + &git_dir, + "exec-cleanup-trigger", + recording_seam(Arc::clone(&recorded)), + ); + + let sweeper = { + let resolver = fixed_resolver(git_dir.clone()); + let recorded = Arc::clone(&recorded); + thread::spawn(move || { + let seam = recording_seam(recorded); + run_codex_mutation_scope_from_payload_with( + &session_end_payload("session-1"), + None, + &resolver, + &seam, + ) + .expect("SessionEnd cleanup should return Ok") + }) + }; + + assert_still_blocked(&p2_done, "Test G"); + assert!( + read_state(&git_dir).recovery.is_clear(), + "Test G: no concurrent process may arm recovery between admit(B) and Start(B)", + ); + + gate.release(); + assert_eq!(p1.join().expect("P1 should not panic"), ""); + sweeper.join().expect("sweeper should not panic"); + p2_handle.join().expect("P2 should not panic"); + + let recorded = recorded.lock().unwrap().clone(); + let start_at = first_index_of(&recorded, "start").expect("P1 drove Start(B)"); + if let Some(abandon_at) = first_index_of(&recorded, "abandon") { + assert!( + start_at < abandon_at, + "Test G: Start(B) must be serialized before any later abandon, got {recorded:?}", + ); + } + + remove_test_git_dir(&git_dir); + } + + #[test] + fn test_j_a_live_flush_owner_is_never_reclaimed_by_a_blocked_process() { + let git_dir = unique_test_git_dir("test-j-live-flush-owner"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + state::arm_recovery(&git_dir).expect("arm recovery"); + + let flush_count = Arc::new(AtomicUsize::new(0)); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (owner_gated, gate) = gated_seam("flush", Arc::clone(&recorded)); + + let owner = { + let resolver = fixed_resolver(git_dir.clone()); + let flush_count = Arc::clone(&flush_count); + thread::spawn(move || { + let seam = move |root: &Path, + payload: &str, + logger: Option<&dyn Logger>| + -> Result { + if payload.contains(r#""operation":"flush""#) { + flush_count.fetch_add(1, Ordering::SeqCst); + } + owner_gated(root, payload, logger) + }; + run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[( + TOOL_USE_ID_FIELD, + Value::String("exec-owner".to_string()), + )]), + None, + &resolver, + &seam, + ) + .expect("owner PreToolUse should return Ok") + }) + }; + + gate.wait_until_entered(); + assert_eq!( + read_state(&git_dir).recovery, + state::RecoveryState::Flushing { generation: 1 }, + ); + + let flush_count_p2 = Arc::clone(&flush_count); + let (p2_handle, p2_done) = + spawn_pre_tool_use_in_turn(&git_dir, "exec-2", "turn-2", move |_r, payload, _l| { + if payload.contains(r#""operation":"flush""#) { + flush_count_p2.fetch_add(1, Ordering::SeqCst); + } + Ok(String::new()) + }); + + assert_still_blocked(&p2_done, "Test J"); + assert_eq!( + read_state(&git_dir).recovery, + state::RecoveryState::Flushing { generation: 1 }, + "Test J: a blocked process must not reclaim the live owner's Flushing(g)", + ); + + gate.release(); + assert_eq!(owner.join().expect("owner should not panic"), ""); + assert_eq!(p2_handle.join().expect("P2 should not panic"), ""); + + assert_eq!( + flush_count.load(Ordering::SeqCst), + 1, + "Test J: exactly one Flush ran — the live owner's, never a reclaim", + ); + let final_state = read_state(&git_dir); + assert!(final_state.recovery.is_clear()); + assert_eq!(final_state.attempts.len(), 2); + assert!(final_state + .attempts + .iter() + .all(|a| a.phase == state::AttemptPhase::Active)); + + remove_test_git_dir(&git_dir); + } + + fn seed_orphaned_flushing(git_dir: &Path) -> u64 { + let generation = state::arm_recovery(git_dir).expect("arm recovery to seed"); + match state::admit_tracked_attempt(git_dir, &key("seed", None, "seed"), "seed-turn", "Bash") + .expect("seeding admit should not error") + { + state::AdmitDecision::FlushClaimed { + generation: claimed, + } => { + assert_eq!(claimed, generation); + } + other => panic!("expected FlushClaimed while seeding, got {other:?}"), + } + assert_eq!( + read_state(git_dir).recovery, + state::RecoveryState::Flushing { generation }, + "seed left durable Flushing(g) with no live boundary-lock owner", + ); + generation + } + + #[test] + fn test_i_orphaned_flushing_is_reclaimed_and_flush_is_retried_once() { + let git_dir = unique_test_git_dir("test-i-orphaned-flushing"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let generation = seed_orphaned_flushing(&git_dir); + let next_generation_before = read_state(&git_dir).next_recovery_generation; + + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + let resolver = fixed_resolver(git_dir.clone()); + let output = drive( + &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-x".to_string()))]), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(output, ""); + + let ops = recorded.lock().unwrap().clone(); + assert_eq!( + ops.iter() + .filter(|p| p.contains(r#""operation":"flush""#)) + .count(), + 1, + "Test I: exactly one retry Flush for the reclaimed generation, got {ops:?}", + ); + assert!(first_index_of(&ops, "flush").unwrap() < first_index_of(&ops, "start").unwrap()); + + let final_state = read_state(&git_dir); + assert!( + final_state.recovery.is_clear(), + "Test I: no permanent RecoveryBlocked" + ); + assert_eq!( + final_state.next_recovery_generation, next_generation_before, + "Test I: reclaiming Flushing(g) preserves the generation, never bumps it", + ); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].tool_use_id, "exec-x"); + assert_eq!(final_state.attempts[0].phase, state::AttemptPhase::Active); + let _ = generation; + + remove_test_git_dir(&git_dir); + } + + #[test] + fn test_k_crash_after_durable_flush_before_completion_write_converges() { + let git_dir = unique_test_git_dir("test-k-crash-after-flush"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + seed_orphaned_flushing(&git_dir); + + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + let resolver = fixed_resolver(git_dir.clone()); + + let first = drive( + &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-1".to_string()))]), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(first, ""); + assert!(read_state(&git_dir).recovery.is_clear()); + + let second = drive( + &pre_tool_use_json(&[ + (TOOL_USE_ID_FIELD, Value::String("exec-2".to_string())), + (TURN_ID_FIELD, Value::String("turn-2".to_string())), + ]), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(second, ""); + + let ops = recorded.lock().unwrap().clone(); + assert_eq!( + ops.iter() + .filter(|p| p.contains(r#""operation":"flush""#)) + .count(), + 1, + "Test K: the recovery retry Flush runs exactly once across convergence, got {ops:?}", + ); + let final_state = read_state(&git_dir); + assert!(final_state.recovery.is_clear()); + assert_eq!(final_state.attempts.len(), 2); + assert!(final_state + .attempts + .iter() + .all(|a| a.phase == state::AttemptPhase::Active)); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn test_l_duplicate_active_delivery_drives_no_second_start() { + let git_dir = unique_test_git_dir("test-l-duplicate-active"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let resolver = fixed_resolver(git_dir.clone()); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let first = drive( + &pre_tool_use_json(&[]), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(first, ""); + let scope_id = read_state(&git_dir).attempts[0].scope_id.clone(); + assert_eq!( + read_state(&git_dir).attempts[0].phase, + state::AttemptPhase::Active + ); + + let duplicate = drive( + &pre_tool_use_json(&[]), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(duplicate, ""); + + let ops = recorded.lock().unwrap().clone(); + assert_eq!( + ops.iter() + .filter(|p| p.contains(r#""operation":"start""#)) + .count(), + 1, + "Test L: duplicate delivery of an Active execution drives no second Start, got {ops:?}", + ); + let attempts = read_state(&git_dir).attempts; + assert_eq!(attempts.len(), 1); + assert_eq!(attempts[0].scope_id, scope_id); + assert_eq!(read_state(&git_dir).next_attempt_seq, 2); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn test_m_untracked_tools_never_touch_the_boundary_lock() { + let git_dir = unique_test_git_dir("test-m-untracked-no-boundary"); + let resolver = fixed_resolver(git_dir.clone()); + + for tool in [ + "mcp__probe__mutate_success", + "some_future_codex_tool", + "collaborationspawn_agent", + "collaborationwait_agent", + ] { + let payload = pre_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); + assert_eq!( + run_codex_mutation_scope_from_payload_with( + &payload, + None, + &panicking_resolver, + &unreachable_seam, + ) + .expect("an untracked PreToolUse is neutral"), + "", + ); + assert_eq!(drive(&payload, &resolver, &unreachable_seam), ""); + } + + assert!( + !crate::services::hooks::codex_mutation_scope::boundary_lock::boundary_lock_path( + &git_dir + ) + .exists(), + "Test M: no untracked tool may create the adapter boundary lock", + ); + assert!( + !state::adapter_state_dir(&git_dir).exists(), + "Test M: an untracked tool resolves no git dir and touches no adapter state", + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn untracked_post_tool_use_never_touches_mutation_scope_machinery() { + let git_dir = unique_test_git_dir("untracked-post-no-footprint"); + let resolver = fixed_resolver(git_dir.clone()); + + for tool in [ + "mcp__probe__mutate_success", + "some_future_codex_tool", + "collaborationspawn_agent", + "collaborationwait_agent", + ] { + let payload = post_tool_use_json(&[(TOOL_NAME_FIELD, Value::String(tool.to_string()))]); + + assert_eq!( + run_codex_mutation_scope_from_payload_with( + &payload, + None, + &panicking_resolver, + &unreachable_seam, + ) + .expect("an untracked PostToolUse is neutral"), + "", + "untracked PostToolUse for {tool:?} must return neutral", + ); + assert_eq!( + drive(&payload, &resolver, &unreachable_seam), + "", + "untracked PostToolUse for {tool:?} must not call the ingress seam", + ); + } + + assert!( + !state::adapter_state_dir(&git_dir).exists(), + "an untracked PostToolUse resolves no git dir and creates no adapter state directory", + ); + assert!( + !crate::services::hooks::codex_mutation_scope::boundary_lock::boundary_lock_path( + &git_dir + ) + .exists(), + "an untracked PostToolUse must not create the adapter boundary lock", + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn a_complete_successful_mcp_lifecycle_leaves_zero_adapter_footprint() { + let git_dir = unique_test_git_dir("mcp-lifecycle-no-footprint"); + let resolver = fixed_resolver(git_dir.clone()); + + let mcp = &[( + TOOL_NAME_FIELD, + Value::String("mcp__probe__mutate_success".to_string()), + )]; + + assert_eq!( + run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(mcp), + None, + &panicking_resolver, + &unreachable_seam, + ) + .expect("MCP PreToolUse is neutral"), + "", + ); + assert_eq!( + run_codex_mutation_scope_from_payload_with( + &post_tool_use_json(mcp), + None, + &panicking_resolver, + &unreachable_seam, + ) + .expect("MCP PostToolUse is neutral"), + "", + ); + + assert_eq!( + drive(&pre_tool_use_json(mcp), &resolver, &unreachable_seam), + "" + ); + assert_eq!( + drive(&post_tool_use_json(mcp), &resolver, &unreachable_seam), + "" + ); + + let state = read_state(&git_dir); + assert!(state.attempts.is_empty(), "no attempts recorded"); + assert!(state.recovery.is_clear(), "recovery stays Clear"); + + assert!( + !state::adapter_state_dir(&git_dir).exists(), + "a complete successful MCP lifecycle creates no adapter state directory, \ + state lock, or boundary lock", + ); + assert!( + !crate::services::hooks::codex_mutation_scope::boundary_lock::boundary_lock_path( + &git_dir + ) + .exists(), + "a complete successful MCP lifecycle creates no boundary lock", + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn test_c_recovery_rearmed_while_flush_in_flight_survives_the_stale_completion() { + let git_dir = unique_test_git_dir("race-rearm-during-flush"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + state::arm_recovery(&git_dir).expect("arm g1"); + + let calls: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (flush_seam, gate) = gated_seam("flush", Arc::clone(&calls)); + + let flusher = { + let git_dir = git_dir.clone(); + let resolver = fixed_resolver(git_dir.clone()); + thread::spawn(move || { + run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[( + TOOL_USE_ID_FIELD, + Value::String("exec-flusher".to_string()), + )]), + None, + &resolver, + &flush_seam, + ) + .expect("flusher PreToolUse should return Ok") + }) + }; + + gate.wait_until_entered(); + assert_eq!( + read_state(&git_dir).recovery, + state::RecoveryState::Flushing { generation: 1 }, + ); + + let second_generation = state::arm_recovery(&git_dir).expect("re-arm to g2"); + assert_eq!(second_generation, 2); + + gate.release(); + let flusher_output = flusher.join().expect("flusher thread should not panic"); + assert_eq!( + flusher_output, + pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), + "Test C: the flusher denies because recovery was re-armed under it", + ); + + assert_eq!( + read_state(&git_dir).recovery, + state::RecoveryState::Pending { generation: 2 }, + "Test C: the stale Flush(g1) completion must not clear Pending(g2)", + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn test_d_start_succeeds_but_mark_active_fails_blocks_a_successor_until_recovery() { + let git_dir = unique_test_git_dir("start-then-mark-active-fails"); + let resolver = fixed_resolver(git_dir.clone()); + + state::arm_mark_active_failure_for_tests(); + let logger = RecordingLogger::default(); + let output = run_codex_mutation_scope_from_payload_with( + &pre_tool_use_json(&[(TOOL_USE_ID_FIELD, Value::String("exec-a".to_string()))]), + Some(&logger), + &resolver, + &ok_seam, + ) + .expect("a mark_active failure still returns Ok with a deny payload"); + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + + let after_start = read_state(&git_dir); + assert_eq!(after_start.attempts.len(), 1); + assert_eq!( + after_start.attempts[0].phase, + state::AttemptPhase::PendingStart + ); + assert!(after_start.recovery.is_clear()); + + let successor = pre_tool_use_json(&[ + (TOOL_USE_ID_FIELD, Value::String("exec-b".to_string())), + (TURN_ID_FIELD, Value::String("turn-2".to_string())), + ]); + assert_eq!( + run_codex_mutation_scope_from_payload_with( + &successor, + None, + &resolver, + &unreachable_seam, + ) + .expect("successor returns a deny payload"), + pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), + "Test D: an uncertain PendingStart in another lane blocks a successor Start", + ); + + drive(&session_end_payload("session-1"), &resolver, &ok_seam); + assert!(read_state(&git_dir).attempts.is_empty()); + assert!(!read_state(&git_dir).recovery.is_clear()); + + let recording: Arc>> = Arc::new(Mutex::new(Vec::new())); + let seam = recording_seam(Arc::clone(&recording)); + let recovered = drive(&successor, &resolver, &seam); + assert_eq!(recovered, ""); + + let ops = recording.lock().expect("recording mutex").clone(); + assert_eq!(ops.len(), 2, "expected flush then start, got {ops:?}"); + assert!(ops[0].contains(r#""operation":"flush""#)); + assert!(ops[1].contains(r#""operation":"start""#)); + + let final_state = read_state(&git_dir); + assert!(final_state.recovery.is_clear()); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].phase, state::AttemptPhase::Active); + assert_eq!(final_state.attempts[0].tool_use_id, "exec-b"); + + remove_test_git_dir(&git_dir); + } + + fn boundary_lock_exists(git_dir: &Path) -> bool { + crate::services::hooks::codex_mutation_scope::boundary_lock::boundary_lock_path(git_dir) + .exists() + } + + #[test] + fn policy_blocked_bash_pre_tool_use_creates_no_mutation_scope_state() { + let git_dir = unique_test_git_dir("policy-blocked-no-scope"); + + let output = run_codex_mutation_scope_from_payload_with_bash_policy( + &pre_tool_use_json(&[(TOOL_INPUT_FIELD, json!({"command": "danger --now"}))]), + None, + &panicking_resolver, + &unreachable_seam, + &blocking_bash_policy, + ) + .expect("a policy-blocked Bash PreToolUse still returns Ok"); + + assert_eq!( + output, BLOCKED_BASH_POLICY_RESPONSE, + "a policy block returns the Codex-native policy denial verbatim, \ + never the generic mutation-scope deny", + ); + assert!(!output.contains(FAIL_CLOSED_DENY_REASON)); + assert!( + !state::adapter_state_dir(&git_dir).exists(), + "a policy block must leave no adapter state: no PendingStart, Active, \ + Start, Abandon, Flush, or recovery", + ); + assert!( + !boundary_lock_exists(&git_dir), + "a policy block must not even acquire the boundary lock", + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn policy_allowed_bash_pre_tool_use_follows_the_normal_write_ahead_start_path() { + let git_dir = unique_test_git_dir("policy-allowed-start"); + let resolver = fixed_resolver(git_dir.clone()); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let output = run_codex_mutation_scope_from_payload_with_bash_policy( + &pre_tool_use_json(&[]), + None, + &resolver, + &recording_seam(Arc::clone(&recorded)), + &allow_bash_policy, + ) + .expect("an allowed Bash PreToolUse returns Ok"); + assert_eq!(output, ""); + + let ops = recorded.lock().unwrap().clone(); + assert_eq!( + ops.iter() + .filter(|payload| payload.contains(r#""operation":"start""#)) + .count(), + 1, + "an allowed Bash still drives exactly one write-ahead Start, got {ops:?}", + ); + let attempts = read_state(&git_dir).attempts; + assert_eq!(attempts.len(), 1); + assert_eq!(attempts[0].phase, state::AttemptPhase::Active); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn policy_evaluation_failure_is_fail_closed_with_no_mutation_scope_state() { + let git_dir = unique_test_git_dir("policy-eval-failure"); + let logger = RecordingLogger::default(); + + let output = run_codex_mutation_scope_from_payload_with_bash_policy( + &pre_tool_use_json(&[]), + Some(&logger), + &panicking_resolver, + &unreachable_seam, + &failing_bash_policy, + ) + .expect("a Bash policy evaluation failure still returns Ok"); + + assert_eq!( + output, + pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), + "a policy evaluation failure fails closed with the generic mutation-scope deny", + ); + let warnings = logger.warnings(); + assert_eq!(warnings.len(), 1); + assert_eq!(warnings[0].0, PRE_TOOL_USE_FAIL_CLOSED_EVENT); + assert!(warnings[0].1.contains("could not be evaluated")); + assert!( + !state::adapter_state_dir(&git_dir).exists(), + "a fail-closed policy evaluation must leave no adapter state", + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn apply_patch_pre_tool_use_never_evaluates_bash_policy() { + let git_dir = unique_test_git_dir("apply-patch-no-policy"); + let resolver = fixed_resolver(git_dir.clone()); + + let output = run_codex_mutation_scope_from_payload_with_bash_policy( + &pre_tool_use_json(&[ + (TOOL_NAME_FIELD, Value::String("apply_patch".to_string())), + (TOOL_INPUT_FIELD, Value::Null), + ]), + None, + &resolver, + &ok_seam, + &unreachable_bash_policy, + ) + .expect("an apply_patch PreToolUse returns Ok"); + assert_eq!(output, ""); + + let attempts = read_state(&git_dir).attempts; + assert_eq!(attempts.len(), 1, "apply_patch still establishes a scope"); + assert_eq!(attempts[0].phase, state::AttemptPhase::Active); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn malformed_bash_tool_input_is_fail_closed_before_the_policy_evaluator_runs() { + let git_dir = unique_test_git_dir("malformed-bash-tool-input"); + let logger = RecordingLogger::default(); + + let output = run_codex_mutation_scope_from_payload_with_bash_policy( + &pre_tool_use_json(&[(TOOL_INPUT_FIELD, json!({"not_command": "x"}))]), + Some(&logger), + &panicking_resolver, + &unreachable_seam, + &unreachable_bash_policy, + ) + .expect("a malformed Bash tool_input still returns Ok"); + + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + let warnings = logger.warnings(); + assert_eq!(warnings.len(), 1); + assert_eq!(warnings[0].0, PRE_TOOL_USE_FAIL_CLOSED_EVENT); + assert!( + warnings[0].1.contains("tool_input.command"), + "extraction reuses the shared bash_command_from_tool_input semantics", + ); + assert!(!state::adapter_state_dir(&git_dir).exists()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn production_bash_policy_evaluator_blocks_a_repo_denied_command_before_any_start() { + let repo = unique_test_git_dir("prod-policy-regression"); + std::fs::create_dir_all(repo.join(".sce")).expect("create .sce dir"); + std::fs::write( + repo.join(".sce").join("config.json"), + concat!( + r#"{"policies":{"bash":{"custom":[{"id":"no-rm","#, + r#""match":{"argv_prefix":["rm"]},"#, + r#""message":"rm is blocked in this repository"}]}}}"#, + ), + ) + .expect("write repo bash policy config"); + + let real_evaluator = |root: &Path, command: &str| evaluate_codex_bash_policy(root, command); + + let blocked = run_codex_mutation_scope_from_payload_with_bash_policy( + &pre_tool_use_json(&[ + ( + CWD_FIELD, + Value::String(repo.to_string_lossy().into_owned()), + ), + (TOOL_INPUT_FIELD, json!({"command": "rm -rf build"})), + ]), + None, + &panicking_resolver, + &unreachable_seam, + &real_evaluator, + ) + .expect("a repo-denied Bash command still returns Ok"); + + assert!(blocked.contains(r#""permissionDecision":"deny""#)); + assert!(blocked.contains("no-rm")); + assert!(blocked.contains("rm is blocked in this repository")); + assert!( + !blocked.contains(FAIL_CLOSED_DENY_REASON), + "a real policy block keeps the policy-specific UX, not the generic deny", + ); + + let allowed = run_codex_mutation_scope_from_payload_with_bash_policy( + &pre_tool_use_json(&[ + ( + CWD_FIELD, + Value::String(repo.to_string_lossy().into_owned()), + ), + (TOOL_INPUT_FIELD, json!({"command": "echo ok > ok.txt"})), + (TOOL_USE_ID_FIELD, Value::String("exec-allowed".to_string())), + ]), + None, + &fixed_resolver(repo.join(".git")), + &ok_seam, + &real_evaluator, + ) + .expect("an allowed Bash command still returns Ok"); + assert_eq!( + allowed, "", + "the same repo config lets a non-denied command through to the normal path", + ); + + remove_test_git_dir(&repo); + } + + fn operations(recorded: &[String]) -> Vec { + recorded + .iter() + .filter_map(|payload| { + for op in ["start", "close", "abandon", "flush"] { + if payload.contains(&format!(r#""operation":"{op}""#)) { + return Some(op.to_string()); + } + } + None + }) + .collect() + } + + fn pre(tool_use_id: &str, tool_name: &str, overrides: &[(&str, Value)]) -> String { + let mut merged: Vec<(&str, Value)> = vec![ + (TOOL_USE_ID_FIELD, Value::String(tool_use_id.to_string())), + (TOOL_NAME_FIELD, Value::String(tool_name.to_string())), + ]; + if tool_name != CODEX_TRACKED_TOOL_BASH { + merged.push((TOOL_INPUT_FIELD, Value::Null)); + } + merged.extend( + overrides + .iter() + .map(|(field, value)| (*field, value.clone())), + ); + pre_tool_use_json(&merged) + } + + fn assert_zombie_then_successor_sweep( + a_tool: &str, + b_tool: &str, + b_overrides: &[(&str, Value)], + ) { + let git_dir = unique_test_git_dir(&format!("zombie-successor-{a_tool}-{b_tool}")); + let resolver = fixed_resolver(git_dir.clone()); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let started = drive( + &pre("exec-a", a_tool, &[]), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(started, ""); + assert_eq!( + read_state(&git_dir).attempts[0].phase, + state::AttemptPhase::Active, + ); + + let successor = drive( + &pre("exec-b", b_tool, b_overrides), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(successor, ""); + + let ops = operations(&recorded.lock().unwrap()); + assert_eq!( + ops, + vec![ + "start".to_string(), + "abandon".to_string(), + "flush".to_string(), + "start".to_string(), + ], + "successor sequence must be Start(A) -> Abandon(A) -> Flush -> Start(B), never Start(A) -> Start(B) -> Abandon(A)", + ); + + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].tool_use_id, "exec-b"); + assert_eq!(final_state.attempts[0].phase, state::AttemptPhase::Active); + assert!(final_state.recovery.is_clear()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn regression1_arbitrary_blocker_zombie_then_tracked_successor() { + assert_zombie_then_successor_sweep("Bash", "Bash", &[]); + } + + #[test] + fn regression2_apply_patch_successor_variants() { + assert_zombie_then_successor_sweep("Bash", "apply_patch", &[]); + assert_zombie_then_successor_sweep("apply_patch", "Bash", &[]); + assert_zombie_then_successor_sweep("apply_patch", "apply_patch", &[]); + } + + #[test] + fn regression3_parent_then_subagent_same_lane_is_swept() { + assert_zombie_then_successor_sweep( + "Bash", + "Bash", + &[(AGENT_ID_FIELD, Value::String("agent-1".to_string()))], + ); + } + + #[test] + fn regression3_subagent_then_parent_same_lane_is_swept() { + let git_dir = unique_test_git_dir("subagent-then-parent"); + let resolver = fixed_resolver(git_dir.clone()); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + + drive( + &pre( + "exec-a", + "Bash", + &[(AGENT_ID_FIELD, Value::String("agent-1".to_string()))], + ), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + drive( + &pre("exec-b", "Bash", &[]), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + + assert_eq!( + operations(&recorded.lock().unwrap()), + vec![ + "start".to_string(), + "abandon".to_string(), + "flush".to_string(), + "start".to_string(), + ], + ); + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].tool_use_id, "exec-b"); + assert!(final_state.attempts[0].agent_id.is_none()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn regression4_different_session_is_not_swept() { + let git_dir = unique_test_git_dir("different-session-not-swept"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt_in_turn( + &git_dir, + "session-1", + "turn-1", + None, + "exec-a", + state::AttemptPhase::Active, + ); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let output = drive( + &pre( + "exec-b", + "Bash", + &[(SESSION_ID_FIELD, Value::String("session-2".to_string()))], + ), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(output, ""); + + assert_eq!( + operations(&recorded.lock().unwrap()), + vec!["start".to_string()] + ); + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 2); + assert!(final_state + .attempts + .iter() + .any(|attempt| attempt.tool_use_id == "exec-a")); + assert!(final_state.recovery.is_clear()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn regression5_different_turn_is_not_swept_by_case_b_inference() { + let git_dir = unique_test_git_dir("different-turn-not-swept"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt_in_turn( + &git_dir, + "session-1", + "turn-1", + None, + "exec-a", + state::AttemptPhase::Active, + ); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let output = drive( + &pre( + "exec-b", + "Bash", + &[(TURN_ID_FIELD, Value::String("turn-2".to_string()))], + ), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(output, ""); + + assert_eq!( + operations(&recorded.lock().unwrap()), + vec!["start".to_string()] + ); + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 2); + assert!(final_state + .attempts + .iter() + .any(|attempt| attempt.tool_use_id == "exec-a")); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn regression6_duplicate_same_attempt_key_is_not_swept() { + let git_dir = unique_test_git_dir("duplicate-not-swept"); + let resolver = fixed_resolver(git_dir.clone()); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + + drive( + &pre("exec-a", "Bash", &[]), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + let scope_id = read_state(&git_dir).attempts[0].scope_id.clone(); + let next_seq = read_state(&git_dir).next_attempt_seq; + + drive( + &pre("exec-a", "Bash", &[]), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + + assert_eq!( + operations(&recorded.lock().unwrap()), + vec!["start".to_string()] + ); + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].scope_id, scope_id); + assert_eq!(final_state.next_attempt_seq, next_seq); + assert!(final_state.recovery.is_clear()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn regression7_pending_start_predecessor_is_swept() { + let git_dir = unique_test_git_dir("pending-start-predecessor-swept"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt_in_turn( + &git_dir, + "session-1", + "turn-1", + None, + "exec-a", + state::AttemptPhase::PendingStart, + ); + let recorded: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let output = drive( + &pre("exec-b", "Bash", &[]), + &resolver, + &recording_seam(Arc::clone(&recorded)), + ); + assert_eq!(output, ""); + + assert_eq!( + operations(&recorded.lock().unwrap()), + vec![ + "abandon".to_string(), + "flush".to_string(), + "start".to_string(), + ], + ); + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].tool_use_id, "exec-b"); + assert!(final_state.recovery.is_clear()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn regression8_abandon_failure_during_sweep_is_fail_closed() { + let git_dir = unique_test_git_dir("sweep-abandon-failure"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt_in_turn( + &git_dir, + "session-1", + "turn-1", + None, + "exec-a", + state::AttemptPhase::Active, + ); + + let output = run_codex_mutation_scope_from_payload_with( + &pre("exec-b", "Bash", &[]), + None, + &resolver, + &seam_failing_on("abandon"), + ) + .expect("a failed sweep abandon still returns Ok with a deny payload"); + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + + let final_state = read_state(&git_dir); + assert_eq!(final_state.attempts.len(), 1); + assert_eq!(final_state.attempts[0].tool_use_id, "exec-a"); + assert!(!final_state.recovery.is_clear()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn regression9_flush_failure_after_sweep_is_fail_closed() { + let git_dir = unique_test_git_dir("sweep-flush-failure"); + let resolver = fixed_resolver(git_dir.clone()); + seed_attempt_in_turn( + &git_dir, + "session-1", + "turn-1", + None, + "exec-a", + state::AttemptPhase::Active, + ); + + let output = run_codex_mutation_scope_from_payload_with( + &pre("exec-b", "Bash", &[]), + None, + &resolver, + &seam_failing_on("flush"), + ) + .expect("a failed post-sweep flush still returns Ok with a deny payload"); + assert_eq!(output, pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON)); + + let final_state = read_state(&git_dir); + assert!(final_state + .attempts + .iter() + .all(|attempt| attempt.tool_use_id != "exec-b")); + assert!(!final_state.recovery.is_clear()); + + remove_test_git_dir(&git_dir); + } +} + +mod production_regressions { + use std::fs; + use std::path::{Path, PathBuf}; + use std::process::Command; + + use super::*; + use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; + use crate::services::agent_trace_storage::{ + resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, + }; + use crate::services::mutation_trace::runtime::{resolve_git_dir, resolve_worktree_id}; + use crate::services::mutation_trace::store::decode_revision; + + const PROBE01_APPLY_PATCH_POST: &str = include_str!( + "fixtures/probe01-apply-patch-and-shell-success.apply_patch.post_tool_use.json" + ); + const PROBE02_FAILED_SHELL_PRE: &str = + include_str!("fixtures/probe02-shell-partial-write-then-nonzero-exit.pre_tool_use.json"); + const PROBE06_APPLY_PATCH_FAILURE_PRE: &str = + include_str!("fixtures/probe06-apply-patch-verification-failure-no-post.pre_tool_use.json"); + const PROBE09_DETACHED_PRE: &str = + include_str!("fixtures/probe09-self-detaching-descendant.pre_tool_use.json"); + const PROBE09_DETACHED_POST: &str = + include_str!("fixtures/probe09-self-detaching-descendant.post_tool_use.json"); + const PROBE11_INTERRUPT_PRE: &str = + include_str!("fixtures/probe11-interrupt-event-on-sigint.pre_tool_use.json"); + const PROBE13_MCP_STOP: &str = include_str!("fixtures/probe13-mcp-mutate-then-error.stop.json"); + const PROBE14_MCP_FAILED_PRE: &str = + include_str!("fixtures/probe14-mcp-failed-then-successor.failed.pre_tool_use.json"); + const PROBE14_MCP_SUCCESSOR_PRE: &str = + include_str!("fixtures/probe14-mcp-failed-then-successor.successor.pre_tool_use.json"); + const PROBE14_MCP_SUCCESSOR_POST: &str = + include_str!("fixtures/probe14-mcp-failed-then-successor.successor.post_tool_use.json"); + const PROBE16_MCP_PARALLEL_A_PRE: &str = + include_str!("fixtures/probe16-mcp-parallel-server-optin.a.pre_tool_use.json"); + const PROBE16_MCP_PARALLEL_A_POST: &str = + include_str!("fixtures/probe16-mcp-parallel-server-optin.a.post_tool_use.json"); + const PROBE16_MCP_PARALLEL_B_PRE: &str = + include_str!("fixtures/probe16-mcp-parallel-server-optin.b.pre_tool_use.json"); + const PROBE16_MCP_PARALLEL_B_POST: &str = + include_str!("fixtures/probe16-mcp-parallel-server-optin.b.post_tool_use.json"); + + const OTHER_HARNESS_ACTOR_KIND: &str = "claude_code"; + + fn git(dir: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(dir) + .output() + .expect("git should spawn"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).expect("git output should be UTF-8") + } + + struct CodexRepo { + temp: tempfile::TempDir, + root: PathBuf, + state_root: PathBuf, + } + + impl CodexRepo { + fn new(label: &str) -> Self { + let temp = tempfile::Builder::new() + .prefix(&format!("sce-codex-mutation-scope-regression-{label}-")) + .tempdir() + .expect("temp dir should be created"); + let root = temp.path().join("repo"); + fs::create_dir_all(&root).expect("repo dir should be created"); + git(&root, &["init", "-q"]); + git(&root, &["config", "user.email", "test@example.invalid"]); + git(&root, &["config", "user.name", "SCE Test"]); + git( + &root, + &["remote", "add", "origin", "git@github.com:acme/widgets.git"], + ); + fs::write(root.join("file.txt"), "one\n").expect("seed file should write"); + git(&root, &["add", "-A"]); + git(&root, &["commit", "-qm", "base"]); + + let state_root = temp.path().join("state"); + fs::create_dir_all(&state_root).expect("state root should be created"); + resolve_agent_trace_storage_at_state_root( + &AgentTraceStorageContext { + repository_root: &root, + explicit_repository_id: None, + repository_remote: "origin", + }, + &state_root, + ) + .expect("state-root storage should initialize the repository DB"); + + Self { + temp, + root, + state_root, + } + } + + fn drive(&self, payload: &str) -> Result { + run_codex_mutation_scope_from_payload_at_state_root(&self.state_root, payload, None) + } + + fn drive_generic(&self, payload: &str) -> Result { + self.drive_generic_at(&self.root, payload) + } + + fn drive_generic_at(&self, repository_root: &Path, payload: &str) -> Result { + crate::services::hooks::mutation_scope::run_mutation_scope_from_payload_at_state_root( + repository_root, + &self.state_root, + payload, + None, + ) + } + + fn drive_flush(&self) -> Result { + self.drive_generic(&flush_payload()) + } + + fn db(&self) -> RepositoryAgentTraceDb { + crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( + &self.root, + &self.state_root, + "codex mutation-scope regression test assertions", + ) + .expect("assertion DB should open") + } + + fn cwd(&self) -> String { + Self::cwd_at(&self.root) + } + + fn cwd_at(root: &Path) -> String { + root.to_string_lossy().into_owned() + } + + fn working_tree_at(root: &Path) -> String { + git(root, &["add", "-A"]); + git(root, &["write-tree"]).trim().to_owned() + } + + fn working_tree(&self) -> String { + Self::working_tree_at(&self.root) + } + + fn git_dir_at(root: &Path) -> PathBuf { + resolve_git_dir(root).expect("git dir should resolve") + } + + fn git_dir(&self) -> PathBuf { + Self::git_dir_at(&self.root) + } + + fn adapter_state_at(root: &Path) -> state::AdapterState { + state::read_state(&Self::git_dir_at(root)).expect("adapter state should be readable") + } + + fn adapter_state(&self) -> state::AdapterState { + Self::adapter_state_at(&self.root) + } + + fn adapter_state_file_exists(&self) -> bool { + state::adapter_state_dir(&self.git_dir()) + .join("codex-mutation-scope-state.json") + .exists() + } + + fn worktree_id_at(root: &Path) -> String { + resolve_worktree_id(root) + .expect("worktree id should resolve") + .0 + } + + fn worktree_id(&self) -> String { + Self::worktree_id_at(&self.root) + } + + fn add_worktree(&self, name: &str) -> PathBuf { + let worktree_path = self.temp.path().join(name); + git( + &self.root, + &[ + "worktree", + "add", + "-q", + worktree_path.to_str().expect("utf-8 worktree path"), + ], + ); + worktree_path + } + + fn write(&self, name: &str, contents: &str) { + fs::write(self.root.join(name), contents).expect("regression write should succeed"); + } + + fn live_scope_id(&self) -> String { + let state = self.adapter_state(); + assert_eq!(state.attempts.len(), 1, "exactly one live attempt expected"); + state.attempts[0].scope_id.clone() + } + } + + fn count(db: &RepositoryAgentTraceDb, table: &str) -> i64 { + db.query_map(&format!("SELECT COUNT(*) FROM {table}"), (), |row| { + row.get::(0).map_err(anyhow::Error::from) + }) + .expect("count query should succeed") + .into_iter() + .next() + .expect("a count row should exist") + } + + fn raw_agent_trace_row_counts(db: &RepositoryAgentTraceDb) -> [i64; 5] { + [ + count(db, "diff_traces"), + count(db, "post_commit_patch_intersections"), + count(db, "agent_traces"), + count(db, "messages"), + count(db, "parts"), + ] + } + + fn assert_raw_agent_trace_tables_untouched(db: &RepositoryAgentTraceDb) { + assert_eq!( + raw_agent_trace_row_counts(db), + [0, 0, 0, 0, 0], + "AC20: the mutation-scope adapter must never write the raw Agent Trace tables" + ); + } + + fn worktree_row(db: &RepositoryAgentTraceDb, worktree_id: &str) -> Option<(u64, String, bool)> { + db.query_map( + "SELECT revision, cursor_tree, needs_rebaseline FROM mutation_trace_worktrees \ + WHERE worktree_id = ?1", + (worktree_id,), + |row| { + let blob: Vec = row.get(0).map_err(anyhow::Error::from)?; + let revision = decode_revision(&blob)?; + let cursor_tree = row.get::(1).map_err(anyhow::Error::from)?; + let needs_rebaseline = row.get::(2).map_err(anyhow::Error::from)? != 0; + Ok((revision, cursor_tree, needs_rebaseline)) + }, + ) + .expect("worktree-row query should succeed") + .into_iter() + .next() + } + + fn processed_events(db: &RepositoryAgentTraceDb) -> Vec<(String, String)> { + db.query_map( + "SELECT scope_id, event_id FROM mutation_trace_processed_events \ + ORDER BY scope_id, event_id", + (), + |row| { + let scope_id = row.get::(0).map_err(anyhow::Error::from)?; + let event_id = row.get::(1).map_err(anyhow::Error::from)?; + Ok((scope_id, event_id)) + }, + ) + .expect("processed-events query should succeed") + } + + fn scope_status(db: &RepositoryAgentTraceDb, scope_id: &str) -> Option<(String, String)> { + db.query_map( + "SELECT actor_kind, status FROM mutation_trace_scopes WHERE scope_id = ?1", + (scope_id,), + |row| { + let actor_kind = row.get::(0).map_err(anyhow::Error::from)?; + let status = row.get::(1).map_err(anyhow::Error::from)?; + Ok((actor_kind, status)) + }, + ) + .expect("scope query should succeed") + .into_iter() + .next() + } + + fn mutation_events_for( + db: &RepositoryAgentTraceDb, + worktree_id: &str, + ) -> Vec<(String, Option, String)> { + db.query_map( + "SELECT attribution_kind, attribution_scope_id, boundary_kind \ + FROM mutation_trace_events WHERE worktree_id = ?1 ORDER BY revision", + (worktree_id,), + |row| { + let attribution_kind = row.get::(0).map_err(anyhow::Error::from)?; + let attribution_scope_id = + row.get::>(1).map_err(anyhow::Error::from)?; + let boundary_kind = row.get::(2).map_err(anyhow::Error::from)?; + Ok((attribution_kind, attribution_scope_id, boundary_kind)) + }, + ) + .expect("mutation-events query should succeed") + } + + fn scope_provenance( + db: &RepositoryAgentTraceDb, + scope_id: &str, + ) -> Option<(String, Option)> { + db.query_map( + "SELECT session_id, model_id FROM mutation_trace_scope_provenance \ + WHERE scope_id = ?1", + (scope_id,), + |row| { + let session_id = row.get::(0).map_err(anyhow::Error::from)?; + let model_id = row.get::>(1).map_err(anyhow::Error::from)?; + Ok((session_id, model_id)) + }, + ) + .expect("scope-provenance query should succeed") + .into_iter() + .next() + } + + fn active_scopes_for(db: &RepositoryAgentTraceDb, worktree_id: &str) -> Vec { + db.query_map( + "SELECT scope_id FROM mutation_trace_event_active_scopes \ + WHERE worktree_id = ?1 ORDER BY revision, scope_id", + (worktree_id,), + |row| row.get::(0).map_err(anyhow::Error::from), + ) + .expect("active-scopes query should succeed") + } + + fn fixture_at(fixture: &str, cwd: &str) -> String { + let mut object: Map = + serde_json::from_str(fixture).expect("a fixture payload is a JSON object"); + object.insert(CWD_FIELD.to_string(), Value::String(cwd.to_string())); + Value::Object(object).to_string() + } + + struct ToolEvent<'a> { + event_name: &'a str, + cwd: &'a str, + session_id: &'a str, + turn_id: &'a str, + tool_name: &'a str, + tool_use_id: &'a str, + agent_id: Option<&'a str>, + } + + fn tool_event_json(event: &ToolEvent, tool_input: Option) -> String { + let mut object = Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(event.event_name.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String(event.session_id.to_string()), + ); + object.insert( + TURN_ID_FIELD.to_string(), + Value::String(event.turn_id.to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String(event.cwd.to_string())); + object.insert( + TOOL_NAME_FIELD.to_string(), + Value::String(event.tool_name.to_string()), + ); + object.insert( + TOOL_USE_ID_FIELD.to_string(), + Value::String(event.tool_use_id.to_string()), + ); + if let Some(agent_id) = event.agent_id { + object.insert( + AGENT_ID_FIELD.to_string(), + Value::String(agent_id.to_string()), + ); + } + if let Some(tool_input) = tool_input { + object.insert(TOOL_INPUT_FIELD.to_string(), tool_input); + } + Value::Object(object).to_string() + } + + struct TrackedCall<'a> { + cwd: &'a str, + session_id: &'a str, + turn_id: &'a str, + tool_name: &'a str, + tool_use_id: &'a str, + agent_id: Option<&'a str>, + } + + impl TrackedCall<'_> { + fn pre(&self) -> String { + tool_event_json( + &ToolEvent { + event_name: HOOK_EVENT_PRE_TOOL_USE, + cwd: self.cwd, + session_id: self.session_id, + turn_id: self.turn_id, + tool_name: self.tool_name, + tool_use_id: self.tool_use_id, + agent_id: self.agent_id, + }, + Some(json!({ "command": "echo regression >> file.txt" })), + ) + } + + fn post(&self) -> String { + tool_event_json( + &ToolEvent { + event_name: HOOK_EVENT_POST_TOOL_USE, + cwd: self.cwd, + session_id: self.session_id, + turn_id: self.turn_id, + tool_name: self.tool_name, + tool_use_id: self.tool_use_id, + agent_id: self.agent_id, + }, + None, + ) + } + } + + fn bash_call<'a>(cwd: &'a str, session_id: &'a str, tool_use_id: &'a str) -> TrackedCall<'a> { + TrackedCall { + cwd, + session_id, + turn_id: "turn-1", + tool_name: CODEX_TRACKED_TOOL_BASH, + tool_use_id, + agent_id: None, + } + } + + fn turn_event_json(event_name: &str, cwd: &str, session_id: &str, turn_id: &str) -> String { + json!({ + HOOK_EVENT_NAME_FIELD: event_name, + SESSION_ID_FIELD: session_id, + TURN_ID_FIELD: turn_id, + CWD_FIELD: cwd, + }) + .to_string() + } + + fn session_end_json(cwd: &str, session_id: &str) -> String { + json!({ + HOOK_EVENT_NAME_FIELD: HOOK_EVENT_SESSION_END, + SESSION_ID_FIELD: session_id, + CWD_FIELD: cwd, + }) + .to_string() + } + + fn other_harness_payload(operation: &str, scope_id: &str) -> String { + json!({ + "operation": operation, + "scope_id": scope_id, + "event_id": format!("{scope_id}|{operation}"), + "actor_kind": OTHER_HARNESS_ACTOR_KIND, + }) + .to_string() + } + + #[test] + fn test1_tracked_bash_success_closes_ai_exclusive_ac8() { + let repo = CodexRepo::new("test1-bash-success"); + let cwd = repo.cwd(); + let pre = fixture_at(PROBE01_SHELL_PRE, &cwd); + let post = fixture_at(PROBE01_SHELL_POST, &cwd); + + assert_eq!( + repo.drive(&pre).expect("PreToolUse should succeed"), + "", + "a tracked PreToolUse that established Start returns the neutral response" + ); + let scope_id = repo.live_scope_id(); + + repo.write("file.txt", "one\ntwo\n"); + + assert_eq!(repo.drive(&post).expect("PostToolUse should succeed"), ""); + assert!( + repo.adapter_state().attempts.is_empty(), + "the closed attempt must be removed from adapter bookkeeping" + ); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("codex".to_string(), "closed".to_string())) + ); + let worktree_id = repo.worktree_id(); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![( + "ai_exclusive".to_string(), + Some(scope_id.clone()), + "close".to_string(), + )] + ); + assert_eq!( + worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), + Some(repo.working_tree()) + ); + assert_eq!( + processed_events(&db), + vec![ + (scope_id.clone(), codex_scope_close_event_id(&scope_id)), + (scope_id.clone(), codex_scope_start_event_id(&scope_id)), + ], + "rows are ordered by (scope_id, event_id), and 'close' sorts before 'start'" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test2_failed_bash_partial_write_still_closes_ai_exclusive_ac9() { + let repo = CodexRepo::new("test2-failed-bash"); + let cwd = repo.cwd(); + + repo.drive(&fixture_at(PROBE02_FAILED_SHELL_PRE, &cwd)) + .expect("PreToolUse should succeed"); + let scope_id = repo.live_scope_id(); + + repo.write("file.txt", "one\npartial\n"); + + assert_eq!( + repo.drive(&fixture_at(PROBE02_FAILED_SHELL_POST, &cwd)) + .expect("a non-zero-exit shell still fires PostToolUse"), + "" + ); + assert!(repo.adapter_state().attempts.is_empty()); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("codex".to_string(), "closed".to_string())), + "D10: a Bash tool that partially mutated then exited non-zero closes its scope" + ); + let worktree_id = repo.worktree_id(); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![( + "ai_exclusive".to_string(), + Some(scope_id), + "close".to_string(), + )], + "the partial mutation is attributed to the failed tool's own scope" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test3_apply_patch_success_closes_ai_exclusive_ac8() { + let repo = CodexRepo::new("test3-apply-patch-success"); + let cwd = repo.cwd(); + + repo.drive(&fixture_at(PROBE01_APPLY_PATCH_PRE, &cwd)) + .expect("PreToolUse should succeed"); + let scope_id = repo.live_scope_id(); + + repo.write("alpha.txt", "alpha one\n"); + + repo.drive(&fixture_at(PROBE01_APPLY_PATCH_POST, &cwd)) + .expect("PostToolUse should succeed"); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("codex".to_string(), "closed".to_string())) + ); + let worktree_id = repo.worktree_id(); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![( + "ai_exclusive".to_string(), + Some(scope_id), + "close".to_string(), + )] + ); + assert_eq!( + worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), + Some(repo.working_tree()) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test4_apply_patch_verification_failure_mutates_nothing_and_is_swept_ac9() { + let repo = CodexRepo::new("test4-apply-patch-failure"); + let cwd = repo.cwd(); + let pre = fixture_at(PROBE06_APPLY_PATCH_FAILURE_PRE, &cwd); + let tree_before = repo.working_tree(); + + repo.drive(&pre).expect("PreToolUse should succeed"); + let scope_id = repo.live_scope_id(); + + let stop = { + let execution = pre_tool_use(&pre); + turn_event_json( + HOOK_EVENT_STOP, + &cwd, + &execution.identity.session_id, + &execution.identity.turn_id, + ) + }; + repo.drive(&stop) + .expect("Stop should retire the attempt that never received PostToolUse"); + + assert!(repo.adapter_state().attempts.is_empty()); + assert!(!repo.adapter_state().recovery.is_clear()); + assert_eq!( + repo.working_tree(), + tree_before, + "D10: apply_patch verification failure never touches the working tree" + ); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("codex".to_string(), "abandoned".to_string())) + ); + let worktree_id = repo.worktree_id(); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![], + "no mutation happened, so nothing is attributed" + ); + assert!(worktree_row(&db, &worktree_id) + .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline)); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test5_duplicate_tracked_lifecycle_is_idempotent_ac4() { + let repo = CodexRepo::new("test5-duplicate-lifecycle"); + let cwd = repo.cwd(); + let pre = fixture_at(PROBE01_SHELL_PRE, &cwd); + let post = fixture_at(PROBE01_SHELL_POST, &cwd); + + repo.drive(&pre).expect("first PreToolUse should succeed"); + let scope_id = repo.live_scope_id(); + assert_eq!( + repo.drive(&pre) + .expect("duplicate PreToolUse should be idempotent"), + "" + ); + assert_eq!( + repo.live_scope_id(), + scope_id, + "AC4: duplicate delivery of a live PreToolUse reuses the same ScopeId" + ); + + repo.write("file.txt", "one\ntwo\n"); + repo.drive(&post).expect("first PostToolUse should succeed"); + + let db = repo.db(); + let (revision_before, events_before, processed_before) = ( + worktree_row(&db, &repo.worktree_id()) + .map(|(revision, _, _)| revision) + .expect("a worktree row should exist"), + count(&db, "mutation_trace_events"), + count(&db, "mutation_trace_processed_events"), + ); + + assert_eq!( + repo.drive(&post) + .expect("duplicate PostToolUse delivery must be a safe no-op"), + "" + ); + + let db = repo.db(); + assert_eq!( + worktree_row(&db, &repo.worktree_id()).map(|(revision, _, _)| revision), + Some(revision_before) + ); + assert_eq!(count(&db, "mutation_trace_events"), events_before); + assert_eq!( + count(&db, "mutation_trace_processed_events"), + processed_before + ); + assert_eq!( + processed_events(&db) + .into_iter() + .filter(|(scope, event)| scope == &scope_id + && event == &codex_scope_close_event_id(&scope_id)) + .count(), + 1 + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test6_interrupted_tracked_execution_is_retired_by_interrupt_ac11() { + let repo = CodexRepo::new("test6-interrupt"); + let cwd = repo.cwd(); + + repo.drive(&fixture_at(PROBE11_INTERRUPT_PRE, &cwd)) + .expect("PreToolUse should succeed"); + let scope_id = repo.live_scope_id(); + + repo.write("file.txt", "one\ninterrupted\n"); + + assert_eq!( + repo.drive(&fixture_at(PROBE11_INTERRUPT, &cwd)) + .expect("Interrupt should succeed"), + "" + ); + + assert!( + repo.adapter_state().attempts.is_empty(), + "AC11: Interrupt is a proven cleanup signal for the interrupted turn" + ); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("codex".to_string(), "abandoned".to_string())) + ); + let worktree_id = repo.worktree_id(); + assert!(worktree_row(&db, &worktree_id) + .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline)); + assert_eq!(mutation_events_for(&db, &worktree_id), vec![]); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test6b_session_end_is_the_load_bearing_backstop_ac11() { + let repo = CodexRepo::new("test6b-session-end"); + let cwd = repo.cwd(); + + repo.drive(&fixture_at(PROBE01_SHELL_PRE, &cwd)) + .expect("PreToolUse should succeed"); + let scope_id = repo.live_scope_id(); + + repo.write("file.txt", "one\nstranded\n"); + + assert_eq!( + repo.drive(&fixture_at(PROBE01_SESSION_END, &cwd)) + .expect("SessionEnd should succeed"), + "" + ); + + assert!( + repo.adapter_state().attempts.is_empty(), + "D12: SessionEnd is the load-bearing whole-session backstop" + ); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("codex".to_string(), "abandoned".to_string())) + ); + assert!(worktree_row(&db, &repo.worktree_id()) + .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline)); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test7_subagent_tracked_tool_gets_its_own_scope_identity() { + let repo = CodexRepo::new("test7-subagent"); + let cwd = repo.cwd(); + let subagent_pre = fixture_at(PROBE08_AGENT_APPLY_PATCH_PRE, &cwd); + let subagent_identity = pre_tool_use(&subagent_pre).identity; + let agent_id = subagent_identity + .agent_id + .clone() + .expect("probe08 carries a delegated-agent identity"); + let main_thread = TrackedCall { + cwd: &cwd, + session_id: &subagent_identity.session_id, + turn_id: "main-turn", + tool_name: CODEX_TRACKED_TOOL_BASH, + tool_use_id: "exec-main-thread", + agent_id: None, + }; + + repo.drive(&main_thread.pre()) + .expect("main-thread PreToolUse should succeed"); + repo.drive(&subagent_pre) + .expect("subagent PreToolUse should succeed"); + + let state = repo.adapter_state(); + assert_eq!(state.attempts.len(), 2); + let subagent_scope_id = state + .attempts + .iter() + .find(|attempt| attempt.agent_id.as_deref() == Some(agent_id.as_str())) + .map(|attempt| attempt.scope_id.clone()) + .expect("the subagent attempt carries its agent_id"); + let main_scope_id = state + .attempts + .iter() + .find(|attempt| attempt.agent_id.is_none()) + .map(|attempt| attempt.scope_id.clone()) + .expect("the main-thread attempt has no agent_id"); + assert_ne!(subagent_scope_id, main_scope_id); + assert!(subagent_scope_id.contains(&agent_id)); + + repo.drive(&fixture_at(PROBE08_SUBAGENT_STOP, &cwd)) + .expect("SubagentStop should succeed"); + + let remaining = repo.adapter_state(); + assert_eq!( + remaining + .attempts + .iter() + .map(|attempt| attempt.scope_id.clone()) + .collect::>(), + vec![main_scope_id.clone()], + "D12: SubagentStop sweeps only the ending agent's attempts" + ); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &subagent_scope_id).map(|(_, status)| status), + Some("abandoned".to_string()) + ); + assert_eq!( + scope_status(&db, &main_scope_id).map(|(_, status)| status), + Some("active".to_string()) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test8_linked_worktree_advances_only_its_own_cursor_ac14() { + let repo = CodexRepo::new("test8-linked-worktree"); + let worktree_path = repo.add_worktree("codex-worktree"); + let worktree_cwd = CodexRepo::cwd_at(&worktree_path); + + let main_worktree_id = repo.worktree_id(); + let linked_worktree_id = CodexRepo::worktree_id_at(&worktree_path); + assert_ne!(main_worktree_id, linked_worktree_id); + + repo.drive_flush() + .expect("main-checkout baseline flush should succeed"); + let main_cursor_before = worktree_row(&repo.db(), &main_worktree_id) + .map(|(_, cursor_tree, _)| cursor_tree) + .expect("main checkout should have a baseline worktree row"); + + let pre = fixture_at(PROBE10_WORKTREE_PRE, &worktree_cwd); + let post = { + let identity = pre_tool_use(&pre).identity; + tool_event_json( + &ToolEvent { + event_name: HOOK_EVENT_POST_TOOL_USE, + cwd: &worktree_cwd, + session_id: &identity.session_id, + turn_id: &identity.turn_id, + tool_name: &identity.tool_name, + tool_use_id: &identity.tool_use_id, + agent_id: None, + }, + None, + ) + }; + + repo.drive(&pre) + .expect("linked-worktree PreToolUse should succeed"); + let scope_id = CodexRepo::adapter_state_at(&worktree_path).attempts[0] + .scope_id + .clone(); + fs::write(worktree_path.join("wt.txt"), "worktree-write\n") + .expect("the linked worktree's own write should succeed"); + repo.drive(&post) + .expect("linked-worktree PostToolUse should succeed"); + + let db = repo.db(); + assert_eq!( + worktree_row(&db, &main_worktree_id).map(|(_, cursor_tree, _)| cursor_tree), + Some(main_cursor_before), + "AC14: the main checkout's cursor must not move" + ); + let linked_row = + worktree_row(&db, &linked_worktree_id).expect("linked worktree row should exist"); + assert_eq!( + linked_row.1, + CodexRepo::working_tree_at(&worktree_path), + "AC14: the linked worktree's own cursor advances" + ); + assert_eq!( + mutation_events_for(&db, &linked_worktree_id), + vec![( + "ai_exclusive".to_string(), + Some(scope_id), + "close".to_string(), + )] + ); + assert_eq!(mutation_events_for(&db, &main_worktree_id), vec![]); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test9_successful_mcp_lifecycle_creates_no_mutation_scope_ac9b() { + let repo = CodexRepo::new("test9-mcp-success"); + let cwd = repo.cwd(); + + assert_eq!( + repo.drive(&fixture_at(PROBE12_MCP_PRE, &cwd)) + .expect("an MCP PreToolUse is allowed"), + "", + "AC9b: an Untracked tool gets the Codex-neutral continue response" + ); + repo.write("mcp_a.txt", "written by the MCP server\n"); + assert_eq!( + repo.drive(&fixture_at(PROBE12_MCP_POST, &cwd)) + .expect("an MCP PostToolUse is ignored"), + "" + ); + + assert!( + !repo.adapter_state_file_exists(), + "AC9b: an Untracked lifecycle writes no adapter bookkeeping at all" + ); + + let db = repo.db(); + assert_eq!(count(&db, "mutation_trace_scopes"), 0); + assert_eq!(count(&db, "mutation_trace_events"), 0); + assert_eq!(count(&db, "mutation_trace_processed_events"), 0); + assert_eq!(count(&db, "mutation_trace_worktrees"), 0); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test10_mcp_mutate_then_error_leaves_no_zombie_state_ac9c() { + let repo = CodexRepo::new("test10-mcp-mutate-then-error"); + let cwd = repo.cwd(); + + repo.drive(&fixture_at(PROBE13_MCP_MUTATE_THEN_ERROR_PRE, &cwd)) + .expect("an MCP PreToolUse is allowed"); + repo.write("mcp_b.txt", "mutated before the MCP error\n"); + + repo.drive(&fixture_at(PROBE13_MCP_STOP, &cwd)) + .expect("Stop should find nothing to retire"); + repo.drive(&fixture_at(PROBE13_MCP_SESSION_END, &cwd)) + .expect("SessionEnd should find nothing to retire"); + + assert!( + !repo.adapter_state_file_exists(), + "AC9c: no Start occurred, so there is no stale attempt and no recovery to arm" + ); + + let db = repo.db(); + assert_eq!(count(&db, "mutation_trace_scopes"), 0); + assert_eq!(count(&db, "mutation_trace_events"), 0); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test11_failed_mcp_then_tracked_successor_starts_clean_ac9d() { + let repo = CodexRepo::new("test11-mcp-then-tracked"); + let cwd = repo.cwd(); + let failed_mcp = fixture_at(PROBE14_MCP_FAILED_PRE, &cwd); + let failed_identity = pre_tool_use(&failed_mcp).identity; + + repo.drive(&failed_mcp) + .expect("the failing MCP PreToolUse is allowed"); + repo.write("mcp_c1.txt", "mutated by the failing MCP tool\n"); + repo.drive(&fixture_at(PROBE14_MCP_SUCCESSOR_PRE, &cwd)) + .expect("the MCP successor is also Untracked"); + repo.drive(&fixture_at(PROBE14_MCP_SUCCESSOR_POST, &cwd)) + .expect("the MCP successor's PostToolUse is ignored"); + + let tracked_successor = TrackedCall { + cwd: &cwd, + session_id: &failed_identity.session_id, + turn_id: &failed_identity.turn_id, + tool_name: CODEX_TRACKED_TOOL_BASH, + tool_use_id: "exec-tracked-successor", + agent_id: None, + }; + repo.drive(&tracked_successor.pre()) + .expect("the tracked successor should Start normally"); + let scope_id = repo.live_scope_id(); + assert!( + repo.adapter_state().recovery.is_clear(), + "AC9d: no MCP attempt existed, so no successor barrier runs" + ); + + repo.write("file.txt", "one\ntracked-successor\n"); + repo.drive(&tracked_successor.post()) + .expect("the tracked successor's PostToolUse should close its scope"); + + let db = repo.db(); + let worktree_id = repo.worktree_id(); + assert_eq!(count(&db, "mutation_trace_scopes"), 1); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![( + "ai_exclusive".to_string(), + Some(scope_id), + "close".to_string(), + )], + "AC9d: the tracked successor is the only live scope — no false AiContended" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test12_parallel_mcp_executions_create_no_scopes_ac9e() { + let repo = CodexRepo::new("test12-parallel-mcp"); + let cwd = repo.cwd(); + + repo.drive(&fixture_at(PROBE16_MCP_PARALLEL_A_PRE, &cwd)) + .expect("parallel MCP A is allowed"); + repo.drive(&fixture_at(PROBE16_MCP_PARALLEL_B_PRE, &cwd)) + .expect("parallel MCP B is allowed"); + assert!( + !repo.adapter_state_file_exists(), + "AC9e: neither overlapping MCP execution creates adapter state" + ); + + repo.write("mcp_parallel_a.txt", "a\n"); + repo.write("mcp_parallel_b.txt", "b\n"); + + repo.drive(&fixture_at(PROBE16_MCP_PARALLEL_A_POST, &cwd)) + .expect("parallel MCP A PostToolUse is ignored"); + repo.drive(&fixture_at(PROBE16_MCP_PARALLEL_B_POST, &cwd)) + .expect("parallel MCP B PostToolUse is ignored"); + + assert!(!repo.adapter_state_file_exists()); + + let db = repo.db(); + assert_eq!( + count(&db, "mutation_trace_scopes"), + 0, + "AC9e: overlapping MCP executions produce no scopes and therefore no AiContended" + ); + assert_eq!(count(&db, "mutation_trace_events"), 0); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test13_tracked_scope_overlapping_an_mcp_mutation_is_tracked_exclusivity_ac9f() { + let repo = CodexRepo::new("test13-tracked-plus-mcp"); + let cwd = repo.cwd(); + + repo.drive(&fixture_at(PROBE01_SHELL_PRE, &cwd)) + .expect("the tracked Bash PreToolUse should Start"); + let scope_id = repo.live_scope_id(); + + repo.drive(&fixture_at(PROBE12_MCP_PRE, &cwd)) + .expect("the overlapping MCP call is allowed and untracked"); + repo.write("mcp_a.txt", "written by the MCP server, not by Bash\n"); + repo.drive(&fixture_at(PROBE12_MCP_POST, &cwd)) + .expect("the MCP PostToolUse is ignored"); + + repo.drive(&fixture_at(PROBE01_SHELL_POST, &cwd)) + .expect("the tracked Bash PostToolUse should close its scope"); + + let db = repo.db(); + let worktree_id = repo.worktree_id(); + assert_eq!(count(&db, "mutation_trace_scopes"), 1); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![( + "ai_exclusive".to_string(), + Some(scope_id), + "close".to_string(), + )], + "AC9f: ai_exclusive means exactly one TRACKED scope was live in the interval, \ + not that the tracked scope authored every mutation — the MCP call did mutate \ + mcp_a.txt inside this interval and remains unattributed (D14/D23)" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test14_unknown_tool_is_allowed_untracked_ac9b() { + let repo = CodexRepo::new("test14-unknown-tool"); + let cwd = repo.cwd(); + let unknown = TrackedCall { + cwd: &cwd, + session_id: "session-unknown", + turn_id: "turn-1", + tool_name: "some_future_codex_tool", + tool_use_id: "exec-unknown", + agent_id: None, + }; + + assert_eq!( + repo.drive(&unknown.pre()) + .expect("an unknown tool is never denied for being untracked"), + "" + ); + repo.write("unknown_tool_output.txt", "the unknown tool mutated\n"); + assert_eq!( + repo.drive(&unknown.post()) + .expect("an unknown tool's PostToolUse is ignored"), + "" + ); + + assert!(!repo.adapter_state_file_exists()); + + let db = repo.db(); + assert_eq!(count(&db, "mutation_trace_scopes"), 0); + assert_eq!(count(&db, "mutation_trace_events"), 0); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test15_regression_matrix_leaves_raw_agent_trace_tables_untouched_ac20() { + let repo = CodexRepo::new("test15-raw-tables"); + let cwd = repo.cwd(); + + let before = raw_agent_trace_row_counts(&repo.db()); + assert_eq!(before, [0, 0, 0, 0, 0]); + + repo.drive(&fixture_at(PROBE01_SHELL_PRE, &cwd)) + .expect("tracked PreToolUse should succeed"); + repo.write("file.txt", "one\ntwo\n"); + repo.drive(&fixture_at(PROBE01_SHELL_POST, &cwd)) + .expect("tracked PostToolUse should succeed"); + repo.drive(&fixture_at(PROBE12_MCP_PRE, &cwd)) + .expect("MCP PreToolUse should succeed"); + repo.write("mcp_a.txt", "mcp\n"); + repo.drive(&fixture_at(PROBE12_MCP_POST, &cwd)) + .expect("MCP PostToolUse should succeed"); + repo.drive(&fixture_at(PROBE01_APPLY_PATCH_PRE, &cwd)) + .expect("apply_patch PreToolUse should succeed"); + repo.drive(&fixture_at(PROBE01_STOP, &cwd)) + .expect("Stop should succeed"); + + let db = repo.db(); + assert_eq!( + raw_agent_trace_row_counts(&db), + before, + "AC20: mutation-scope-only regressions leave diff_traces, \ + post_commit_patch_intersections, agent_traces, messages and parts unchanged" + ); + assert!(count(&db, "mutation_trace_scopes") > 0); + assert!( + state::adapter_state_dir(&repo.git_dir()).starts_with(repo.git_dir()), + "AC20: adapter state lives only below /sce/" + ); + } + + #[test] + fn test16_arbitrary_blocker_zombie_then_same_lane_successor_ac9a() { + let repo = CodexRepo::new("test16-zombie-successor"); + let cwd = repo.cwd(); + let zombie = bash_call(&cwd, "session-lane", "exec-zombie"); + let successor = bash_call(&cwd, "session-lane", "exec-successor"); + + repo.drive(&zombie.pre()) + .expect("the first tracked PreToolUse should Start"); + let zombie_scope_id = repo.live_scope_id(); + repo.write("file.txt", "one\nzombie-partial\n"); + + repo.drive(&successor.pre()) + .expect("the same-lane successor should sweep, flush, then Start"); + let successor_scope_id = repo.live_scope_id(); + assert_ne!(zombie_scope_id, successor_scope_id); + assert!( + repo.adapter_state().recovery.is_clear(), + "the quiescent flush must clear the barrier before Start(B)" + ); + + repo.write("file.txt", "one\nzombie-partial\nsuccessor\n"); + repo.drive(&successor.post()) + .expect("the successor's PostToolUse should close its scope"); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &zombie_scope_id), + Some(("codex".to_string(), "abandoned".to_string())), + "AC9a: the stale same-lane predecessor is abandoned, never closed" + ); + assert_eq!( + scope_status(&db, &successor_scope_id), + Some(("codex".to_string(), "closed".to_string())) + ); + let worktree_id = repo.worktree_id(); + let events = mutation_events_for(&db, &worktree_id); + assert!( + events.iter().all(|(kind, scope, _)| kind != "ai_contended" + && scope.as_deref() != Some(zombie_scope_id.as_str())), + "AC9a: no false AiContended and nothing attributed to the zombie: {events:?}" + ); + assert_eq!( + events.last(), + Some(&( + "ai_exclusive".to_string(), + Some(successor_scope_id), + "close".to_string() + )), + "the successor is the only live scope at its own Close" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test17_crash_before_start_commit_is_recovered_conservatively_ac21a() { + let repo = CodexRepo::new("test17-crash-before-start"); + let cwd = repo.cwd(); + let git_dir = repo.git_dir(); + let crashed = bash_call(&cwd, "session-crash", "exec-crashed"); + let key = key("session-crash", None, "exec-crashed"); + + let attempt = state::seed_attempt_for_tests( + &git_dir, + &key, + "turn-1", + CODEX_TRACKED_TOOL_BASH, + state::AttemptPhase::PendingStart, + ); + + repo.drive(&crashed.post()) + .expect("D11: a pending_start attempt must abandon, not late-Start"); + + assert!(repo.adapter_state().attempts.is_empty()); + assert!(!repo.adapter_state().recovery.is_clear()); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &attempt.scope_id), + None, + "AC21a: a Start that never committed must never appear as a real scope" + ); + assert_eq!(count(&db, "mutation_trace_events"), 0); + + let fresh = bash_call(&cwd, "session-crash", "exec-fresh"); + repo.drive(&fresh.pre()) + .expect("the next tracked PreToolUse proceeds after the quiescent flush"); + assert!(repo.adapter_state().recovery.is_clear()); + assert_eq!(repo.adapter_state().attempts.len(), 1); + + assert_raw_agent_trace_tables_untouched(&repo.db()); + } + + #[test] + fn test18_start_committed_before_state_settlement_is_abandoned_ac21b() { + let repo = CodexRepo::new("test18-crash-after-start"); + let cwd = repo.cwd(); + let git_dir = repo.git_dir(); + let crashed = bash_call(&cwd, "session-crash", "exec-crashed"); + let key = key("session-crash", None, "exec-crashed"); + + let attempt = state::seed_attempt_for_tests( + &git_dir, + &key, + "turn-1", + CODEX_TRACKED_TOOL_BASH, + state::AttemptPhase::PendingStart, + ); + let scope_id = attempt.scope_id.clone(); + + repo.drive_generic(&scope_boundary_payload( + "start", + &scope_id, + &codex_scope_start_event_id(&scope_id), + )) + .expect("the runtime Start should commit durably"); + assert_eq!( + repo.adapter_state().attempts[0].phase, + state::AttemptPhase::PendingStart + ); + + repo.drive(&crashed.post()) + .expect("D11: a committed Start with unsettled bookkeeping must be abandoned"); + + assert!(repo.adapter_state().attempts.is_empty()); + assert!(!repo.adapter_state().recovery.is_clear()); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id), + Some(("codex".to_string(), "abandoned".to_string())), + "AC21b: the committed Start settles as a real abandonment, not a late Start" + ); + assert!(worktree_row(&db, &repo.worktree_id()) + .is_some_and(|(_, _, needs_rebaseline)| needs_rebaseline)); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test19_close_committed_before_state_cleanup_is_replay_safe_ac21c() { + let repo = CodexRepo::new("test19-crash-after-close"); + let cwd = repo.cwd(); + let call = bash_call(&cwd, "session-close", "exec-close"); + + repo.drive(&call.pre()).expect("PreToolUse should succeed"); + let scope_id = repo.live_scope_id(); + repo.write("file.txt", "one\ntwo\n"); + + repo.drive_generic(&scope_boundary_payload( + "close", + &scope_id, + &codex_scope_close_event_id(&scope_id), + )) + .expect("the runtime Close should commit durably"); + assert_eq!(repo.adapter_state().attempts.len(), 1); + + let db = repo.db(); + let (revision_before, events_before) = ( + worktree_row(&db, &repo.worktree_id()) + .map(|(revision, _, _)| revision) + .expect("a worktree row should exist"), + count(&db, "mutation_trace_events"), + ); + + repo.drive(&call.post()) + .expect("a replayed Close against an already-durable commit must be safe"); + + assert!( + repo.adapter_state().attempts.is_empty(), + "the stale bookkeeping is finally cleared" + ); + + let db = repo.db(); + assert_eq!( + worktree_row(&db, &repo.worktree_id()).map(|(revision, _, _)| revision), + Some(revision_before), + "AC21c: a durably completed Close is never re-applied as a second transition" + ); + assert_eq!(count(&db, "mutation_trace_events"), events_before); + assert_eq!( + scope_status(&db, &scope_id).map(|(_, status)| status), + Some("closed".to_string()) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test20_recovery_pending_blocks_a_tracked_successor_until_recovery_succeeds_ac12() { + let repo = CodexRepo::new("test20-recovery-barrier"); + let cwd = repo.cwd(); + let first = bash_call(&cwd, "session-a", "exec-a"); + let second = bash_call(&cwd, "session-b", "exec-b"); + let blocked = bash_call(&cwd, "session-c", "exec-c"); + + repo.drive(&first.pre()).expect("session-a Start"); + repo.drive(&second.pre()).expect("session-b Start"); + assert_eq!(repo.adapter_state().attempts.len(), 2); + + repo.write("file.txt", "one\nabandoned\n"); + repo.drive(&turn_event_json( + HOOK_EVENT_INTERRUPT, + &cwd, + "session-a", + "turn-1", + )) + .expect("Interrupt should retire session-a's attempt"); + assert!(!repo.adapter_state().recovery.is_clear()); + assert_eq!(repo.adapter_state().attempts.len(), 1); + + assert_eq!( + repo.drive(&blocked.pre()) + .expect("a barred PreToolUse still returns Ok with a deny payload"), + pre_tool_use_deny_json(FAIL_CLOSED_DENY_REASON), + "AC12: while recovery is armed and attempts remain, a tracked successor is denied" + ); + assert!( + repo.adapter_state() + .attempts + .iter() + .all(|attempt| attempt.tool_use_id != "exec-c"), + "the denied successor must never be admitted" + ); + + repo.drive(&session_end_json(&cwd, "session-b")) + .expect("SessionEnd should retire session-b's attempt"); + assert!(repo.adapter_state().attempts.is_empty()); + assert!(!repo.adapter_state().recovery.is_clear()); + + repo.drive(&blocked.pre()) + .expect("once quiescent, the flush runs and the successor Starts"); + assert!( + repo.adapter_state().recovery.is_clear(), + "AC12: recovery_pending clears only on durable flush success" + ); + let scope_id = repo.live_scope_id(); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &scope_id).map(|(_, status)| status), + Some("active".to_string()) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test21_reused_tool_use_id_after_terminal_gets_a_fresh_scope_id_ac5() { + let repo = CodexRepo::new("test21-reused-identifier"); + let cwd = repo.cwd(); + let call = bash_call(&cwd, "session-reuse", "exec-reused"); + + repo.drive(&call.pre()).expect("first PreToolUse"); + let first_scope_id = repo.live_scope_id(); + repo.write("file.txt", "one\nfirst\n"); + repo.drive(&call.post()).expect("first PostToolUse"); + assert!(repo.adapter_state().attempts.is_empty()); + + repo.drive(&call.pre()) + .expect("a later attempt reusing the same tool_use_id"); + let second_scope_id = repo.live_scope_id(); + assert_ne!( + first_scope_id, second_scope_id, + "AC5: a terminal ScopeId is never reused" + ); + + repo.write("file.txt", "one\nfirst\nsecond\n"); + repo.drive(&call.post()).expect("second PostToolUse"); + + let db = repo.db(); + assert_eq!( + scope_status(&db, &first_scope_id).map(|(_, status)| status), + Some("closed".to_string()) + ); + assert_eq!( + scope_status(&db, &second_scope_id).map(|(_, status)| status), + Some("closed".to_string()) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test22_self_detaching_descendant_write_is_not_folded_into_the_closed_scope_ac15() { + let repo = CodexRepo::new("test22-detached-descendant"); + let cwd = repo.cwd(); + + repo.drive(&fixture_at(PROBE09_DETACHED_PRE, &cwd)) + .expect("PreToolUse should succeed"); + let scope_id = repo.live_scope_id(); + + repo.write("file.txt", "one\nforeground\n"); + let tree_at_close = repo.working_tree(); + repo.drive(&fixture_at(PROBE09_DETACHED_POST, &cwd)) + .expect("PostToolUse should succeed"); + + let db = repo.db(); + let worktree_id = repo.worktree_id(); + assert_eq!( + worktree_row(&db, &worktree_id).map(|(_, cursor_tree, _)| cursor_tree), + Some(tree_at_close.clone()) + ); + let events_before_flush = mutation_events_for(&db, &worktree_id); + + repo.write("file.txt", "one\nforeground\ndetached-descendant\n"); + let tree_after_descendant = repo.working_tree(); + assert_ne!(tree_after_descendant, tree_at_close); + + repo.drive_flush() + .expect("a later diagnostic flush should succeed"); + + let db = repo.db(); + let events_after_flush = mutation_events_for(&db, &worktree_id); + assert_eq!(events_after_flush.len(), events_before_flush.len() + 1); + let (attribution_kind, attribution_scope_id, _) = events_after_flush + .last() + .expect("a flush event should exist"); + assert_eq!( + attribution_kind, "ineligible_unscoped", + "AC15/D16: SCE does not supervise self-detaching descendants; \ + a post-terminal write is never folded into the closed tool scope" + ); + assert_ne!(attribution_scope_id.as_deref(), Some(scope_id.as_str())); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test23_denied_tracked_execution_leaves_no_untracked_start_ac7() { + let repo = CodexRepo::new("test23-policy-denied"); + let cwd = repo.cwd(); + fs::create_dir_all(repo.root.join(".sce")).expect(".sce dir should be created"); + fs::write( + repo.root.join(".sce").join("config.json"), + concat!( + r#"{"policies":{"bash":{"custom":[{"id":"no-rm","#, + r#""match":{"argv_prefix":["rm"]},"#, + r#""message":"rm is blocked in this repository"}]}}}"#, + ), + ) + .expect("repo bash policy config should write"); + + let denied = tool_event_json( + &ToolEvent { + event_name: HOOK_EVENT_PRE_TOOL_USE, + cwd: &cwd, + session_id: "session-denied", + turn_id: "turn-1", + tool_name: CODEX_TRACKED_TOOL_BASH, + tool_use_id: "exec-denied", + agent_id: None, + }, + Some(json!({ "command": "rm -rf build" })), + ); + + let response = repo + .drive(&denied) + .expect("a policy-denied Bash command still returns Ok with a deny payload"); + assert!(response.contains(r#""permissionDecision":"deny""#)); + + assert!( + !repo.adapter_state_file_exists(), + "AC7: a denied tracked execution must never leave an untracked Start behind" + ); + + let db = repo.db(); + assert_eq!(count(&db, "mutation_trace_scopes"), 0); + assert_eq!(count(&db, "mutation_trace_events"), 0); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test24_cross_harness_overlap_at_a_non_confirming_boundary_is_ineligible_ac10() { + let repo = CodexRepo::new("test24-cross-harness-ineligible"); + let cwd = repo.cwd(); + let codex_call = bash_call(&cwd, "session-cross", "exec-codex"); + let other_scope_id = "claude-scope-1"; + + repo.drive_generic(&other_harness_payload("start", other_scope_id)) + .expect("the other harness's Start should commit"); + repo.drive(&codex_call.pre()) + .expect("the Codex PreToolUse should Start"); + let codex_scope_id = repo.live_scope_id(); + + repo.write("file.txt", "one\ncontended\n"); + + repo.drive_generic(&other_harness_payload("close", other_scope_id)) + .expect("the other harness's Close should commit"); + + let db = repo.db(); + let worktree_id = repo.worktree_id(); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![("ineligible_unscoped".to_string(), None, "close".to_string())], + "AC10/D14: an unconfirmed live Codex scope forces IneligibleUnscoped at a \ + boundary that does not confirm it — never AiContended" + ); + let mut active = active_scopes_for(&db, &worktree_id); + active.sort(); + let mut expected = vec![codex_scope_id, other_scope_id.to_string()]; + expected.sort(); + assert_eq!( + active, expected, + "active_scopes still records the complete live set; only eligibility changes" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test25_cross_harness_overlap_at_the_codex_close_is_contended_ac10() { + let repo = CodexRepo::new("test25-cross-harness-contended"); + let cwd = repo.cwd(); + let codex_call = bash_call(&cwd, "session-cross", "exec-codex"); + let other_scope_id = "claude-scope-1"; + + repo.drive_generic(&other_harness_payload("start", other_scope_id)) + .expect("the other harness's Start should commit"); + repo.drive(&codex_call.pre()) + .expect("the Codex PreToolUse should Start"); + let codex_scope_id = repo.live_scope_id(); + + repo.write("file.txt", "one\ncontended\n"); + + repo.drive(&codex_call.post()) + .expect("the Codex PostToolUse should close its scope"); + + let db = repo.db(); + let worktree_id = repo.worktree_id(); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![("ai_contended".to_string(), None, "close".to_string())], + "AC10/D14: the Codex scope's own Close confirms it, so the overlap with the \ + other harness's live scope is attributed AiContended" + ); + assert_eq!( + scope_status(&db, &codex_scope_id).map(|(_, status)| status), + Some("closed".to_string()) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test26_a_second_unconfirmed_codex_scope_suppresses_contention_ac10() { + let repo = CodexRepo::new("test26-second-codex-scope"); + let cwd = repo.cwd(); + let confirmed = bash_call(&cwd, "session-one", "exec-one"); + let unconfirmed = bash_call(&cwd, "session-two", "exec-two"); + let other_scope_id = "claude-scope-1"; + + repo.drive_generic(&other_harness_payload("start", other_scope_id)) + .expect("the other harness's Start should commit"); + repo.drive(&confirmed.pre()) + .expect("the first Codex PreToolUse should Start"); + repo.drive(&unconfirmed.pre()) + .expect("a second Codex lane's PreToolUse should Start"); + assert_eq!(repo.adapter_state().attempts.len(), 2); + + repo.write("file.txt", "one\ncontended\n"); + + repo.drive(&confirmed.post()) + .expect("the first Codex scope's Close should commit"); + + let db = repo.db(); + let worktree_id = repo.worktree_id(); + assert_eq!( + mutation_events_for(&db, &worktree_id), + vec![("ineligible_unscoped".to_string(), None, "close".to_string())], + "AC10/D14: a second unconfirmed live Codex scope suppresses attribution back to \ + IneligibleUnscoped even at a confirming Codex Close" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test27_tracked_fixtures_persist_scope_provenance_ac3() { + for (label, fixture) in [ + ("bash", PROBE01_SHELL_PRE), + ("apply-patch", PROBE01_APPLY_PATCH_PRE), + ] { + let repo = CodexRepo::new(&format!("test27-provenance-{label}")); + let cwd = repo.cwd(); + + repo.drive(&fixture_at(fixture, &cwd)) + .expect("a tracked PreToolUse should Start"); + let scope_id = repo.live_scope_id(); + + let db = repo.db(); + assert_eq!( + scope_provenance(&db, &scope_id), + Some(( + "cx_01a07c1e-e08e-7172-8032-cb9d62af21d9".to_string(), + Some("gpt-5.6-sol".to_string()) + )), + "AC3: the {label} fixture must persist its cx_ session and normalized model" + ); + assert_eq!(count(&db, "mutation_trace_scope_provenance"), 1); + assert_eq!( + scope_status(&db, &scope_id), + Some(("codex".to_string(), "active".to_string())) + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + } + + #[test] + fn test28_a_tracked_execution_without_a_model_persists_a_null_model_ac3() { + let repo = CodexRepo::new("test28-provenance-no-model"); + let cwd = repo.cwd(); + let call = bash_call(&cwd, "session-no-model", "exec-no-model"); + + repo.drive(&call.pre()) + .expect("a tracked PreToolUse without a model should still Start"); + let scope_id = repo.live_scope_id(); + + let db = repo.db(); + assert_eq!( + scope_provenance(&db, &scope_id), + Some(("cx_session-no-model".to_string(), None)), + "AC3: a missing model records model_id = NULL without losing the session" + ); + + assert_raw_agent_trace_tables_untouched(&db); + } + + #[test] + fn test29_untracked_and_delegation_tools_persist_no_provenance_ac3() { + let repo = CodexRepo::new("test29-untracked-no-provenance"); + let cwd = repo.cwd(); + + for fixture in [ + PROBE12_MCP_PRE, + PROBE08_SPAWN_AGENT_PRE, + PROBE08_WAIT_AGENT_PRE, + ] { + assert_eq!( + repo.drive(&fixture_at(fixture, &cwd)) + .expect("an untracked or delegation PreToolUse should succeed"), + "" + ); + } + + let db = repo.db(); + assert_eq!(count(&db, "mutation_trace_scopes"), 0); + assert_eq!(count(&db, "mutation_trace_scope_provenance"), 0); + + assert_raw_agent_trace_tables_untouched(&db); + } +} diff --git a/cli/src/services/hooks/commit_hooks.rs b/cli/src/services/hooks/commit_hooks.rs new file mode 100644 index 000000000..960f298de --- /dev/null +++ b/cli/src/services/hooks/commit_hooks.rs @@ -0,0 +1,662 @@ +use std::fs; +use std::path::Path; + +use anyhow::{anyhow, bail, Context, Result}; +use chrono::{DateTime, Utc}; +use serde_json::{to_string as serialize_to_json, Value}; + +use crate::services::agent_trace::{ + agent_trace_persisted_url, build_agent_trace_from_evidence, patch_has_touched_lines, + patches_have_overlap, validate_agent_trace_value, AgentTrace, AgentTraceEvidence, + AgentTraceMetadataInput, AgentTraceVcsType, +}; +use crate::services::agent_trace_db::{ + AgentTraceInsert, PostCommitPatchIntersectionInsert, RecentDiffTracePatches, +}; +use crate::services::config; +use crate::services::observability::traits::Logger; +use crate::services::patch::{ + combine_patches as combine_patches_fn, intersect_patches as intersect_patches_fn, + parse_patch as parse_patch_from_text, ParsedPatch, +}; +use crate::services::sync::auto_sync; + +use super::runtime::{ + commit_msg_policy_gate_passed, current_unix_time_ms, open_agent_trace_db_for_hook_runtime, + post_rewrite_no_op_reason, pre_commit_no_op_reason, read_hook_stdin, resolve_runtime_state, + run_git_command_capture_stdout, HookRuntimeState, +}; +use super::HookSubcommand; +use super::CANONICAL_SCE_COAUTHOR_TRAILER; + +pub(crate) fn run_pre_commit_subcommand_with_trace(repository_root: &Path) -> Result { + run_pre_commit_subcommand(repository_root) +} + +pub(crate) fn run_pre_commit_subcommand(repository_root: &Path) -> Result { + let runtime = resolve_runtime_state(repository_root)?; + + Ok(format!( + "pre-commit hook executed with no-op runtime state: {:?}", + pre_commit_no_op_reason(&runtime) + )) +} + +pub(crate) fn run_commit_msg_subcommand_in_repo( + repository_root: &Path, + message_file: &Path, + logger: Option<&dyn Logger>, +) -> Result { + let metadata = fs::metadata(message_file).with_context(|| { + format!( + "Invalid commit message file '{}': file does not exist or is not readable.", + message_file.display() + ) + })?; + + if !metadata.is_file() { + bail!( + "Invalid commit message file '{}': expected a regular file path.", + message_file.display() + ); + } + + let runtime = resolve_runtime_state(repository_root)?; + let original = fs::read_to_string(message_file).with_context(|| { + format!( + "Invalid commit message file '{}': failed to read UTF-8 content.", + message_file.display() + ) + })?; + + let gate_passed = commit_msg_policy_gate_passed(&runtime); + let ai_contribution_present = if gate_passed { + match staged_diff_has_ai_overlap(repository_root, logger) { + StagedDiffAiOverlapResult::Overlap => true, + StagedDiffAiOverlapResult::NoOverlap | StagedDiffAiOverlapResult::Error => false, + } + } else { + false + }; + let transformed = + apply_commit_msg_coauthor_policy(&runtime, ai_contribution_present, &original); + let trailer_applied = gate_passed && transformed != original; + + if trailer_applied { + fs::write(message_file, transformed.as_bytes()).with_context(|| { + format!( + "Failed to update commit message file '{}' with canonical co-author trailer.", + message_file.display() + ) + })?; + } + + Ok(format!( + "commit-msg hook processed message file '{}' (policy_gate_passed={}, trailer_applied={}).", + message_file.display(), + gate_passed, + trailer_applied + )) +} + +pub(crate) fn run_commit_msg_subcommand_with_trace( + repository_root: &Path, + _: &HookSubcommand, + message_file: &Path, + logger: Option<&dyn Logger>, +) -> Result { + run_commit_msg_subcommand_in_repo(repository_root, message_file, logger) +} + +pub(crate) fn run_post_commit_subcommand( + repository_root: &Path, + vcs_type: Option, + remote_url: &str, + logger: Option<&dyn Logger>, +) -> Result { + run_post_commit_subcommand_with( + repository_root, + vcs_type, + remote_url, + run_post_commit_intersection_flow, + run_post_commit_agent_trace_flow, + |root| { + config::resolve_hook_runtime_config(root).map(|runtime| runtime.agent_trace_auto_sync) + }, + |root| { + auto_sync::launch(root); + Ok(()) + }, + run_post_commit_passive_checkpoint, + logger, + ) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn run_post_commit_subcommand_with( + repository_root: &Path, + vcs_type: Option, + remote_url: &str, + run_intersection_flow: F, + run_agent_trace_flow: B, + resolve_auto_sync: C, + launch_auto_sync: L, + run_passive_checkpoint: K, + logger: Option<&dyn Logger>, +) -> Result +where + F: FnOnce(&Path) -> Result, + B: FnOnce( + &Path, + &PostCommitIntersectionFlowResult, + Option, + &str, + ) -> Result, + C: FnOnce(&Path) -> Result, + L: FnOnce(&Path) -> Result<()>, + K: FnOnce(&Path) -> Result<()>, +{ + let result = run_intersection_flow(repository_root)?; + let _agent_trace = run_agent_trace_flow(repository_root, &result, vcs_type, remote_url)?; + + if let Err(error) = run_passive_checkpoint(repository_root) { + if let Some(log) = logger { + log.warn( + "sce.agent_trace_db.passive_checkpoint_failed", + &error.to_string(), + &[], + None, + ); + } + } + + if resolve_auto_sync(repository_root)? { + let _ = launch_auto_sync(repository_root); + } + + Ok(format!( + "post-commit hook processed intersection: commit={}, intersection_files={}", + result.post_commit_data.commit_oid, + result.combined_recent_patch.files.len() + )) +} + +pub(crate) fn run_post_commit_passive_checkpoint(repository_root: &Path) -> Result<()> { + let db = open_agent_trace_db_for_hook_runtime( + repository_root, + "Failed to open Agent Trace DB for post-commit checkpoint.", + )?; + + db.passive_checkpoint() +} + +pub(crate) fn run_post_commit_agent_trace_flow( + repository_root: &Path, + flow_result: &PostCommitIntersectionFlowResult, + vcs_type: Option, + remote_url: &str, +) -> Result { + let db = open_agent_trace_db_for_hook_runtime( + repository_root, + "Failed to open Agent Trace DB for post-commit trace.", + )?; + + let direct_intersection = intersect_patches_fn( + &flow_result.combined_recent_patch, + &flow_result.post_commit_data.parsed_patch, + ); + let mutation_ai_patch = + crate::services::mutation_trace::runtime::resolve_post_commit_mutation_ai_patch( + repository_root, + &db, + &direct_intersection, + &flow_result.post_commit_data.parsed_patch, + ); + + run_post_commit_agent_trace_flow_with( + flow_result, + vcs_type, + remote_url, + &mutation_ai_patch, + |trace_value| { + validate_agent_trace_value(trace_value) + .map_err(|error| anyhow!(error.to_string())) + .context("Failed to verify built post-commit Agent Trace payload.")?; + + Ok(()) + }, + |insert_input| { + db.insert_agent_trace(insert_input) + .context("Failed to persist built post-commit Agent Trace payload.")?; + + Ok(()) + }, + ) +} + +pub(crate) fn run_post_commit_agent_trace_flow_with( + flow_result: &PostCommitIntersectionFlowResult, + vcs_type: Option, + remote_url: &str, + mutation_ai_patch: &ParsedPatch, + validate_agent_trace: V, + persist_agent_trace: I, +) -> Result +where + V: FnOnce(&Value) -> Result<()>, + I: for<'a> FnOnce(AgentTraceInsert<'a>) -> Result<()>, +{ + let commit_timestamp = + DateTime::::from_timestamp_millis(flow_result.post_commit_data.commit_time_ms) + .ok_or_else(|| { + anyhow!( + "Invalid post-commit timestamp '{}': expected a valid Unix epoch millisecond value.", + flow_result.post_commit_data.commit_time_ms + ) + })? + .to_rfc3339(); + + let agent_trace = build_agent_trace_from_evidence( + AgentTraceEvidence { + direct_patch: &flow_result.combined_recent_patch, + mutation_ai_patch, + }, + &flow_result.post_commit_data.parsed_patch, + AgentTraceMetadataInput { + commit_timestamp: &commit_timestamp, + commit_revision: &flow_result.post_commit_data.commit_oid, + vcs_type, + tool_name: flow_result.tool_name.as_deref(), + tool_version: flow_result.tool_version.as_deref(), + }, + ) + .context("Failed to build Agent Trace payload from post-commit intersection flow result.")?; + + let agent_trace_value = serde_json::to_value(&agent_trace) + .context("Failed to serialize post-commit Agent Trace payload for validation.")?; + validate_agent_trace(&agent_trace_value) + .context("Failed to validate built post-commit Agent Trace payload.")?; + + let serialized = format!( + "{}\n", + serde_json::to_string_pretty(&agent_trace) + .context("Failed to serialize post-commit Agent Trace payload for persistence.")? + ); + + let constructed_url = agent_trace_persisted_url(&agent_trace.id); + + let insert_input = AgentTraceInsert { + commit_id: &flow_result.post_commit_data.commit_oid, + commit_time_ms: flow_result.post_commit_data.commit_time_ms, + trace_json: &serialized, + agent_trace_id: &agent_trace.id, + url: &constructed_url, + remote_url, + }; + persist_agent_trace(insert_input)?; + + Ok(agent_trace) +} + +pub(crate) const RECENT_DAYS_MILLIS: i64 = 7 * 24 * 60 * 60 * 1000; + +pub(crate) fn run_post_commit_intersection_flow( + repository_root: &Path, +) -> Result { + let db = open_agent_trace_db_for_hook_runtime( + repository_root, + "Failed to open Agent Trace DB for post-commit intersection.", + )?; + + run_post_commit_intersection_flow_with( + repository_root, + capture_post_commit_patch_from_git, + current_unix_time_ms, + |cutoff_ms, end_ms| { + db.recent_diff_trace_patches(cutoff_ms, end_ms) + .context("Failed to query recent diff trace patches.") + }, + |insert_input| { + db.insert_post_commit_patch_intersection(insert_input) + .context("Failed to persist post-commit patch intersection.")?; + + Ok(()) + }, + ) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum StagedDiffAiOverlapResult { + Overlap, + NoOverlap, + Error, +} + +pub(crate) fn staged_diff_has_ai_overlap( + repository_root: &Path, + logger: Option<&dyn Logger>, +) -> StagedDiffAiOverlapResult { + let db_open_result = open_agent_trace_db_for_hook_runtime( + repository_root, + "Failed to open Agent Trace DB for staged AI-overlap evidence check.", + ); + + let db = match db_open_result { + Ok(db) => db, + Err(error) => { + if let Some(log) = logger { + log.error( + "sce.hooks.commit_msg.ai_overlap_error", + &format!("Staged AI-overlap evidence check failed: {error}."), + &[], + None, + ); + } + return StagedDiffAiOverlapResult::Error; + } + }; + + let result = staged_diff_has_ai_overlap_with( + repository_root, + capture_staged_patch_from_git, + current_unix_time_ms, + |cutoff_ms, end_ms| db.recent_diff_trace_patches(cutoff_ms, end_ms), + ); + + if result == StagedDiffAiOverlapResult::Error { + if let Some(log) = logger { + log.error( + "sce.hooks.commit_msg.ai_overlap_error", + "Staged AI-overlap evidence check failed: error during staged-diff or trace query.", + &[], + None, + ); + } + } + + result +} + +pub(crate) fn staged_diff_has_ai_overlap_with( + repository_root: &Path, + capture_staged_patch: C, + now_ms: N, + query_recent_patches: Q, +) -> StagedDiffAiOverlapResult +where + C: FnOnce(&Path) -> Result, + N: FnOnce() -> Result, + Q: FnOnce(i64, i64) -> Result, +{ + let Ok(staged_patch) = capture_staged_patch(repository_root) else { + return StagedDiffAiOverlapResult::Error; + }; + + if !patch_has_touched_lines(&staged_patch) { + return StagedDiffAiOverlapResult::NoOverlap; + } + + let Ok(now_ms) = now_ms() else { + return StagedDiffAiOverlapResult::Error; + }; + let cutoff_ms = now_ms - RECENT_DAYS_MILLIS; + + let Ok(recent_patches) = query_recent_patches(cutoff_ms, now_ms) else { + return StagedDiffAiOverlapResult::Error; + }; + + let has_overlap = recent_patches.patches.into_iter().any(|recent_patch| { + let combined_recent_patch = combine_patches_fn(&[recent_patch.patch]); + patches_have_overlap(&combined_recent_patch, &staged_patch) + }); + + if has_overlap { + StagedDiffAiOverlapResult::Overlap + } else { + StagedDiffAiOverlapResult::NoOverlap + } +} + +pub(crate) fn capture_staged_patch_from_git(repository_root: &Path) -> Result { + let patch_text = capture_staged_diff_from_git(repository_root)?; + + if patch_text.trim().is_empty() { + return Ok(ParsedPatch { files: Vec::new() }); + } + + parse_patch_from_text(&patch_text, None).map_err(|error| { + anyhow!(staged_patch_error( + "failed to parse staged patch", + &error.to_string() + )) + }) +} + +pub(crate) fn capture_staged_diff_from_git(repository_root: &Path) -> Result { + run_git_command_capture_stdout( + repository_root, + &["diff", "--cached", "--patch", "--no-ext-diff"], + "Failed to capture staged patch from git.", + ) +} + +pub(crate) fn staged_patch_error(detail: &str, context: &str) -> String { + format!("Staged patch capture error: {detail} ({context}).") +} + +pub(crate) fn run_post_commit_intersection_flow_with( + repository_root: &Path, + capture_post_commit_patch: C, + now_ms: N, + query_recent_patches: Q, + persist_intersection: P, +) -> Result +where + C: FnOnce(&Path) -> Result, + N: FnOnce() -> Result, + Q: FnOnce(i64, i64) -> Result, + P: for<'a> FnOnce(PostCommitPatchIntersectionInsert<'a>) -> Result<()>, +{ + let post_commit_data = capture_post_commit_patch(repository_root)?; + + let now_ms = now_ms()?; + let cutoff_ms = now_ms - RECENT_DAYS_MILLIS; + + let recent_patches = query_recent_patches(cutoff_ms, now_ms)?; + + #[allow(clippy::cast_possible_wrap)] + let loaded_count = recent_patches.loaded_count() as i64; + #[allow(clippy::cast_possible_wrap)] + let skipped_count = recent_patches.skipped_count() as i64; + + let last_patch = recent_patches.patches.last(); + let tool_name = last_patch.and_then(|patch| patch.tool_name.clone()); + let tool_version = last_patch.and_then(|patch| patch.tool_version.clone()); + + let recent_patches_slice: Vec = recent_patches + .patches + .into_iter() + .map(|p| p.patch) + .collect(); + + let combined_recent_patch = combine_patches_fn(&recent_patches_slice); + + let intersection_patch = + intersect_patches_fn(&combined_recent_patch, &post_commit_data.parsed_patch); + + let serialized_intersection = serialize_to_json(&intersection_patch) + .context("Failed to serialize intersection patch.")?; + + let insert_input = PostCommitPatchIntersectionInsert { + commit_id: &post_commit_data.commit_oid, + post_commit_time_ms: post_commit_data.commit_time_ms, + recent_window_cutoff_ms: cutoff_ms, + recent_window_end_ms: now_ms, + loaded_diff_trace_count: loaded_count, + skipped_diff_trace_count: skipped_count, + intersection_patch: &serialized_intersection, + }; + + persist_intersection(insert_input)?; + + Ok(PostCommitIntersectionFlowResult { + combined_recent_patch, + post_commit_data, + tool_name, + tool_version, + }) +} + +pub(crate) fn run_post_commit_subcommand_with_trace( + repository_root: &Path, + vcs_type: Option, + remote_url: Option<&str>, + logger: Option<&dyn Logger>, +) -> Result { + run_post_commit_subcommand( + repository_root, + vcs_type, + remote_url.unwrap_or_default(), + logger, + ) +} + +pub(crate) fn run_post_rewrite_subcommand( + repository_root: &Path, + rewrite_method: &str, +) -> Result { + let runtime = resolve_runtime_state(repository_root)?; + + Ok(format!( + "post-rewrite hook executed with no-op runtime state: {:?} (rewrite_method='{}')", + post_rewrite_no_op_reason(&runtime), + rewrite_method.trim() + )) +} + +pub(crate) fn run_post_rewrite_subcommand_with_trace( + repository_root: &Path, + _: &HookSubcommand, + rewrite_method: &str, +) -> Result { + let stdin_payload = read_hook_stdin(); + stdin_payload.and_then(|_| run_post_rewrite_subcommand(repository_root, rewrite_method)) +} + +pub(crate) fn hook_runtime_invocation_name(subcommand: &HookSubcommand) -> &'static str { + match subcommand { + HookSubcommand::PreCommit => "pre-commit runtime invocation", + HookSubcommand::CommitMsg { .. } => "commit-msg runtime invocation", + HookSubcommand::PostCommit { .. } => "post-commit runtime invocation", + HookSubcommand::PostRewrite { .. } => "post-rewrite runtime invocation", + HookSubcommand::DiffTrace => "diff-trace runtime invocation", + HookSubcommand::ConversationTrace => "conversation-trace runtime invocation", + HookSubcommand::Codex => "codex runtime invocation", + HookSubcommand::ClaudeModelState => "Claude model-state runtime invocation", + HookSubcommand::MutationScope => "mutation-scope runtime invocation", + HookSubcommand::ClaudeMutationScope => "Claude mutation-scope runtime invocation", + HookSubcommand::CodexMutationScope => "Codex mutation-scope runtime invocation", + HookSubcommand::OpenCodeMutationScope => "OpenCode mutation-scope runtime invocation", + HookSubcommand::PiMutationScope => "Pi mutation-scope runtime invocation", + HookSubcommand::ExternalMutationGuard => "external-mutation-guard runtime invocation", + } +} +pub fn apply_commit_msg_coauthor_policy( + runtime: &HookRuntimeState, + ai_contribution_present: bool, + commit_message: &str, +) -> String { + if !commit_msg_policy_gate_passed(runtime) || !ai_contribution_present { + return commit_message.to_string(); + } + + let mut lines: Vec<&str> = commit_message.lines().collect(); + lines.retain(|line| *line != CANONICAL_SCE_COAUTHOR_TRAILER); + + if !lines.is_empty() && !lines.last().is_some_and(|line| line.is_empty()) { + lines.push(""); + } + lines.push(CANONICAL_SCE_COAUTHOR_TRAILER); + + let mut normalized = lines.join("\n"); + if commit_message.ends_with('\n') { + normalized.push('\n'); + } + + normalized +} +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PostCommitPatchData { + pub commit_oid: String, + pub commit_time_ms: i64, + pub parsed_patch: ParsedPatch, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PostCommitIntersectionFlowResult { + pub combined_recent_patch: ParsedPatch, + pub post_commit_data: PostCommitPatchData, + pub tool_name: Option, + pub tool_version: Option, +} + +pub fn capture_post_commit_patch_from_git(repository_root: &Path) -> Result { + let commit_oid = capture_head_oid_from_git(repository_root)?; + let commit_time_ms = capture_head_timestamp_from_git(repository_root)?; + let patch_text = capture_head_patch_from_git(repository_root)?; + let parsed_patch = parse_patch_from_text(&patch_text, None).map_err(|e| { + anyhow!(post_commit_patch_error( + "failed to parse post-commit patch", + &e.to_string() + )) + })?; + + Ok(PostCommitPatchData { + commit_oid, + commit_time_ms, + parsed_patch, + }) +} + +pub(crate) fn capture_head_oid_from_git(repository_root: &Path) -> Result { + let output = run_git_command_capture_stdout( + repository_root, + &["rev-parse", "HEAD"], + "Failed to capture HEAD commit OID from git.", + )?; + Ok(output.trim().to_string()) +} + +pub(crate) fn capture_head_timestamp_from_git(repository_root: &Path) -> Result { + let output = run_git_command_capture_stdout( + repository_root, + &["show", "--format=%ct", "--no-patch", "HEAD"], + "Failed to capture HEAD commit timestamp from git.", + )?; + let timestamp_str = output.trim(); + let timestamp_seconds: i64 = timestamp_str.parse().map_err(|_| { + anyhow!(post_commit_patch_error( + "failed to parse HEAD timestamp", + timestamp_str, + )) + })?; + let timestamp_ms = timestamp_seconds.checked_mul(1000).ok_or_else(|| { + anyhow!(post_commit_patch_error( + "failed to parse HEAD timestamp", + timestamp_str, + )) + })?; + Ok(timestamp_ms) +} + +pub(crate) fn capture_head_patch_from_git(repository_root: &Path) -> Result { + run_git_command_capture_stdout( + repository_root, + &["show", "--format=", "--patch", "--no-ext-diff", "HEAD"], + "Failed to capture HEAD patch from git.", + ) +} + +pub(crate) fn post_commit_patch_error(detail: &str, context: &str) -> String { + format!("Post-commit patch capture error: {detail} ({context}).") +} diff --git a/cli/src/services/hooks/conversation_trace.rs b/cli/src/services/hooks/conversation_trace.rs new file mode 100644 index 000000000..e784fcfd7 --- /dev/null +++ b/cli/src/services/hooks/conversation_trace.rs @@ -0,0 +1,712 @@ +use std::path::Path; + +use anyhow::{anyhow, bail, Context, Result}; +use serde_json::{to_string as serialize_to_json, Value}; + +use crate::services::agent_trace_db::{ + InsertMessageInsert, InsertPartInsert, MessageRole, PartType, +}; +use crate::services::observability::traits::Logger; +use crate::services::patch::{load_patch_from_json, parse_patch as parse_patch_from_text}; + +use super::claude_transforms::{ + transform_claude_post_tool_use, transform_claude_stop, transform_claude_user_prompt_submit, +}; +use super::runtime::{ + open_agent_trace_db_for_hook_runtime, prefixed_conversation_trace_session_id, read_hook_stdin, + PayloadValidationError, CLAUDE_TOOL_NAME, NORMALIZED_CONVERSATION_TRACE_TOOL_NAMES, +}; + +pub(crate) const CONVERSATION_TRACE_MESSAGE_UPDATED: &str = "message"; +pub(crate) const CONVERSATION_TRACE_MESSAGE_PART_UPDATED: &str = "message.part"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConversationTracePayload { + pub attempted_count: usize, + pub message_updated: ConversationTraceMessageBatch, + pub message_part_updated: ConversationTracePartBatch, + pub skipped: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConversationTraceMessageBatch { + pub inserts: Vec, + pub skipped: Vec, + diagnostic_session_id: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConversationTracePartBatch { + pub inserts: Vec, + pub skipped: Vec, + diagnostic_session_id: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SkippedConversationTracePayload { + pub index: usize, + pub reason: String, + pub session_id: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ConversationTracePersistenceSummary { + attempted: usize, + persisted_messages: usize, + persisted_parts: usize, + skipped: usize, +} + +impl ConversationTracePersistenceSummary { + fn render(&self) -> String { + format!( + "conversation-trace hook persisted mixed payload batch to AgentTraceDb: attempted={}, persisted_messages={}, persisted_parts={}, skipped={}.", + self.attempted, self.persisted_messages, self.persisted_parts, self.skipped + ) + } +} +pub(crate) fn run_conversation_trace_subcommand( + repository_root: &Path, + logger: Option<&dyn Logger>, +) -> String { + let stdin_payload = match read_hook_stdin() { + Ok(payload) => payload, + Err(error) => return log_conversation_trace_fail_open(&error, logger, None), + }; + let session_id = conversation_trace_fail_open_session_id(&stdin_payload); + + match run_conversation_trace_subcommand_from_payload( + repository_root, + &stdin_payload, + logger, + session_id.as_deref(), + ) { + Ok(output) => output, + Err(error) => log_conversation_trace_fail_open(&error, logger, session_id.as_deref()), + } +} + +pub(crate) fn run_conversation_trace_subcommand_from_payload( + repository_root: &Path, + stdin_payload: &str, + logger: Option<&dyn Logger>, + session_id: Option<&str>, +) -> Result { + let payload = parse_conversation_trace_payload(stdin_payload)?; + Ok(persist_conversation_trace_payload_to_agent_trace_db( + repository_root, + payload, + logger, + session_id, + )) +} + +pub(crate) fn log_conversation_trace_fail_open( + error: &anyhow::Error, + logger: Option<&dyn Logger>, + session_id: Option<&str>, +) -> String { + if let Some(log) = logger { + log.error( + "sce.hooks.conversation_trace.error", + &error.to_string(), + &[], + session_id, + ); + } + + String::from("conversation-trace hook intake failed open; error logged.") +} + +pub(crate) fn persist_conversation_trace_payload_to_agent_trace_db( + repository_root: &Path, + payload: ConversationTracePayload, + logger: Option<&dyn Logger>, + session_id: Option<&str>, +) -> String { + let db = match open_agent_trace_db_for_hook_runtime( + repository_root, + "Failed to open Agent Trace DB for conversation-trace persistence.", + ) { + Ok(db) => db, + Err(error) => { + if let Some(log) = logger { + log.error( + "sce.hooks.conversation_trace.agent_trace_db_open_failed", + &error.to_string(), + &[], + session_id, + ); + } + + return String::from("conversation-trace hook intake failed open; error logged."); + } + }; + + let summary = persist_conversation_trace_payload_to_agent_trace_db_with( + payload, + logger, + |inserts| db.insert_messages(inserts), + |inserts| db.insert_parts(inserts), + ); + + summary.render() +} +pub(crate) fn persist_conversation_trace_payload_to_agent_trace_db_with( + payload: ConversationTracePayload, + logger: Option<&dyn Logger>, + insert_messages: IM, + insert_parts: IP, +) -> ConversationTracePersistenceSummary +where + IM: FnOnce(Vec) -> Result, + IP: FnOnce(Vec) -> Result, +{ + log_skipped_conversation_trace_payloads(logger, "unsupported", &payload.skipped); + + let message_summary = persist_message_updated_batch_to_agent_trace_db_with( + payload.message_updated, + logger, + insert_messages, + ); + let part_summary = persist_message_part_updated_batch_to_agent_trace_db_with( + payload.message_part_updated, + logger, + insert_parts, + ); + + ConversationTracePersistenceSummary { + attempted: payload.attempted_count, + persisted_messages: message_summary.persisted, + persisted_parts: part_summary.persisted, + skipped: payload.skipped.len() + message_summary.skipped + part_summary.skipped, + } +} + +pub(crate) struct ConversationTraceEventPersistenceSummary { + persisted: usize, + skipped: usize, +} + +pub(crate) fn persist_message_updated_batch_to_agent_trace_db_with( + batch: ConversationTraceMessageBatch, + logger: Option<&dyn Logger>, + insert_messages: I, +) -> ConversationTraceEventPersistenceSummary +where + I: FnOnce(Vec) -> Result, +{ + const EVENT_TYPE: &str = "message"; + + let mut skipped = batch.skipped.len(); + + log_skipped_conversation_trace_payloads(logger, EVENT_TYPE, &batch.skipped); + + let valid_count = batch.inserts.len(); + let session_id = batch.diagnostic_session_id; + let persisted = if valid_count == 0 { + 0 + } else { + match insert_messages(batch.inserts) { + Ok(affected_rows) => usize::try_from(affected_rows) + .unwrap_or(usize::MAX) + .min(valid_count), + Err(error) => { + skipped += valid_count; + log_conversation_trace_batch_insert_failure( + logger, + EVENT_TYPE, + valid_count, + &error, + session_id.as_deref(), + ); + 0 + } + } + }; + + ConversationTraceEventPersistenceSummary { persisted, skipped } +} + +pub(crate) fn persist_message_part_updated_batch_to_agent_trace_db_with( + batch: ConversationTracePartBatch, + logger: Option<&dyn Logger>, + insert_parts: I, +) -> ConversationTraceEventPersistenceSummary +where + I: FnOnce(Vec) -> Result, +{ + const EVENT_TYPE: &str = "message.part"; + + let mut skipped = batch.skipped.len(); + + log_skipped_conversation_trace_payloads(logger, EVENT_TYPE, &batch.skipped); + + let valid_count = batch.inserts.len(); + let session_id = batch.diagnostic_session_id; + let persisted = if valid_count == 0 { + 0 + } else { + match insert_parts(batch.inserts) { + Ok(affected_rows) => usize::try_from(affected_rows) + .unwrap_or(usize::MAX) + .min(valid_count), + Err(error) => { + skipped += valid_count; + log_conversation_trace_batch_insert_failure( + logger, + EVENT_TYPE, + valid_count, + &error, + session_id.as_deref(), + ); + 0 + } + } + }; + + ConversationTraceEventPersistenceSummary { persisted, skipped } +} + +pub(crate) fn log_skipped_conversation_trace_payloads( + logger: Option<&dyn Logger>, + event_type: &str, + skipped_payloads: &[SkippedConversationTracePayload], +) { + let Some(log) = logger else { + return; + }; + + for skipped in skipped_payloads { + let index = skipped.index.to_string(); + log.warn( + "sce.hooks.conversation_trace.payload_skipped", + &skipped.reason, + &[ + ("event_type", event_type), + ("payload_index", index.as_str()), + ], + skipped.session_id.as_deref(), + ); + } +} + +pub(crate) fn log_conversation_trace_batch_insert_failure( + logger: Option<&dyn Logger>, + event_type: &str, + valid_count: usize, + error: &anyhow::Error, + session_id: Option<&str>, +) { + if let Some(log) = logger { + let count = valid_count.to_string(); + log.warn( + "sce.hooks.conversation_trace.agent_trace_db_batch_failed", + &error.to_string(), + &[("event_type", event_type), ("valid_count", count.as_str())], + session_id, + ); + } +} + +pub fn parse_conversation_trace_payload(stdin_payload: &str) -> Result { + let parsed: Value = serde_json::from_str(stdin_payload) + .context("Invalid conversation-trace payload from STDIN: expected valid JSON.")?; + let payload = parsed.as_object().ok_or_else(|| { + anyhow!(conversation_trace_validation_error( + "expected a JSON object" + )) + })?; + + if payload.contains_key("hook_event_name") { + let event_name = required_non_empty_string_field( + payload, + "hook_event_name", + conversation_trace_validation_error, + )?; + + let items = match event_name.as_str() { + "UserPromptSubmit" => transform_claude_user_prompt_submit(payload)?, + "Stop" => transform_claude_stop(payload)?, + "PostToolUse" => transform_claude_post_tool_use(payload)?, + _ => bail!(conversation_trace_validation_error(&format!( + "unsupported Claude hook event '{event_name}': supported events are 'UserPromptSubmit', 'Stop' and 'PostToolUse'" + ))), + }; + return Ok(parse_conversation_trace_payloads(&items, CLAUDE_TOOL_NAME)); + } + + let tool_name = + required_non_empty_string_field(payload, "tool_name", conversation_trace_validation_error)?; + if !NORMALIZED_CONVERSATION_TRACE_TOOL_NAMES.contains(&tool_name.as_str()) { + bail!(conversation_trace_validation_error(&format!( + "unsupported tool_name '{tool_name}': supported producers are 'opencode' and 'pi'" + ))); + } + let payloads = required_payloads_array(payload)?; + + Ok(parse_conversation_trace_payloads(payloads, &tool_name)) +} + +pub(crate) fn required_payloads_array( + payload: &serde_json::Map, +) -> Result<&Vec> { + required_field(payload, "payloads", conversation_trace_validation_error)? + .as_array() + .ok_or_else(|| { + anyhow!(conversation_trace_validation_error( + "field 'payloads' must be an array" + )) + }) +} + +pub(crate) fn parse_conversation_trace_payloads( + payloads: &[Value], + tool_name: &str, +) -> ConversationTracePayload { + let mut message_inserts = Vec::new(); + let mut message_skipped = Vec::new(); + let mut part_inserts = Vec::new(); + let mut part_skipped = Vec::new(); + let mut skipped = Vec::new(); + let mut message_diagnostic_session_id = None; + let mut part_diagnostic_session_id = None; + + for (index, item) in payloads.iter().enumerate() { + let session_id = non_empty_string(item.get("session_id")).map(str::to_owned); + let Some(item) = conversation_trace_payload_item(item, index, &mut skipped) else { + continue; + }; + + let event_type = + match required_string_field(item, "type", conversation_trace_validation_error) { + Ok(event_type) => event_type, + Err(error) => { + skipped.push(SkippedConversationTracePayload { + index, + reason: error.to_string(), + session_id: session_id.clone(), + }); + continue; + } + }; + + match event_type.as_str() { + CONVERSATION_TRACE_MESSAGE_UPDATED => match parse_message_updated_item(item) { + Ok(mut input) => { + if message_diagnostic_session_id.is_none() { + message_diagnostic_session_id.clone_from(&session_id); + } + input.session_id = + prefixed_conversation_trace_session_id(tool_name, &input.session_id); + message_inserts.push(input); + } + Err(error) => message_skipped.push(SkippedConversationTracePayload { + index, + reason: error.to_string(), + session_id: session_id.clone(), + }), + }, + CONVERSATION_TRACE_MESSAGE_PART_UPDATED => { + match parse_message_part_updated_item(item) { + Ok(mut input) => { + if part_diagnostic_session_id.is_none() { + part_diagnostic_session_id.clone_from(&session_id); + } + input.session_id = + prefixed_conversation_trace_session_id(tool_name, &input.session_id); + part_inserts.push(input); + } + Err(error) => part_skipped.push(SkippedConversationTracePayload { + index, + reason: error.to_string(), + session_id: session_id.clone(), + }), + } + } + _ => skipped.push(SkippedConversationTracePayload { + index, + reason: conversation_trace_validation_error( + "field 'type' must be one of 'message' or 'message.part'", + ), + session_id, + }), + } + } + + ConversationTracePayload { + attempted_count: payloads.len(), + message_updated: ConversationTraceMessageBatch { + inserts: message_inserts, + skipped: message_skipped, + diagnostic_session_id: message_diagnostic_session_id, + }, + message_part_updated: ConversationTracePartBatch { + inserts: part_inserts, + skipped: part_skipped, + diagnostic_session_id: part_diagnostic_session_id, + }, + skipped, + } +} + +pub(crate) fn conversation_trace_payload_item<'a>( + item: &'a Value, + index: usize, + skipped: &mut Vec, +) -> Option<&'a serde_json::Map> { + let Some(payload) = item.as_object() else { + skipped.push(SkippedConversationTracePayload { + index, + reason: conversation_trace_validation_error(&format!( + "payloads[{index}] must be an object" + )), + session_id: None, + }); + return None; + }; + + Some(payload) +} + +pub(crate) fn parse_message_updated_item( + payload: &serde_json::Map, +) -> Result { + Ok(InsertMessageInsert { + session_id: required_non_empty_string_field( + payload, + "session_id", + conversation_trace_validation_error, + )?, + message_id: required_non_empty_string_field( + payload, + "message_id", + conversation_trace_validation_error, + )?, + role: parse_message_role(payload)?, + generated_at_unix_ms: required_i64_millisecond_field( + payload, + "generated_at_unix_ms", + conversation_trace_validation_error, + )?, + }) +} + +pub(crate) fn parse_message_part_updated_item( + payload: &serde_json::Map, +) -> Result { + let part_type = parse_part_type(payload)?; + let raw_text = required_string_field(payload, "text", conversation_trace_validation_error)?; + let text = match part_type { + PartType::Patch => { + if load_patch_from_json(&raw_text).is_ok() { + raw_text + } else { + match parse_patch_from_text(&raw_text, None) { + Ok(parsed_patch) => serialize_to_json(&parsed_patch).map_err(|error| { + anyhow!(conversation_trace_validation_error(&format!( + "failed to serialize parsed patch for conversation-trace patch part: {error}" + ))) + })?, + Err(diff_error) => { + bail!(conversation_trace_validation_error(&format!( + "field 'text' for patch part is neither valid patch-JSON nor a valid patch: {diff_error}" + ))); + } + } + } + } + PartType::Text | PartType::Reasoning => raw_text, + PartType::Question => validate_question_part_text(raw_text)?, + }; + + Ok(InsertPartInsert { + session_id: required_non_empty_string_field( + payload, + "session_id", + conversation_trace_validation_error, + )?, + message_id: required_non_empty_string_field( + payload, + "message_id", + conversation_trace_validation_error, + )?, + part_type, + text, + generated_at_unix_ms: required_i64_millisecond_field( + payload, + "generated_at_unix_ms", + conversation_trace_validation_error, + )?, + }) +} + +pub(crate) fn parse_message_role(payload: &serde_json::Map) -> Result { + match required_string_field(payload, "role", conversation_trace_validation_error)?.as_str() { + "user" => Ok(MessageRole::User), + "assistant" => Ok(MessageRole::Assistant), + _ => bail!(conversation_trace_validation_error( + "field 'role' must be one of 'user' or 'assistant'" + )), + } +} + +pub(crate) fn parse_part_type(payload: &serde_json::Map) -> Result { + match required_string_field(payload, "part_type", conversation_trace_validation_error)?.as_str() + { + "text" => Ok(PartType::Text), + "reasoning" => Ok(PartType::Reasoning), + "patch" => Ok(PartType::Patch), + "question" => Ok(PartType::Question), + _ => bail!(conversation_trace_validation_error( + "field 'part_type' must be one of 'text', 'reasoning', 'patch' or 'question'" + )), + } +} + +pub(crate) fn validate_question_part_text(raw_text: String) -> Result { + let parsed: Value = serde_json::from_str(&raw_text).map_err(|_| { + anyhow!(conversation_trace_validation_error( + "field 'text' for question part must be a JSON array of objects with string 'question' and 'answer' fields" + )) + })?; + + let items = parsed.as_array().ok_or_else(|| { + anyhow!(conversation_trace_validation_error( + "field 'text' for question part must be a JSON array of objects with string 'question' and 'answer' fields" + )) + })?; + + if items.iter().all(|item| { + item.as_object().is_some_and(|object| { + object.get("question").is_some_and(Value::is_string) + && object.get("answer").is_some_and(Value::is_string) + }) + }) { + return Ok(raw_text); + } + + bail!(conversation_trace_validation_error( + "field 'text' for question part must be a JSON array of objects with string 'question' and 'answer' fields" + )) +} + +pub(crate) fn conversation_trace_validation_error(detail: &str) -> String { + format!("Invalid conversation-trace payload from STDIN: {detail}.") +} + +pub(crate) fn conversation_trace_fail_open_session_id(stdin_payload: &str) -> Option { + let payload: Value = serde_json::from_str(stdin_payload).ok()?; + let payload = payload.as_object()?; + + if payload.contains_key("hook_event_name") { + return non_empty_string(payload.get("session_id")).map(str::to_owned); + } + + let first_payload = payload.get("payloads")?.as_array()?.first()?; + non_empty_string(first_payload.get("session_id")).map(str::to_owned) +} + +pub(crate) fn diff_trace_fail_open_session_id(stdin_payload: &str) -> Option { + let payload: Value = serde_json::from_str(stdin_payload).ok()?; + let payload = payload.as_object()?; + let field_name = if payload.contains_key("hook_event_name") { + "session_id" + } else { + "sessionID" + }; + + non_empty_string(payload.get(field_name)).map(str::to_owned) +} + +pub(crate) fn non_empty_string(value: Option<&Value>) -> Option<&str> { + value?.as_str().filter(|value| !value.trim().is_empty()) +} + +pub(crate) fn required_non_empty_string_field( + payload: &serde_json::Map, + field_name: &str, + format_error: impl Fn(&str) -> String, +) -> Result { + let raw = required_field(payload, field_name, &format_error)?; + + let value = raw.as_str().ok_or_else(|| { + anyhow!(format_error(&format!( + "field '{field_name}' must be a non-empty string" + ))) + })?; + + if value.trim().is_empty() { + bail!(format_error(&format!( + "field '{field_name}' must be a non-empty string" + ))); + } + + Ok(value.to_string()) +} + +pub(crate) fn required_string_field( + payload: &serde_json::Map, + field_name: &str, + validation_error: PayloadValidationError, +) -> Result { + let raw = required_field(payload, field_name, validation_error)?; + + raw.as_str().map(ToString::to_string).ok_or_else(|| { + anyhow!(validation_error(&format!( + "field '{field_name}' must be a string" + ))) + }) +} + +#[allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss +)] +pub(crate) fn required_i64_millisecond_field( + payload: &serde_json::Map, + field_name: &str, + validation_error: PayloadValidationError, +) -> Result { + let raw = required_field(payload, field_name, validation_error)?; + + if let Some(value) = raw.as_i64() { + if value < 0 { + bail!(validation_error(&format!( + "field '{field_name}' must be a non-negative signed 64-bit Unix epoch millisecond value" + ))); + } + return Ok(value); + } + + if let Some(value) = raw.as_u64() { + return i64::try_from(value).map_err(|_| { + anyhow!(validation_error(&format!( + "field '{field_name}' must fit in a signed 64-bit Unix epoch millisecond value for Agent Trace DB storage" + ))) + }); + } + + if raw.as_f64().is_some_and(|value| value.fract() != 0.0) { + bail!(validation_error(&format!( + "field '{field_name}' must be a non-negative signed 64-bit Unix epoch millisecond value, got a fractional number" + ))); + } + + bail!(validation_error(&format!( + "field '{field_name}' must be a non-negative signed 64-bit Unix epoch millisecond value" + ))) +} + +pub(crate) fn required_field<'a>( + payload: &'a serde_json::Map, + field_name: &str, + format_error: impl Fn(&str) -> String, +) -> Result<&'a Value> { + payload.get(field_name).ok_or_else(|| { + anyhow!(format_error(&format!( + "missing required field '{field_name}'" + ))) + }) +} diff --git a/cli/src/services/hooks/diff_trace.rs b/cli/src/services/hooks/diff_trace.rs new file mode 100644 index 000000000..356fd8482 --- /dev/null +++ b/cli/src/services/hooks/diff_trace.rs @@ -0,0 +1,605 @@ +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{anyhow, bail, Context, Result}; +use serde::Serialize; +use serde_json::Value; + +use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; +use crate::services::agent_trace_db::{ + ClaudeModelStateObservation, DiffTraceInsert, ObservationKind, PAYLOAD_TYPE_PATCH, + PAYLOAD_TYPE_STRUCTURED, +}; +use crate::services::observability::traits::Logger; +use crate::services::structured_patch::{ + derive_claude_structured_patch, ClaudeStructuredPatchDerivationResult, +}; + +use super::claude_model_state; +use super::claude_transcript; +use super::conversation_trace::{ + diff_trace_fail_open_session_id, non_empty_string, required_field, + required_non_empty_string_field, +}; +use super::runtime::{ + current_unix_time_ms, open_agent_trace_db_for_hook_runtime, prefixed_diff_trace_session_id, + read_hook_stdin, CLAUDE_MODEL_ID_PREFIX, CLAUDE_TOOL_NAME, +}; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub(crate) struct DiffTracePayload { + #[serde(rename = "sessionID")] + pub(crate) session_id: String, + pub(crate) diff: String, + pub(crate) time: u64, + pub(crate) model_id: Option, + #[serde(skip)] + pub(crate) agent_id: Option, + #[serde(skip)] + pub(crate) transcript_path: Option, + pub(crate) tool_name: String, + pub(crate) tool_version: Option, + pub(crate) payload_type: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum DiffTraceParseResult { + Persist(DiffTracePayload), + NoOp(String), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum StdinPayloadKind { + DiffTrace, +} + +impl StdinPayloadKind { + fn label(self) -> &'static str { + match self { + Self::DiffTrace => "diff-trace", + } + } + + fn validation_error(self, detail: &str) -> String { + format!("Invalid {} payload from STDIN: {detail}.", self.label()) + } +} +pub(crate) fn run_diff_trace_subcommand( + repository_root: &Path, + logger: Option<&dyn Logger>, +) -> String { + let stdin_payload = match read_hook_stdin() { + Ok(payload) => payload, + Err(error) => return log_diff_trace_fail_open(&error, logger, None), + }; + let session_id = diff_trace_fail_open_session_id(&stdin_payload); + + match run_diff_trace_subcommand_from_payload(repository_root, &stdin_payload, logger) { + Ok(output) => output, + Err(error) => log_diff_trace_fail_open(&error, logger, session_id.as_deref()), + } +} + +pub(crate) fn run_diff_trace_subcommand_from_payload( + repository_root: &Path, + stdin_payload: &str, + logger: Option<&dyn Logger>, +) -> Result { + let parse_result = parse_diff_trace_payload(stdin_payload)?; + let payload = match parse_result { + DiffTraceParseResult::Persist(payload) => payload, + DiffTraceParseResult::NoOp(message) => return Ok(message), + }; + Ok(run_diff_trace_subcommand_from_payload_with( + repository_root, + &payload, + logger, + )) +} + +pub(crate) fn log_diff_trace_fail_open( + error: &anyhow::Error, + logger: Option<&dyn Logger>, + session_id: Option<&str>, +) -> String { + if let Some(log) = logger { + log.error( + "sce.hooks.diff_trace.error", + &error.to_string(), + &[], + session_id, + ); + } + + String::from("diff-trace hook intake failed open; error logged.") +} + +pub(crate) fn run_diff_trace_subcommand_from_payload_with( + repository_root: &Path, + payload: &DiffTracePayload, + logger: Option<&dyn Logger>, +) -> String { + if let Err(error) = diff_trace_db_time_ms(payload.time) { + if let Some(log) = logger { + log.warn( + "sce.hooks.diff_trace.agent_trace_db_time_invalid", + &error.to_string(), + &[], + Some(&payload.session_id), + ); + } + } + let agent_trace_db_persisted = + match persist_diff_trace_payload_to_agent_trace_db(repository_root, payload, logger) { + Ok(persisted) => persisted, + Err(error) => { + if let Some(log) = logger { + log.warn( + "sce.hooks.diff_trace.agent_trace_db_write_failed", + &error.to_string(), + &[], + Some(&payload.session_id), + ); + } + false + } + }; + + if agent_trace_db_persisted { + String::from("diff-trace hook intake persisted payload to AgentTraceDb.") + } else { + String::from("diff-trace hook intake completed; AgentTraceDb persistence failed.") + } +} + +pub(crate) fn parse_diff_trace_payload(stdin_payload: &str) -> Result { + let payload_kind = StdinPayloadKind::DiffTrace; + let parsed: Value = serde_json::from_str(stdin_payload) + .with_context(|| payload_kind.validation_error("expected valid JSON"))?; + let payload = parsed + .as_object() + .ok_or_else(|| anyhow!(payload_kind.validation_error("expected a JSON object")))?; + + if payload.contains_key("hook_event_name") { + return parse_claude_diff_trace_payload(payload, stdin_payload, payload_kind); + } + + let session_id = required_non_empty_string_field(payload, "sessionID", |d| { + payload_kind.validation_error(d) + })?; + let diff = + required_non_empty_string_field(payload, "diff", |d| payload_kind.validation_error(d))?; + let time = required_u64_millisecond_field(payload, "time", payload_kind)?; + let model_id = optional_string_field(payload, "model_id", payload_kind)?; + let tool_name = required_non_empty_string_field(payload, "tool_name", |d| { + payload_kind.validation_error(d) + })?; + let tool_version = + required_nullable_or_non_empty_string_field(payload, "tool_version", payload_kind)?; + + Ok(DiffTraceParseResult::Persist(DiffTracePayload { + session_id, + diff, + time, + model_id, + agent_id: None, + transcript_path: None, + tool_name, + tool_version, + payload_type: PAYLOAD_TYPE_PATCH.to_string(), + })) +} + +pub(crate) fn parse_claude_diff_trace_payload( + payload: &serde_json::Map, + stdin_payload: &str, + payload_kind: StdinPayloadKind, +) -> Result { + let event_name = required_non_empty_string_field(payload, "hook_event_name", |d| { + payload_kind.validation_error(d) + })?; + + if event_name != "PostToolUse" { + return Ok(DiffTraceParseResult::NoOp(format!( + "diff-trace hook intake: Claude '{event_name}' event has no diff trace; no-op." + ))); + } + + let time = extract_claude_event_time(payload); + + match derive_claude_structured_patch(&event_name, &Value::Object(payload.clone()), time, None) { + ClaudeStructuredPatchDerivationResult::Derived(patch) => { + Ok(DiffTraceParseResult::Persist(DiffTracePayload { + session_id: patch.session_id, + diff: stdin_payload.to_string(), + time: patch.time, + model_id: resolve_claude_model_id(payload), + agent_id: extract_claude_agent_id(payload)?, + transcript_path: non_empty_string(payload.get("transcript_path")) + .map(str::to_string), + tool_name: patch.tool_name, + tool_version: patch.tool_version, + payload_type: PAYLOAD_TYPE_STRUCTURED.to_string(), + })) + } + ClaudeStructuredPatchDerivationResult::Skipped(reason) => { + Ok(DiffTraceParseResult::NoOp(format!( + "diff-trace hook intake: Claude PostToolUse event skipped ({reason:?}); no-op." + ))) + } + } +} + +pub(crate) fn extract_claude_agent_id( + payload: &serde_json::Map, +) -> Result> { + let Some(value) = payload.get("agent_id") else { + return Ok(None); + }; + if value.is_null() { + return Ok(None); + } + + let value = value.as_str().ok_or_else(|| { + anyhow!(StdinPayloadKind::DiffTrace + .validation_error("field 'agent_id' must be null or a non-empty string")) + })?; + let value = value.trim(); + if value.is_empty() { + bail!(StdinPayloadKind::DiffTrace + .validation_error("field 'agent_id' must be null or a non-empty string")); + } + + Ok(Some(value.to_string())) +} + +pub(crate) fn resolve_claude_model_id(payload: &serde_json::Map) -> Option { + resolve_claude_model_id_with(payload, claude_transcript::extract_claude_transcript_model) +} + +pub(crate) fn resolve_claude_model_id_with( + payload: &serde_json::Map, + transcript_lookup: F, +) -> Option +where + F: FnOnce(&Path, &str) -> Option, +{ + extract_direct_claude_model_id(payload).or_else(|| { + let transcript_path = non_empty_string(payload.get("transcript_path"))?; + let tool_use_id = non_empty_string(payload.get("tool_use_id"))?; + + transcript_lookup(Path::new(transcript_path), tool_use_id) + .and_then(|model| normalize_claude_model_id(&model)) + }) +} + +pub(crate) fn extract_direct_claude_model_id( + payload: &serde_json::Map, +) -> Option { + direct_claude_model_id_string(payload, &["model", "model_id", "modelId"]) + .or_else(|| { + payload + .get("model") + .and_then(Value::as_object) + .and_then(|model| direct_claude_model_id_string(model, &["id", "model", "name"])) + }) + .and_then(|model| normalize_claude_model_id(&model)) +} + +pub(crate) fn direct_claude_model_id_string( + payload: &serde_json::Map, + keys: &[&str], +) -> Option { + keys.iter().find_map(|key| { + payload + .get(*key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }) +} + +pub(crate) fn normalize_claude_model_id(model: &str) -> Option { + let normalized = model.trim(); + if normalized.is_empty() { + return None; + } + + if normalized.starts_with(CLAUDE_MODEL_ID_PREFIX) { + Some(normalized.to_string()) + } else { + Some(format!("{CLAUDE_MODEL_ID_PREFIX}{normalized}")) + } +} + +pub(crate) fn normalize_codex_model_id(model: &str) -> Option { + let normalized = model.trim(); + if normalized.is_empty() { + return None; + } + + Some(normalized.to_string()) +} + +pub(crate) fn normalize_opencode_model_id(model: &str) -> Option { + let normalized = model.trim(); + if normalized.is_empty() { + return None; + } + + Some(normalized.to_string()) +} + +pub(crate) fn normalize_pi_model_id(model: &str) -> Option { + let normalized = model.trim(); + if normalized.is_empty() { + return None; + } + + Some(normalized.to_string()) +} + +pub(crate) fn extract_claude_event_time(payload: &serde_json::Map) -> u64 { + for key in &["time", "timestamp"] { + if let Some(time_value) = payload.get(*key) { + if let Some(time) = time_value.as_u64() { + return time; + } + if let Some(time) = time_value.as_i64() { + if time >= 0 { + #[allow(clippy::cast_sign_loss)] + return time as u64; + } + } + if let Some(time) = time_value.as_f64() { + #[allow( + clippy::cast_sign_loss, + clippy::cast_possible_truncation, + clippy::cast_precision_loss + )] + if time >= 0.0 && time.fract() == 0.0 && time <= u64::MAX as f64 { + return time as u64; + } + } + } + } + #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as u64) +} + +pub(crate) fn required_nullable_or_non_empty_string_field( + payload: &serde_json::Map, + field_name: &str, + payload_kind: StdinPayloadKind, +) -> Result> { + let raw = required_field(payload, field_name, |d| payload_kind.validation_error(d))?; + + if raw.is_null() { + return Ok(None); + } + + let value = raw.as_str().ok_or_else(|| { + anyhow!(payload_kind.validation_error(&format!( + "field '{field_name}' must be null or a non-empty string" + ))) + })?; + + if value.trim().is_empty() { + bail!(payload_kind.validation_error(&format!( + "field '{field_name}' must be null or a non-empty string" + ))); + } + + Ok(Some(value.to_string())) +} + +pub(crate) fn optional_string_field( + payload: &serde_json::Map, + field_name: &str, + payload_kind: StdinPayloadKind, +) -> Result> { + let Some(raw) = payload.get(field_name) else { + return Ok(None); + }; + + if raw.is_null() { + return Ok(None); + } + + let value = raw.as_str().ok_or_else(|| { + anyhow!(payload_kind.validation_error(&format!( + "field '{field_name}' must be null, absent, or a non-empty string" + ))) + })?; + + if value.trim().is_empty() { + bail!(payload_kind.validation_error(&format!( + "field '{field_name}' must be null, absent, or a non-empty string" + ))); + } + + Ok(Some(value.to_string())) +} + +#[allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss +)] +pub(crate) fn required_u64_millisecond_field( + payload: &serde_json::Map, + field_name: &str, + payload_kind: StdinPayloadKind, +) -> Result { + let raw = required_field(payload, field_name, |d| payload_kind.validation_error(d))?; + + if let Some(value) = raw.as_u64() { + return Ok(value); + } + + if let Some(value) = raw.as_i64() { + if value < 0 { + bail!(payload_kind.validation_error(&format!( + "field '{field_name}' must be a u64 Unix epoch millisecond value, got a negative number" + ))); + } + return Ok(value as u64); + } + + if let Some(value) = raw.as_f64() { + if value.fract() != 0.0 { + bail!(payload_kind.validation_error(&format!( + "field '{field_name}' must be a u64 Unix epoch millisecond value, got a fractional number" + ))); + } + if value < 0.0 { + bail!(payload_kind.validation_error(&format!( + "field '{field_name}' must be a u64 Unix epoch millisecond value, got a negative number" + ))); + } + if value > u64::MAX as f64 { + bail!(payload_kind.validation_error(&format!( + "field '{field_name}' must be a u64 Unix epoch millisecond value" + ))); + } + return Ok(value as u64); + } + + bail!(payload_kind.validation_error(&format!( + "field '{field_name}' must be a u64 Unix epoch millisecond value" + ))) +} + +pub(crate) fn persist_diff_trace_payload_to_agent_trace_db( + repository_root: &Path, + payload: &DiffTracePayload, + logger: Option<&dyn Logger>, +) -> Result { + let db = match open_agent_trace_db_for_hook_runtime( + repository_root, + "Failed to open Agent Trace DB for diff-trace persistence.", + ) { + Ok(db) => db, + Err(error) => { + if let Some(log) = logger { + log.error( + "sce.hooks.diff_trace.agent_trace_db_open_failed", + &error.to_string(), + &[], + Some(&payload.session_id), + ); + } + + return Ok(false); + } + }; + + persist_diff_trace_payload_to_agent_trace_db_with_db(&db, payload)?; + Ok(true) +} + +pub(crate) fn persist_diff_trace_payload_to_agent_trace_db_with_db( + db: &RepositoryAgentTraceDb, + payload: &DiffTracePayload, +) -> Result<()> { + let model_id = resolve_diff_trace_model_id(db, payload)?; + db.insert_diff_trace(DiffTraceInsert { + time_ms: diff_trace_db_time_ms(payload.time)?, + session_id: &prefixed_diff_trace_session_id(&payload.tool_name, &payload.session_id), + patch: &payload.diff, + model_id: model_id.as_deref(), + tool_name: &payload.tool_name, + tool_version: payload.tool_version.as_deref(), + payload_type: &payload.payload_type, + }) + .context("Failed to persist diff-trace payload to Agent Trace DB.")?; + + Ok(()) +} + +pub(crate) fn resolve_diff_trace_model_id( + db: &RepositoryAgentTraceDb, + payload: &DiffTracePayload, +) -> Result> { + if payload.model_id.is_some() + || payload.tool_name != CLAUDE_TOOL_NAME + || payload.payload_type != PAYLOAD_TYPE_STRUCTURED + { + return Ok(payload.model_id.clone()); + } + + let session_id = prefixed_diff_trace_session_id(CLAUDE_TOOL_NAME, &payload.session_id); + let agent_id = payload.agent_id.as_deref().unwrap_or(""); + if let Some(state) = db.claude_model_state_by_session_and_agent(&session_id, agent_id)? { + return Ok(Some(state.model_id)); + } + + Ok(seed_diff_trace_model_from_bridge_chain( + db, + payload, + &session_id, + agent_id, + )) +} + +pub(crate) fn seed_diff_trace_model_from_bridge_chain( + db: &RepositoryAgentTraceDb, + payload: &DiffTracePayload, + session_id: &str, + agent_id: &str, +) -> Option { + if !agent_id.is_empty() { + return None; + } + + let transcript_path = payload.transcript_path.as_deref()?; + let model_id = claude_model_state::newest_bridge_chain_model(db, Path::new(transcript_path))?; + + let observed_at_ms = current_unix_time_ms().ok()?; + match db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: session_id.to_string(), + agent_id: String::new(), + model_id: model_id.clone(), + observation_kind: ObservationKind::SessionStart, + source: String::from("bridge_inherited"), + observed_at_ms, + }) { + Ok(_) => Some(model_id), + Err(_) => None, + } +} + +#[cfg(test)] +pub(crate) fn persist_diff_trace_payload_to_agent_trace_db_with( + payload: &DiffTracePayload, + model_id: Option<&str>, + tool_version: Option<&str>, + insert_fn: F, +) -> Result +where + F: FnOnce(DiffTraceInsert<'_>) -> Result, +{ + let time_ms = diff_trace_db_time_ms(payload.time)?; + let session_id = prefixed_diff_trace_session_id(&payload.tool_name, &payload.session_id); + + insert_fn(DiffTraceInsert { + time_ms, + session_id: &session_id, + patch: &payload.diff, + model_id, + tool_name: &payload.tool_name, + tool_version, + payload_type: &payload.payload_type, + }) +} + +pub(crate) fn diff_trace_db_time_ms(time: u64) -> Result { + i64::try_from(time).map_err(|_| { + anyhow!(StdinPayloadKind::DiffTrace.validation_error( + "field 'time' must fit in a signed 64-bit Unix epoch millisecond value for Agent Trace DB storage" + )) + }) +} diff --git a/cli/src/services/hooks/mod.rs b/cli/src/services/hooks/mod.rs index 79c6bb2ad..a42fad7b2 100644 --- a/cli/src/services/hooks/mod.rs +++ b/cli/src/services/hooks/mod.rs @@ -1,44 +1,36 @@ -use std::fs; -use std::io::{self, Read}; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::time::{SystemTime, UNIX_EPOCH}; +use anyhow::Context; -use anyhow::{anyhow, bail, Context, Result}; -use chrono::{DateTime, Utc}; -use serde::Serialize; -use serde_json::{json, to_string as serialize_to_json, Value}; - -use crate::services::agent_trace::{ - agent_trace_persisted_url, build_agent_trace_from_evidence, patch_has_touched_lines, - patches_have_overlap, validate_agent_trace_value, AgentTrace, AgentTraceEvidence, - AgentTraceMetadataInput, AgentTraceVcsType, -}; +#[cfg(test)] +use crate::services::agent_trace::{validate_agent_trace_value, AgentTrace, AgentTraceVcsType}; +#[cfg(test)] use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; +#[cfg(test)] use crate::services::agent_trace_db::{ - AgentTraceInsert, ClaudeModelStateObservation, DiffTraceInsert, InsertMessageInsert, - InsertPartInsert, MessageRole, ObservationKind, PartType, PostCommitPatchIntersectionInsert, - RecentDiffTracePatches, PAYLOAD_TYPE_PATCH, PAYLOAD_TYPE_STRUCTURED, + DiffTraceInsert, MessageRole, PartType, RecentDiffTracePatches, PAYLOAD_TYPE_PATCH, + PAYLOAD_TYPE_STRUCTURED, }; #[cfg(test)] use crate::services::agent_trace_storage::{ - resolve_agent_trace_storage_at_state_root, - resolve_agent_trace_storage_for_hook_runtime_at_state_root, -}; -use crate::services::agent_trace_storage::{ - resolve_agent_trace_storage_for_hook_runtime, AgentTraceStorageContext, + resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, }; -use crate::services::config; +#[cfg(test)] use crate::services::observability::traits::Logger; +#[cfg(test)] use crate::services::patch::{ - combine_patches as combine_patches_fn, intersect_patches as intersect_patches_fn, - load_patch_from_json, parse_patch as parse_patch_from_text, ParsedPatch, + intersect_patches as intersect_patches_fn, load_patch_from_json, + parse_patch as parse_patch_from_text, ParsedPatch, }; -use crate::services::structured_patch::{ - build_claude_post_tool_use_patch, derive_claude_structured_patch, - ClaudeStructuredPatchDerivationResult, PatchBuildResult, -}; -use crate::services::sync::auto_sync; +#[cfg(test)] +use anyhow::{anyhow, Result}; +#[cfg(test)] +use serde_json::{json, to_string as serialize_to_json, Value}; + +mod claude_transforms; +mod commit_hooks; +mod conversation_trace; +mod diff_trace; +mod runtime; + pub mod claude_bridge_session; pub mod claude_model_state; pub mod claude_mutation_scope; @@ -49,55 +41,26 @@ pub mod command; pub mod lifecycle; pub mod mutation_scope; pub mod mutation_scope_health; +pub mod mutation_scope_owner; pub mod opencode_mutation_scope; pub mod pi_mutation_scope; -pub const NAME: &str = "hooks"; -pub const CANONICAL_SCE_COAUTHOR_TRAILER: &str = "Co-authored-by: SCE "; -const CLAUDE_MODEL_ID_PREFIX: &str = "claude/"; -pub(crate) const DIFF_TRACE_OPENCODE_SESSION_ID_PREFIX: &str = "oc_"; -pub(crate) const DIFF_TRACE_CLAUDE_SESSION_ID_PREFIX: &str = "cc_"; -pub(crate) const DIFF_TRACE_PI_SESSION_ID_PREFIX: &str = "pi_"; -pub(crate) const DIFF_TRACE_CODEX_SESSION_ID_PREFIX: &str = "cx_"; -const OPENCODE_TOOL_NAME: &str = "opencode"; -const CLAUDE_TOOL_NAME: &str = "claude"; -const PI_TOOL_NAME: &str = "pi"; -const CODEX_TOOL_NAME: &str = "codex"; -const NORMALIZED_CONVERSATION_TRACE_TOOL_NAMES: &[&str] = &[OPENCODE_TOOL_NAME, PI_TOOL_NAME]; -type PayloadValidationError = fn(&str) -> String; - -pub(crate) fn prefixed_diff_trace_session_id(tool_name: &str, raw_session_id: &str) -> String { - prefixed_session_id(tool_name, raw_session_id) -} +pub(crate) use commit_hooks::*; +pub(crate) use conversation_trace::*; +pub(crate) use diff_trace::*; +pub(crate) use runtime::*; -fn prefixed_conversation_trace_session_id(tool_name: &str, raw_session_id: &str) -> String { - prefixed_session_id(tool_name, raw_session_id) -} - -fn prefixed_session_id(tool_name: &str, raw_session_id: &str) -> String { - let prefix = match tool_name { - OPENCODE_TOOL_NAME => DIFF_TRACE_OPENCODE_SESSION_ID_PREFIX, - CLAUDE_TOOL_NAME => DIFF_TRACE_CLAUDE_SESSION_ID_PREFIX, - PI_TOOL_NAME => DIFF_TRACE_PI_SESSION_ID_PREFIX, - CODEX_TOOL_NAME => DIFF_TRACE_CODEX_SESSION_ID_PREFIX, - _ => return raw_session_id.to_string(), - }; - - if raw_session_id.starts_with(prefix) { - raw_session_id.to_string() - } else { - format!("{prefix}{raw_session_id}") - } -} +#[cfg(test)] +mod tests; #[derive(Clone, Debug, Eq, PartialEq)] pub enum HookSubcommand { PreCommit, CommitMsg { - message_file: PathBuf, + message_file: std::path::PathBuf, }, PostCommit { - vcs_type: Option, + vcs_type: Option, remote_url: Option, }, PostRewrite { @@ -115,98 +78,13 @@ pub enum HookSubcommand { ExternalMutationGuard, } -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -struct DiffTracePayload { - #[serde(rename = "sessionID")] - session_id: String, - diff: String, - time: u64, - model_id: Option, - #[serde(skip)] - agent_id: Option, - #[serde(skip)] - transcript_path: Option, - tool_name: String, - tool_version: Option, - payload_type: String, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -enum DiffTraceParseResult { - Persist(DiffTracePayload), - NoOp(String), -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum StdinPayloadKind { - DiffTrace, -} - -impl StdinPayloadKind { - fn label(self) -> &'static str { - match self { - Self::DiffTrace => "diff-trace", - } - } - - fn validation_error(self, detail: &str) -> String { - format!("Invalid {} payload from STDIN: {detail}.", self.label()) - } -} - -const CONVERSATION_TRACE_MESSAGE_UPDATED: &str = "message"; -const CONVERSATION_TRACE_MESSAGE_PART_UPDATED: &str = "message.part"; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ConversationTracePayload { - pub attempted_count: usize, - pub message_updated: ConversationTraceMessageBatch, - pub message_part_updated: ConversationTracePartBatch, - pub skipped: Vec, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ConversationTraceMessageBatch { - pub inserts: Vec, - pub skipped: Vec, - diagnostic_session_id: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ConversationTracePartBatch { - pub inserts: Vec, - pub skipped: Vec, - diagnostic_session_id: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct SkippedConversationTracePayload { - pub index: usize, - pub reason: String, - pub session_id: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct ConversationTracePersistenceSummary { - attempted: usize, - persisted_messages: usize, - persisted_parts: usize, - skipped: usize, -} - -impl ConversationTracePersistenceSummary { - fn render(&self) -> String { - format!( - "conversation-trace hook persisted mixed payload batch to AgentTraceDb: attempted={}, persisted_messages={}, persisted_parts={}, skipped={}.", - self.attempted, self.persisted_messages, self.persisted_parts, self.skipped - ) - } -} +pub const NAME: &str = "hooks"; +pub const CANONICAL_SCE_COAUTHOR_TRAILER: &str = "Co-authored-by: SCE "; pub fn run_hooks_subcommand( subcommand: &HookSubcommand, - logger: Option<&dyn Logger>, -) -> Result { + logger: Option<&dyn crate::services::observability::traits::Logger>, +) -> anyhow::Result { let repository_root = std::env::current_dir().with_context(|| { format!( "Failed to determine current directory for {}.", @@ -218,10 +96,10 @@ pub fn run_hooks_subcommand( } fn run_hooks_subcommand_in_repo( - repository_root: &Path, + repository_root: &std::path::Path, subcommand: &HookSubcommand, - logger: Option<&dyn Logger>, -) -> Result { + logger: Option<&dyn crate::services::observability::traits::Logger>, +) -> anyhow::Result { match subcommand { HookSubcommand::PreCommit => run_pre_commit_subcommand_with_trace(repository_root), HookSubcommand::CommitMsg { message_file } => { @@ -267,6245 +145,3 @@ fn run_hooks_subcommand_in_repo( } } } - -fn run_conversation_trace_subcommand( - repository_root: &Path, - logger: Option<&dyn Logger>, -) -> String { - let stdin_payload = match read_hook_stdin() { - Ok(payload) => payload, - Err(error) => return log_conversation_trace_fail_open(&error, logger, None), - }; - let session_id = conversation_trace_fail_open_session_id(&stdin_payload); - - match run_conversation_trace_subcommand_from_payload( - repository_root, - &stdin_payload, - logger, - session_id.as_deref(), - ) { - Ok(output) => output, - Err(error) => log_conversation_trace_fail_open(&error, logger, session_id.as_deref()), - } -} - -fn run_conversation_trace_subcommand_from_payload( - repository_root: &Path, - stdin_payload: &str, - logger: Option<&dyn Logger>, - session_id: Option<&str>, -) -> Result { - let payload = parse_conversation_trace_payload(stdin_payload)?; - Ok(persist_conversation_trace_payload_to_agent_trace_db( - repository_root, - payload, - logger, - session_id, - )) -} - -fn log_conversation_trace_fail_open( - error: &anyhow::Error, - logger: Option<&dyn Logger>, - session_id: Option<&str>, -) -> String { - if let Some(log) = logger { - log.error( - "sce.hooks.conversation_trace.error", - &error.to_string(), - &[], - session_id, - ); - } - - String::from("conversation-trace hook intake failed open; error logged.") -} - -fn persist_conversation_trace_payload_to_agent_trace_db( - repository_root: &Path, - payload: ConversationTracePayload, - logger: Option<&dyn Logger>, - session_id: Option<&str>, -) -> String { - let db = match open_agent_trace_db_for_hook_runtime( - repository_root, - "Failed to open Agent Trace DB for conversation-trace persistence.", - ) { - Ok(db) => db, - Err(error) => { - if let Some(log) = logger { - log.error( - "sce.hooks.conversation_trace.agent_trace_db_open_failed", - &error.to_string(), - &[], - session_id, - ); - } - - return String::from("conversation-trace hook intake failed open; error logged."); - } - }; - - let summary = persist_conversation_trace_payload_to_agent_trace_db_with( - payload, - logger, - |inserts| db.insert_messages(inserts), - |inserts| db.insert_parts(inserts), - ); - - summary.render() -} -fn persist_conversation_trace_payload_to_agent_trace_db_with( - payload: ConversationTracePayload, - logger: Option<&dyn Logger>, - insert_messages: IM, - insert_parts: IP, -) -> ConversationTracePersistenceSummary -where - IM: FnOnce(Vec) -> Result, - IP: FnOnce(Vec) -> Result, -{ - log_skipped_conversation_trace_payloads(logger, "unsupported", &payload.skipped); - - let message_summary = persist_message_updated_batch_to_agent_trace_db_with( - payload.message_updated, - logger, - insert_messages, - ); - let part_summary = persist_message_part_updated_batch_to_agent_trace_db_with( - payload.message_part_updated, - logger, - insert_parts, - ); - - ConversationTracePersistenceSummary { - attempted: payload.attempted_count, - persisted_messages: message_summary.persisted, - persisted_parts: part_summary.persisted, - skipped: payload.skipped.len() + message_summary.skipped + part_summary.skipped, - } -} - -fn open_agent_trace_db_for_hook_runtime( - repository_root: &Path, - context_message: &'static str, -) -> Result { - let storage_config = config::resolve_agent_trace_storage_runtime_config(repository_root) - .context("Failed to resolve Agent Trace repository storage config.")?; - let storage_context = AgentTraceStorageContext { - repository_root, - explicit_repository_id: storage_config.repository_id.as_deref(), - repository_remote: &storage_config.repository_remote, - }; - - resolve_agent_trace_storage_for_hook_runtime(&storage_context) - .map(|storage| storage.db) - .context(context_message) -} - -#[cfg(test)] -pub(crate) fn open_agent_trace_db_for_hook_runtime_at_state_root( - repository_root: &Path, - state_root: &Path, - context_message: &'static str, -) -> Result { - let storage_config = config::resolve_agent_trace_storage_runtime_config(repository_root) - .context("Failed to resolve Agent Trace repository storage config.")?; - let storage_context = AgentTraceStorageContext { - repository_root, - explicit_repository_id: storage_config.repository_id.as_deref(), - repository_remote: &storage_config.repository_remote, - }; - - resolve_agent_trace_storage_for_hook_runtime_at_state_root(&storage_context, state_root) - .map(|storage| storage.db) - .context(context_message) -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct ConversationTraceEventPersistenceSummary { - persisted: usize, - skipped: usize, -} - -fn persist_message_updated_batch_to_agent_trace_db_with( - batch: ConversationTraceMessageBatch, - logger: Option<&dyn Logger>, - insert_messages: I, -) -> ConversationTraceEventPersistenceSummary -where - I: FnOnce(Vec) -> Result, -{ - const EVENT_TYPE: &str = "message"; - - let mut skipped = batch.skipped.len(); - - log_skipped_conversation_trace_payloads(logger, EVENT_TYPE, &batch.skipped); - - let valid_count = batch.inserts.len(); - let session_id = batch.diagnostic_session_id; - let persisted = if valid_count == 0 { - 0 - } else { - match insert_messages(batch.inserts) { - Ok(affected_rows) => usize::try_from(affected_rows) - .unwrap_or(usize::MAX) - .min(valid_count), - Err(error) => { - skipped += valid_count; - log_conversation_trace_batch_insert_failure( - logger, - EVENT_TYPE, - valid_count, - &error, - session_id.as_deref(), - ); - 0 - } - } - }; - - ConversationTraceEventPersistenceSummary { persisted, skipped } -} - -fn persist_message_part_updated_batch_to_agent_trace_db_with( - batch: ConversationTracePartBatch, - logger: Option<&dyn Logger>, - insert_parts: I, -) -> ConversationTraceEventPersistenceSummary -where - I: FnOnce(Vec) -> Result, -{ - const EVENT_TYPE: &str = "message.part"; - - let mut skipped = batch.skipped.len(); - - log_skipped_conversation_trace_payloads(logger, EVENT_TYPE, &batch.skipped); - - let valid_count = batch.inserts.len(); - let session_id = batch.diagnostic_session_id; - let persisted = if valid_count == 0 { - 0 - } else { - match insert_parts(batch.inserts) { - Ok(affected_rows) => usize::try_from(affected_rows) - .unwrap_or(usize::MAX) - .min(valid_count), - Err(error) => { - skipped += valid_count; - log_conversation_trace_batch_insert_failure( - logger, - EVENT_TYPE, - valid_count, - &error, - session_id.as_deref(), - ); - 0 - } - } - }; - - ConversationTraceEventPersistenceSummary { persisted, skipped } -} - -fn log_skipped_conversation_trace_payloads( - logger: Option<&dyn Logger>, - event_type: &str, - skipped_payloads: &[SkippedConversationTracePayload], -) { - let Some(log) = logger else { - return; - }; - - for skipped in skipped_payloads { - let index = skipped.index.to_string(); - log.warn( - "sce.hooks.conversation_trace.payload_skipped", - &skipped.reason, - &[ - ("event_type", event_type), - ("payload_index", index.as_str()), - ], - skipped.session_id.as_deref(), - ); - } -} - -fn log_conversation_trace_batch_insert_failure( - logger: Option<&dyn Logger>, - event_type: &str, - valid_count: usize, - error: &anyhow::Error, - session_id: Option<&str>, -) { - if let Some(log) = logger { - let count = valid_count.to_string(); - log.warn( - "sce.hooks.conversation_trace.agent_trace_db_batch_failed", - &error.to_string(), - &[("event_type", event_type), ("valid_count", count.as_str())], - session_id, - ); - } -} - -pub fn parse_conversation_trace_payload(stdin_payload: &str) -> Result { - let parsed: Value = serde_json::from_str(stdin_payload) - .context("Invalid conversation-trace payload from STDIN: expected valid JSON.")?; - let payload = parsed.as_object().ok_or_else(|| { - anyhow!(conversation_trace_validation_error( - "expected a JSON object" - )) - })?; - - if payload.contains_key("hook_event_name") { - let event_name = required_non_empty_string_field( - payload, - "hook_event_name", - conversation_trace_validation_error, - )?; - - let items = match event_name.as_str() { - "UserPromptSubmit" => transform_claude_user_prompt_submit(payload)?, - "Stop" => transform_claude_stop(payload)?, - "PostToolUse" => transform_claude_post_tool_use(payload)?, - _ => bail!(conversation_trace_validation_error(&format!( - "unsupported Claude hook event '{event_name}': supported events are 'UserPromptSubmit', 'Stop' and 'PostToolUse'" - ))), - }; - return Ok(parse_conversation_trace_payloads(&items, CLAUDE_TOOL_NAME)); - } - - let tool_name = - required_non_empty_string_field(payload, "tool_name", conversation_trace_validation_error)?; - if !NORMALIZED_CONVERSATION_TRACE_TOOL_NAMES.contains(&tool_name.as_str()) { - bail!(conversation_trace_validation_error(&format!( - "unsupported tool_name '{tool_name}': supported producers are 'opencode' and 'pi'" - ))); - } - let payloads = required_payloads_array(payload)?; - - Ok(parse_conversation_trace_payloads(payloads, &tool_name)) -} - -fn required_payloads_array(payload: &serde_json::Map) -> Result<&Vec> { - required_field(payload, "payloads", conversation_trace_validation_error)? - .as_array() - .ok_or_else(|| { - anyhow!(conversation_trace_validation_error( - "field 'payloads' must be an array" - )) - }) -} - -fn parse_conversation_trace_payloads( - payloads: &[Value], - tool_name: &str, -) -> ConversationTracePayload { - let mut message_inserts = Vec::new(); - let mut message_skipped = Vec::new(); - let mut part_inserts = Vec::new(); - let mut part_skipped = Vec::new(); - let mut skipped = Vec::new(); - let mut message_diagnostic_session_id = None; - let mut part_diagnostic_session_id = None; - - for (index, item) in payloads.iter().enumerate() { - let session_id = non_empty_string(item.get("session_id")).map(str::to_owned); - let Some(item) = conversation_trace_payload_item(item, index, &mut skipped) else { - continue; - }; - - let event_type = - match required_string_field(item, "type", conversation_trace_validation_error) { - Ok(event_type) => event_type, - Err(error) => { - skipped.push(SkippedConversationTracePayload { - index, - reason: error.to_string(), - session_id: session_id.clone(), - }); - continue; - } - }; - - match event_type.as_str() { - CONVERSATION_TRACE_MESSAGE_UPDATED => match parse_message_updated_item(item) { - Ok(mut input) => { - if message_diagnostic_session_id.is_none() { - message_diagnostic_session_id.clone_from(&session_id); - } - input.session_id = - prefixed_conversation_trace_session_id(tool_name, &input.session_id); - message_inserts.push(input); - } - Err(error) => message_skipped.push(SkippedConversationTracePayload { - index, - reason: error.to_string(), - session_id: session_id.clone(), - }), - }, - CONVERSATION_TRACE_MESSAGE_PART_UPDATED => { - match parse_message_part_updated_item(item) { - Ok(mut input) => { - if part_diagnostic_session_id.is_none() { - part_diagnostic_session_id.clone_from(&session_id); - } - input.session_id = - prefixed_conversation_trace_session_id(tool_name, &input.session_id); - part_inserts.push(input); - } - Err(error) => part_skipped.push(SkippedConversationTracePayload { - index, - reason: error.to_string(), - session_id: session_id.clone(), - }), - } - } - _ => skipped.push(SkippedConversationTracePayload { - index, - reason: conversation_trace_validation_error( - "field 'type' must be one of 'message' or 'message.part'", - ), - session_id, - }), - } - } - - ConversationTracePayload { - attempted_count: payloads.len(), - message_updated: ConversationTraceMessageBatch { - inserts: message_inserts, - skipped: message_skipped, - diagnostic_session_id: message_diagnostic_session_id, - }, - message_part_updated: ConversationTracePartBatch { - inserts: part_inserts, - skipped: part_skipped, - diagnostic_session_id: part_diagnostic_session_id, - }, - skipped, - } -} - -fn conversation_trace_payload_item<'a>( - item: &'a Value, - index: usize, - skipped: &mut Vec, -) -> Option<&'a serde_json::Map> { - let Some(payload) = item.as_object() else { - skipped.push(SkippedConversationTracePayload { - index, - reason: conversation_trace_validation_error(&format!( - "payloads[{index}] must be an object" - )), - session_id: None, - }); - return None; - }; - - Some(payload) -} - -fn parse_message_updated_item( - payload: &serde_json::Map, -) -> Result { - Ok(InsertMessageInsert { - session_id: required_non_empty_string_field( - payload, - "session_id", - conversation_trace_validation_error, - )?, - message_id: required_non_empty_string_field( - payload, - "message_id", - conversation_trace_validation_error, - )?, - role: parse_message_role(payload)?, - generated_at_unix_ms: required_i64_millisecond_field( - payload, - "generated_at_unix_ms", - conversation_trace_validation_error, - )?, - }) -} - -fn parse_message_part_updated_item( - payload: &serde_json::Map, -) -> Result { - let part_type = parse_part_type(payload)?; - let raw_text = required_string_field(payload, "text", conversation_trace_validation_error)?; - let text = match part_type { - PartType::Patch => { - if load_patch_from_json(&raw_text).is_ok() { - raw_text - } else { - match parse_patch_from_text(&raw_text, None) { - Ok(parsed_patch) => serialize_to_json(&parsed_patch).map_err(|error| { - anyhow!(conversation_trace_validation_error(&format!( - "failed to serialize parsed patch for conversation-trace patch part: {error}" - ))) - })?, - Err(diff_error) => { - bail!(conversation_trace_validation_error(&format!( - "field 'text' for patch part is neither valid patch-JSON nor a valid patch: {diff_error}" - ))); - } - } - } - } - PartType::Text | PartType::Reasoning => raw_text, - PartType::Question => validate_question_part_text(raw_text)?, - }; - - Ok(InsertPartInsert { - session_id: required_non_empty_string_field( - payload, - "session_id", - conversation_trace_validation_error, - )?, - message_id: required_non_empty_string_field( - payload, - "message_id", - conversation_trace_validation_error, - )?, - part_type, - text, - generated_at_unix_ms: required_i64_millisecond_field( - payload, - "generated_at_unix_ms", - conversation_trace_validation_error, - )?, - }) -} - -fn parse_message_role(payload: &serde_json::Map) -> Result { - match required_string_field(payload, "role", conversation_trace_validation_error)?.as_str() { - "user" => Ok(MessageRole::User), - "assistant" => Ok(MessageRole::Assistant), - _ => bail!(conversation_trace_validation_error( - "field 'role' must be one of 'user' or 'assistant'" - )), - } -} - -fn parse_part_type(payload: &serde_json::Map) -> Result { - match required_string_field(payload, "part_type", conversation_trace_validation_error)?.as_str() - { - "text" => Ok(PartType::Text), - "reasoning" => Ok(PartType::Reasoning), - "patch" => Ok(PartType::Patch), - "question" => Ok(PartType::Question), - _ => bail!(conversation_trace_validation_error( - "field 'part_type' must be one of 'text', 'reasoning', 'patch' or 'question'" - )), - } -} - -fn validate_question_part_text(raw_text: String) -> Result { - let parsed: Value = serde_json::from_str(&raw_text).map_err(|_| { - anyhow!(conversation_trace_validation_error( - "field 'text' for question part must be a JSON array of objects with string 'question' and 'answer' fields" - )) - })?; - - let items = parsed.as_array().ok_or_else(|| { - anyhow!(conversation_trace_validation_error( - "field 'text' for question part must be a JSON array of objects with string 'question' and 'answer' fields" - )) - })?; - - if items.iter().all(|item| { - item.as_object().is_some_and(|object| { - object.get("question").is_some_and(Value::is_string) - && object.get("answer").is_some_and(Value::is_string) - }) - }) { - return Ok(raw_text); - } - - bail!(conversation_trace_validation_error( - "field 'text' for question part must be a JSON array of objects with string 'question' and 'answer' fields" - )) -} - -fn conversation_trace_validation_error(detail: &str) -> String { - format!("Invalid conversation-trace payload from STDIN: {detail}.") -} - -fn conversation_trace_fail_open_session_id(stdin_payload: &str) -> Option { - let payload: Value = serde_json::from_str(stdin_payload).ok()?; - let payload = payload.as_object()?; - - if payload.contains_key("hook_event_name") { - return non_empty_string(payload.get("session_id")).map(str::to_owned); - } - - let first_payload = payload.get("payloads")?.as_array()?.first()?; - non_empty_string(first_payload.get("session_id")).map(str::to_owned) -} - -fn diff_trace_fail_open_session_id(stdin_payload: &str) -> Option { - let payload: Value = serde_json::from_str(stdin_payload).ok()?; - let payload = payload.as_object()?; - let field_name = if payload.contains_key("hook_event_name") { - "session_id" - } else { - "sessionID" - }; - - non_empty_string(payload.get(field_name)).map(str::to_owned) -} - -fn non_empty_string(value: Option<&Value>) -> Option<&str> { - value?.as_str().filter(|value| !value.trim().is_empty()) -} - -fn run_diff_trace_subcommand(repository_root: &Path, logger: Option<&dyn Logger>) -> String { - let stdin_payload = match read_hook_stdin() { - Ok(payload) => payload, - Err(error) => return log_diff_trace_fail_open(&error, logger, None), - }; - let session_id = diff_trace_fail_open_session_id(&stdin_payload); - - match run_diff_trace_subcommand_from_payload(repository_root, &stdin_payload, logger) { - Ok(output) => output, - Err(error) => log_diff_trace_fail_open(&error, logger, session_id.as_deref()), - } -} - -fn run_diff_trace_subcommand_from_payload( - repository_root: &Path, - stdin_payload: &str, - logger: Option<&dyn Logger>, -) -> Result { - let parse_result = parse_diff_trace_payload(stdin_payload)?; - let payload = match parse_result { - DiffTraceParseResult::Persist(payload) => payload, - DiffTraceParseResult::NoOp(message) => return Ok(message), - }; - Ok(run_diff_trace_subcommand_from_payload_with( - repository_root, - &payload, - logger, - )) -} - -fn log_diff_trace_fail_open( - error: &anyhow::Error, - logger: Option<&dyn Logger>, - session_id: Option<&str>, -) -> String { - if let Some(log) = logger { - log.error( - "sce.hooks.diff_trace.error", - &error.to_string(), - &[], - session_id, - ); - } - - String::from("diff-trace hook intake failed open; error logged.") -} - -fn run_diff_trace_subcommand_from_payload_with( - repository_root: &Path, - payload: &DiffTracePayload, - logger: Option<&dyn Logger>, -) -> String { - if let Err(error) = diff_trace_db_time_ms(payload.time) { - if let Some(log) = logger { - log.warn( - "sce.hooks.diff_trace.agent_trace_db_time_invalid", - &error.to_string(), - &[], - Some(&payload.session_id), - ); - } - } - let agent_trace_db_persisted = - match persist_diff_trace_payload_to_agent_trace_db(repository_root, payload, logger) { - Ok(persisted) => persisted, - Err(error) => { - if let Some(log) = logger { - log.warn( - "sce.hooks.diff_trace.agent_trace_db_write_failed", - &error.to_string(), - &[], - Some(&payload.session_id), - ); - } - false - } - }; - - if agent_trace_db_persisted { - String::from("diff-trace hook intake persisted payload to AgentTraceDb.") - } else { - String::from("diff-trace hook intake completed; AgentTraceDb persistence failed.") - } -} - -fn parse_diff_trace_payload(stdin_payload: &str) -> Result { - let payload_kind = StdinPayloadKind::DiffTrace; - let parsed: Value = serde_json::from_str(stdin_payload) - .with_context(|| payload_kind.validation_error("expected valid JSON"))?; - let payload = parsed - .as_object() - .ok_or_else(|| anyhow!(payload_kind.validation_error("expected a JSON object")))?; - - if payload.contains_key("hook_event_name") { - return parse_claude_diff_trace_payload(payload, stdin_payload, payload_kind); - } - - let session_id = required_non_empty_string_field(payload, "sessionID", |d| { - payload_kind.validation_error(d) - })?; - let diff = - required_non_empty_string_field(payload, "diff", |d| payload_kind.validation_error(d))?; - let time = required_u64_millisecond_field(payload, "time", payload_kind)?; - let model_id = optional_string_field(payload, "model_id", payload_kind)?; - let tool_name = required_non_empty_string_field(payload, "tool_name", |d| { - payload_kind.validation_error(d) - })?; - let tool_version = - required_nullable_or_non_empty_string_field(payload, "tool_version", payload_kind)?; - - Ok(DiffTraceParseResult::Persist(DiffTracePayload { - session_id, - diff, - time, - model_id, - agent_id: None, - transcript_path: None, - tool_name, - tool_version, - payload_type: PAYLOAD_TYPE_PATCH.to_string(), - })) -} - -fn parse_claude_diff_trace_payload( - payload: &serde_json::Map, - stdin_payload: &str, - payload_kind: StdinPayloadKind, -) -> Result { - let event_name = required_non_empty_string_field(payload, "hook_event_name", |d| { - payload_kind.validation_error(d) - })?; - - if event_name != "PostToolUse" { - return Ok(DiffTraceParseResult::NoOp(format!( - "diff-trace hook intake: Claude '{event_name}' event has no diff trace; no-op." - ))); - } - - let time = extract_claude_event_time(payload); - - match derive_claude_structured_patch(&event_name, &Value::Object(payload.clone()), time, None) { - ClaudeStructuredPatchDerivationResult::Derived(patch) => { - Ok(DiffTraceParseResult::Persist(DiffTracePayload { - session_id: patch.session_id, - diff: stdin_payload.to_string(), - time: patch.time, - model_id: resolve_claude_model_id(payload), - agent_id: extract_claude_agent_id(payload)?, - transcript_path: non_empty_string(payload.get("transcript_path")) - .map(str::to_string), - tool_name: patch.tool_name, - tool_version: patch.tool_version, - payload_type: PAYLOAD_TYPE_STRUCTURED.to_string(), - })) - } - ClaudeStructuredPatchDerivationResult::Skipped(reason) => { - Ok(DiffTraceParseResult::NoOp(format!( - "diff-trace hook intake: Claude PostToolUse event skipped ({reason:?}); no-op." - ))) - } - } -} - -fn extract_claude_agent_id(payload: &serde_json::Map) -> Result> { - let Some(value) = payload.get("agent_id") else { - return Ok(None); - }; - if value.is_null() { - return Ok(None); - } - - let value = value.as_str().ok_or_else(|| { - anyhow!(StdinPayloadKind::DiffTrace - .validation_error("field 'agent_id' must be null or a non-empty string")) - })?; - let value = value.trim(); - if value.is_empty() { - bail!(StdinPayloadKind::DiffTrace - .validation_error("field 'agent_id' must be null or a non-empty string")); - } - - Ok(Some(value.to_string())) -} - -fn resolve_claude_model_id(payload: &serde_json::Map) -> Option { - resolve_claude_model_id_with(payload, claude_transcript::extract_claude_transcript_model) -} - -fn resolve_claude_model_id_with( - payload: &serde_json::Map, - transcript_lookup: F, -) -> Option -where - F: FnOnce(&Path, &str) -> Option, -{ - extract_direct_claude_model_id(payload).or_else(|| { - let transcript_path = non_empty_string(payload.get("transcript_path"))?; - let tool_use_id = non_empty_string(payload.get("tool_use_id"))?; - - transcript_lookup(Path::new(transcript_path), tool_use_id) - .and_then(|model| normalize_claude_model_id(&model)) - }) -} - -fn extract_direct_claude_model_id(payload: &serde_json::Map) -> Option { - direct_claude_model_id_string(payload, &["model", "model_id", "modelId"]) - .or_else(|| { - payload - .get("model") - .and_then(Value::as_object) - .and_then(|model| direct_claude_model_id_string(model, &["id", "model", "name"])) - }) - .and_then(|model| normalize_claude_model_id(&model)) -} - -fn direct_claude_model_id_string( - payload: &serde_json::Map, - keys: &[&str], -) -> Option { - keys.iter().find_map(|key| { - payload - .get(*key) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - }) -} - -fn normalize_claude_model_id(model: &str) -> Option { - let normalized = model.trim(); - if normalized.is_empty() { - return None; - } - - if normalized.starts_with(CLAUDE_MODEL_ID_PREFIX) { - Some(normalized.to_string()) - } else { - Some(format!("{CLAUDE_MODEL_ID_PREFIX}{normalized}")) - } -} - -fn normalize_codex_model_id(model: &str) -> Option { - let normalized = model.trim(); - if normalized.is_empty() { - return None; - } - - Some(normalized.to_string()) -} - -fn normalize_opencode_model_id(model: &str) -> Option { - let normalized = model.trim(); - if normalized.is_empty() { - return None; - } - - Some(normalized.to_string()) -} - -fn normalize_pi_model_id(model: &str) -> Option { - let normalized = model.trim(); - if normalized.is_empty() { - return None; - } - - Some(normalized.to_string()) -} - -fn extract_claude_event_time(payload: &serde_json::Map) -> u64 { - for key in &["time", "timestamp"] { - if let Some(time_value) = payload.get(*key) { - if let Some(time) = time_value.as_u64() { - return time; - } - if let Some(time) = time_value.as_i64() { - if time >= 0 { - #[allow(clippy::cast_sign_loss)] - return time as u64; - } - } - if let Some(time) = time_value.as_f64() { - #[allow( - clippy::cast_sign_loss, - clippy::cast_possible_truncation, - clippy::cast_precision_loss - )] - if time >= 0.0 && time.fract() == 0.0 && time <= u64::MAX as f64 { - return time as u64; - } - } - } - } - #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_or(0, |d| d.as_millis() as u64) -} - -fn required_nullable_or_non_empty_string_field( - payload: &serde_json::Map, - field_name: &str, - payload_kind: StdinPayloadKind, -) -> Result> { - let raw = required_field(payload, field_name, |d| payload_kind.validation_error(d))?; - - if raw.is_null() { - return Ok(None); - } - - let value = raw.as_str().ok_or_else(|| { - anyhow!(payload_kind.validation_error(&format!( - "field '{field_name}' must be null or a non-empty string" - ))) - })?; - - if value.trim().is_empty() { - bail!(payload_kind.validation_error(&format!( - "field '{field_name}' must be null or a non-empty string" - ))); - } - - Ok(Some(value.to_string())) -} - -fn optional_string_field( - payload: &serde_json::Map, - field_name: &str, - payload_kind: StdinPayloadKind, -) -> Result> { - let Some(raw) = payload.get(field_name) else { - return Ok(None); - }; - - if raw.is_null() { - return Ok(None); - } - - let value = raw.as_str().ok_or_else(|| { - anyhow!(payload_kind.validation_error(&format!( - "field '{field_name}' must be null, absent, or a non-empty string" - ))) - })?; - - if value.trim().is_empty() { - bail!(payload_kind.validation_error(&format!( - "field '{field_name}' must be null, absent, or a non-empty string" - ))); - } - - Ok(Some(value.to_string())) -} - -fn required_non_empty_string_field( - payload: &serde_json::Map, - field_name: &str, - format_error: impl Fn(&str) -> String, -) -> Result { - let raw = required_field(payload, field_name, &format_error)?; - - let value = raw.as_str().ok_or_else(|| { - anyhow!(format_error(&format!( - "field '{field_name}' must be a non-empty string" - ))) - })?; - - if value.trim().is_empty() { - bail!(format_error(&format!( - "field '{field_name}' must be a non-empty string" - ))); - } - - Ok(value.to_string()) -} - -fn required_string_field( - payload: &serde_json::Map, - field_name: &str, - validation_error: PayloadValidationError, -) -> Result { - let raw = required_field(payload, field_name, validation_error)?; - - raw.as_str().map(ToString::to_string).ok_or_else(|| { - anyhow!(validation_error(&format!( - "field '{field_name}' must be a string" - ))) - }) -} - -#[allow( - clippy::cast_precision_loss, - clippy::cast_possible_truncation, - clippy::cast_sign_loss -)] -fn required_u64_millisecond_field( - payload: &serde_json::Map, - field_name: &str, - payload_kind: StdinPayloadKind, -) -> Result { - let raw = required_field(payload, field_name, |d| payload_kind.validation_error(d))?; - - if let Some(value) = raw.as_u64() { - return Ok(value); - } - - if let Some(value) = raw.as_i64() { - if value < 0 { - bail!(payload_kind.validation_error(&format!( - "field '{field_name}' must be a u64 Unix epoch millisecond value, got a negative number" - ))); - } - return Ok(value as u64); - } - - if let Some(value) = raw.as_f64() { - if value.fract() != 0.0 { - bail!(payload_kind.validation_error(&format!( - "field '{field_name}' must be a u64 Unix epoch millisecond value, got a fractional number" - ))); - } - if value < 0.0 { - bail!(payload_kind.validation_error(&format!( - "field '{field_name}' must be a u64 Unix epoch millisecond value, got a negative number" - ))); - } - if value > u64::MAX as f64 { - bail!(payload_kind.validation_error(&format!( - "field '{field_name}' must be a u64 Unix epoch millisecond value" - ))); - } - return Ok(value as u64); - } - - bail!(payload_kind.validation_error(&format!( - "field '{field_name}' must be a u64 Unix epoch millisecond value" - ))) -} - -fn required_i64_millisecond_field( - payload: &serde_json::Map, - field_name: &str, - validation_error: PayloadValidationError, -) -> Result { - let raw = required_field(payload, field_name, validation_error)?; - - if let Some(value) = raw.as_i64() { - if value < 0 { - bail!(validation_error(&format!( - "field '{field_name}' must be a non-negative signed 64-bit Unix epoch millisecond value" - ))); - } - return Ok(value); - } - - if let Some(value) = raw.as_u64() { - return i64::try_from(value).map_err(|_| { - anyhow!(validation_error(&format!( - "field '{field_name}' must fit in a signed 64-bit Unix epoch millisecond value for Agent Trace DB storage" - ))) - }); - } - - if raw.as_f64().is_some_and(|value| value.fract() != 0.0) { - bail!(validation_error(&format!( - "field '{field_name}' must be a non-negative signed 64-bit Unix epoch millisecond value, got a fractional number" - ))); - } - - bail!(validation_error(&format!( - "field '{field_name}' must be a non-negative signed 64-bit Unix epoch millisecond value" - ))) -} - -fn required_field<'a>( - payload: &'a serde_json::Map, - field_name: &str, - format_error: impl Fn(&str) -> String, -) -> Result<&'a Value> { - payload.get(field_name).ok_or_else(|| { - anyhow!(format_error(&format!( - "missing required field '{field_name}'" - ))) - }) -} - -fn persist_diff_trace_payload_to_agent_trace_db( - repository_root: &Path, - payload: &DiffTracePayload, - logger: Option<&dyn Logger>, -) -> Result { - let db = match open_agent_trace_db_for_hook_runtime( - repository_root, - "Failed to open Agent Trace DB for diff-trace persistence.", - ) { - Ok(db) => db, - Err(error) => { - if let Some(log) = logger { - log.error( - "sce.hooks.diff_trace.agent_trace_db_open_failed", - &error.to_string(), - &[], - Some(&payload.session_id), - ); - } - - return Ok(false); - } - }; - - persist_diff_trace_payload_to_agent_trace_db_with_db(&db, payload)?; - Ok(true) -} - -fn persist_diff_trace_payload_to_agent_trace_db_with_db( - db: &RepositoryAgentTraceDb, - payload: &DiffTracePayload, -) -> Result<()> { - let model_id = resolve_diff_trace_model_id(db, payload)?; - db.insert_diff_trace(DiffTraceInsert { - time_ms: diff_trace_db_time_ms(payload.time)?, - session_id: &prefixed_diff_trace_session_id(&payload.tool_name, &payload.session_id), - patch: &payload.diff, - model_id: model_id.as_deref(), - tool_name: &payload.tool_name, - tool_version: payload.tool_version.as_deref(), - payload_type: &payload.payload_type, - }) - .context("Failed to persist diff-trace payload to Agent Trace DB.")?; - - Ok(()) -} - -fn resolve_diff_trace_model_id( - db: &RepositoryAgentTraceDb, - payload: &DiffTracePayload, -) -> Result> { - if payload.model_id.is_some() - || payload.tool_name != CLAUDE_TOOL_NAME - || payload.payload_type != PAYLOAD_TYPE_STRUCTURED - { - return Ok(payload.model_id.clone()); - } - - let session_id = prefixed_diff_trace_session_id(CLAUDE_TOOL_NAME, &payload.session_id); - let agent_id = payload.agent_id.as_deref().unwrap_or(""); - if let Some(state) = db.claude_model_state_by_session_and_agent(&session_id, agent_id)? { - return Ok(Some(state.model_id)); - } - - Ok(seed_diff_trace_model_from_bridge_chain( - db, - payload, - &session_id, - agent_id, - )) -} - -fn seed_diff_trace_model_from_bridge_chain( - db: &RepositoryAgentTraceDb, - payload: &DiffTracePayload, - session_id: &str, - agent_id: &str, -) -> Option { - if !agent_id.is_empty() { - return None; - } - - let transcript_path = payload.transcript_path.as_deref()?; - let model_id = claude_model_state::newest_bridge_chain_model(db, Path::new(transcript_path))?; - - let observed_at_ms = current_unix_time_ms().ok()?; - match db.upsert_claude_model_state(ClaudeModelStateObservation { - session_id: session_id.to_string(), - agent_id: String::new(), - model_id: model_id.clone(), - observation_kind: ObservationKind::SessionStart, - source: String::from("bridge_inherited"), - observed_at_ms, - }) { - Ok(_) => Some(model_id), - Err(_) => None, - } -} - -#[cfg(test)] -fn persist_diff_trace_payload_to_agent_trace_db_with( - payload: &DiffTracePayload, - model_id: Option<&str>, - tool_version: Option<&str>, - insert_fn: F, -) -> Result -where - F: FnOnce(DiffTraceInsert<'_>) -> Result, -{ - let time_ms = diff_trace_db_time_ms(payload.time)?; - let session_id = prefixed_diff_trace_session_id(&payload.tool_name, &payload.session_id); - - insert_fn(DiffTraceInsert { - time_ms, - session_id: &session_id, - patch: &payload.diff, - model_id, - tool_name: &payload.tool_name, - tool_version, - payload_type: &payload.payload_type, - }) -} - -fn diff_trace_db_time_ms(time: u64) -> Result { - i64::try_from(time).map_err(|_| { - anyhow!(StdinPayloadKind::DiffTrace.validation_error( - "field 'time' must fit in a signed 64-bit Unix epoch millisecond value for Agent Trace DB storage" - )) - }) -} - -fn run_pre_commit_subcommand_with_trace(repository_root: &Path) -> Result { - run_pre_commit_subcommand(repository_root) -} - -fn run_pre_commit_subcommand(repository_root: &Path) -> Result { - let runtime = resolve_runtime_state(repository_root)?; - - Ok(format!( - "pre-commit hook executed with no-op runtime state: {:?}", - pre_commit_no_op_reason(&runtime) - )) -} - -fn run_commit_msg_subcommand_in_repo( - repository_root: &Path, - message_file: &Path, - logger: Option<&dyn Logger>, -) -> Result { - let metadata = fs::metadata(message_file).with_context(|| { - format!( - "Invalid commit message file '{}': file does not exist or is not readable.", - message_file.display() - ) - })?; - - if !metadata.is_file() { - bail!( - "Invalid commit message file '{}': expected a regular file path.", - message_file.display() - ); - } - - let runtime = resolve_runtime_state(repository_root)?; - let original = fs::read_to_string(message_file).with_context(|| { - format!( - "Invalid commit message file '{}': failed to read UTF-8 content.", - message_file.display() - ) - })?; - - let gate_passed = commit_msg_policy_gate_passed(&runtime); - let ai_contribution_present = if gate_passed { - match staged_diff_has_ai_overlap(repository_root, logger) { - StagedDiffAiOverlapResult::Overlap => true, - StagedDiffAiOverlapResult::NoOverlap | StagedDiffAiOverlapResult::Error => false, - } - } else { - false - }; - let transformed = - apply_commit_msg_coauthor_policy(&runtime, ai_contribution_present, &original); - let trailer_applied = gate_passed && transformed != original; - - if trailer_applied { - fs::write(message_file, transformed.as_bytes()).with_context(|| { - format!( - "Failed to update commit message file '{}' with canonical co-author trailer.", - message_file.display() - ) - })?; - } - - Ok(format!( - "commit-msg hook processed message file '{}' (policy_gate_passed={}, trailer_applied={}).", - message_file.display(), - gate_passed, - trailer_applied - )) -} - -fn run_commit_msg_subcommand_with_trace( - repository_root: &Path, - _: &HookSubcommand, - message_file: &Path, - logger: Option<&dyn Logger>, -) -> Result { - run_commit_msg_subcommand_in_repo(repository_root, message_file, logger) -} - -fn run_post_commit_subcommand( - repository_root: &Path, - vcs_type: Option, - remote_url: &str, - logger: Option<&dyn Logger>, -) -> Result { - run_post_commit_subcommand_with( - repository_root, - vcs_type, - remote_url, - run_post_commit_intersection_flow, - run_post_commit_agent_trace_flow, - |root| { - config::resolve_hook_runtime_config(root).map(|runtime| runtime.agent_trace_auto_sync) - }, - |root| { - auto_sync::launch(root); - Ok(()) - }, - run_post_commit_passive_checkpoint, - logger, - ) -} - -#[allow(clippy::too_many_arguments)] -fn run_post_commit_subcommand_with( - repository_root: &Path, - vcs_type: Option, - remote_url: &str, - run_intersection_flow: F, - run_agent_trace_flow: B, - resolve_auto_sync: C, - launch_auto_sync: L, - run_passive_checkpoint: K, - logger: Option<&dyn Logger>, -) -> Result -where - F: FnOnce(&Path) -> Result, - B: FnOnce( - &Path, - &PostCommitIntersectionFlowResult, - Option, - &str, - ) -> Result, - C: FnOnce(&Path) -> Result, - L: FnOnce(&Path) -> Result<()>, - K: FnOnce(&Path) -> Result<()>, -{ - let result = run_intersection_flow(repository_root)?; - let _agent_trace = run_agent_trace_flow(repository_root, &result, vcs_type, remote_url)?; - - if let Err(error) = run_passive_checkpoint(repository_root) { - if let Some(log) = logger { - log.warn( - "sce.agent_trace_db.passive_checkpoint_failed", - &error.to_string(), - &[], - None, - ); - } - } - - if resolve_auto_sync(repository_root)? { - let _ = launch_auto_sync(repository_root); - } - - Ok(format!( - "post-commit hook processed intersection: commit={}, intersection_files={}", - result.post_commit_data.commit_oid, - result.combined_recent_patch.files.len() - )) -} - -fn run_post_commit_passive_checkpoint(repository_root: &Path) -> Result<()> { - let db = open_agent_trace_db_for_hook_runtime( - repository_root, - "Failed to open Agent Trace DB for post-commit checkpoint.", - )?; - - db.passive_checkpoint() -} - -fn run_post_commit_agent_trace_flow( - repository_root: &Path, - flow_result: &PostCommitIntersectionFlowResult, - vcs_type: Option, - remote_url: &str, -) -> Result { - let db = open_agent_trace_db_for_hook_runtime( - repository_root, - "Failed to open Agent Trace DB for post-commit trace.", - )?; - - let direct_intersection = intersect_patches_fn( - &flow_result.combined_recent_patch, - &flow_result.post_commit_data.parsed_patch, - ); - let mutation_ai_patch = - crate::services::mutation_trace::runtime::resolve_post_commit_mutation_ai_patch( - repository_root, - &db, - &direct_intersection, - &flow_result.post_commit_data.parsed_patch, - ); - - run_post_commit_agent_trace_flow_with( - flow_result, - vcs_type, - remote_url, - &mutation_ai_patch, - |trace_value| { - validate_agent_trace_value(trace_value) - .map_err(|error| anyhow!(error.to_string())) - .context("Failed to verify built post-commit Agent Trace payload.")?; - - Ok(()) - }, - |insert_input| { - db.insert_agent_trace(insert_input) - .context("Failed to persist built post-commit Agent Trace payload.")?; - - Ok(()) - }, - ) -} - -fn run_post_commit_agent_trace_flow_with( - flow_result: &PostCommitIntersectionFlowResult, - vcs_type: Option, - remote_url: &str, - mutation_ai_patch: &ParsedPatch, - validate_agent_trace: V, - persist_agent_trace: I, -) -> Result -where - V: FnOnce(&Value) -> Result<()>, - I: for<'a> FnOnce(AgentTraceInsert<'a>) -> Result<()>, -{ - let commit_timestamp = - DateTime::::from_timestamp_millis(flow_result.post_commit_data.commit_time_ms) - .ok_or_else(|| { - anyhow!( - "Invalid post-commit timestamp '{}': expected a valid Unix epoch millisecond value.", - flow_result.post_commit_data.commit_time_ms - ) - })? - .to_rfc3339(); - - let agent_trace = build_agent_trace_from_evidence( - AgentTraceEvidence { - direct_patch: &flow_result.combined_recent_patch, - mutation_ai_patch, - }, - &flow_result.post_commit_data.parsed_patch, - AgentTraceMetadataInput { - commit_timestamp: &commit_timestamp, - commit_revision: &flow_result.post_commit_data.commit_oid, - vcs_type, - tool_name: flow_result.tool_name.as_deref(), - tool_version: flow_result.tool_version.as_deref(), - }, - ) - .context("Failed to build Agent Trace payload from post-commit intersection flow result.")?; - - let agent_trace_value = serde_json::to_value(&agent_trace) - .context("Failed to serialize post-commit Agent Trace payload for validation.")?; - validate_agent_trace(&agent_trace_value) - .context("Failed to validate built post-commit Agent Trace payload.")?; - - let serialized = format!( - "{}\n", - serde_json::to_string_pretty(&agent_trace) - .context("Failed to serialize post-commit Agent Trace payload for persistence.")? - ); - - let constructed_url = agent_trace_persisted_url(&agent_trace.id); - - let insert_input = AgentTraceInsert { - commit_id: &flow_result.post_commit_data.commit_oid, - commit_time_ms: flow_result.post_commit_data.commit_time_ms, - trace_json: &serialized, - agent_trace_id: &agent_trace.id, - url: &constructed_url, - remote_url, - }; - persist_agent_trace(insert_input)?; - - Ok(agent_trace) -} - -const RECENT_DAYS_MILLIS: i64 = 7 * 24 * 60 * 60 * 1000; - -fn run_post_commit_intersection_flow( - repository_root: &Path, -) -> Result { - let db = open_agent_trace_db_for_hook_runtime( - repository_root, - "Failed to open Agent Trace DB for post-commit intersection.", - )?; - - run_post_commit_intersection_flow_with( - repository_root, - capture_post_commit_patch_from_git, - current_unix_time_ms, - |cutoff_ms, end_ms| { - db.recent_diff_trace_patches(cutoff_ms, end_ms) - .context("Failed to query recent diff trace patches.") - }, - |insert_input| { - db.insert_post_commit_patch_intersection(insert_input) - .context("Failed to persist post-commit patch intersection.")?; - - Ok(()) - }, - ) -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum StagedDiffAiOverlapResult { - Overlap, - NoOverlap, - Error, -} - -fn staged_diff_has_ai_overlap( - repository_root: &Path, - logger: Option<&dyn Logger>, -) -> StagedDiffAiOverlapResult { - let db_open_result = open_agent_trace_db_for_hook_runtime( - repository_root, - "Failed to open Agent Trace DB for staged AI-overlap evidence check.", - ); - - let db = match db_open_result { - Ok(db) => db, - Err(error) => { - if let Some(log) = logger { - log.error( - "sce.hooks.commit_msg.ai_overlap_error", - &format!("Staged AI-overlap evidence check failed: {error}."), - &[], - None, - ); - } - return StagedDiffAiOverlapResult::Error; - } - }; - - let result = staged_diff_has_ai_overlap_with( - repository_root, - capture_staged_patch_from_git, - current_unix_time_ms, - |cutoff_ms, end_ms| db.recent_diff_trace_patches(cutoff_ms, end_ms), - ); - - if result == StagedDiffAiOverlapResult::Error { - if let Some(log) = logger { - log.error( - "sce.hooks.commit_msg.ai_overlap_error", - "Staged AI-overlap evidence check failed: error during staged-diff or trace query.", - &[], - None, - ); - } - } - - result -} - -fn staged_diff_has_ai_overlap_with( - repository_root: &Path, - capture_staged_patch: C, - now_ms: N, - query_recent_patches: Q, -) -> StagedDiffAiOverlapResult -where - C: FnOnce(&Path) -> Result, - N: FnOnce() -> Result, - Q: FnOnce(i64, i64) -> Result, -{ - let Ok(staged_patch) = capture_staged_patch(repository_root) else { - return StagedDiffAiOverlapResult::Error; - }; - - if !patch_has_touched_lines(&staged_patch) { - return StagedDiffAiOverlapResult::NoOverlap; - } - - let Ok(now_ms) = now_ms() else { - return StagedDiffAiOverlapResult::Error; - }; - let cutoff_ms = now_ms - RECENT_DAYS_MILLIS; - - let Ok(recent_patches) = query_recent_patches(cutoff_ms, now_ms) else { - return StagedDiffAiOverlapResult::Error; - }; - - let has_overlap = recent_patches.patches.into_iter().any(|recent_patch| { - let combined_recent_patch = combine_patches_fn(&[recent_patch.patch]); - patches_have_overlap(&combined_recent_patch, &staged_patch) - }); - - if has_overlap { - StagedDiffAiOverlapResult::Overlap - } else { - StagedDiffAiOverlapResult::NoOverlap - } -} - -fn capture_staged_patch_from_git(repository_root: &Path) -> Result { - let patch_text = capture_staged_diff_from_git(repository_root)?; - - if patch_text.trim().is_empty() { - return Ok(ParsedPatch { files: Vec::new() }); - } - - parse_patch_from_text(&patch_text, None).map_err(|error| { - anyhow!(staged_patch_error( - "failed to parse staged patch", - &error.to_string() - )) - }) -} - -fn capture_staged_diff_from_git(repository_root: &Path) -> Result { - run_git_command_capture_stdout( - repository_root, - &["diff", "--cached", "--patch", "--no-ext-diff"], - "Failed to capture staged patch from git.", - ) -} - -fn staged_patch_error(detail: &str, context: &str) -> String { - format!("Staged patch capture error: {detail} ({context}).") -} - -fn run_post_commit_intersection_flow_with( - repository_root: &Path, - capture_post_commit_patch: C, - now_ms: N, - query_recent_patches: Q, - persist_intersection: P, -) -> Result -where - C: FnOnce(&Path) -> Result, - N: FnOnce() -> Result, - Q: FnOnce(i64, i64) -> Result, - P: for<'a> FnOnce(PostCommitPatchIntersectionInsert<'a>) -> Result<()>, -{ - let post_commit_data = capture_post_commit_patch(repository_root)?; - - let now_ms = now_ms()?; - let cutoff_ms = now_ms - RECENT_DAYS_MILLIS; - - let recent_patches = query_recent_patches(cutoff_ms, now_ms)?; - - #[allow(clippy::cast_possible_wrap)] - let loaded_count = recent_patches.loaded_count() as i64; - #[allow(clippy::cast_possible_wrap)] - let skipped_count = recent_patches.skipped_count() as i64; - - let last_patch = recent_patches.patches.last(); - let tool_name = last_patch.and_then(|patch| patch.tool_name.clone()); - let tool_version = last_patch.and_then(|patch| patch.tool_version.clone()); - - let recent_patches_slice: Vec = recent_patches - .patches - .into_iter() - .map(|p| p.patch) - .collect(); - - let combined_recent_patch = combine_patches_fn(&recent_patches_slice); - - let intersection_patch = - intersect_patches_fn(&combined_recent_patch, &post_commit_data.parsed_patch); - - let serialized_intersection = serialize_to_json(&intersection_patch) - .context("Failed to serialize intersection patch.")?; - - let insert_input = PostCommitPatchIntersectionInsert { - commit_id: &post_commit_data.commit_oid, - post_commit_time_ms: post_commit_data.commit_time_ms, - recent_window_cutoff_ms: cutoff_ms, - recent_window_end_ms: now_ms, - loaded_diff_trace_count: loaded_count, - skipped_diff_trace_count: skipped_count, - intersection_patch: &serialized_intersection, - }; - - persist_intersection(insert_input)?; - - Ok(PostCommitIntersectionFlowResult { - combined_recent_patch, - post_commit_data, - tool_name, - tool_version, - }) -} - -fn current_unix_time_ms() -> Result { - i64::try_from(SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis()) - .context("Current time exceeds i64 range for post-commit intersection.") -} - -fn run_post_commit_subcommand_with_trace( - repository_root: &Path, - vcs_type: Option, - remote_url: Option<&str>, - logger: Option<&dyn Logger>, -) -> Result { - run_post_commit_subcommand( - repository_root, - vcs_type, - remote_url.unwrap_or_default(), - logger, - ) -} - -fn run_post_rewrite_subcommand(repository_root: &Path, rewrite_method: &str) -> Result { - let runtime = resolve_runtime_state(repository_root)?; - - Ok(format!( - "post-rewrite hook executed with no-op runtime state: {:?} (rewrite_method='{}')", - post_rewrite_no_op_reason(&runtime), - rewrite_method.trim() - )) -} - -fn run_post_rewrite_subcommand_with_trace( - repository_root: &Path, - _: &HookSubcommand, - rewrite_method: &str, -) -> Result { - let stdin_payload = read_hook_stdin(); - stdin_payload.and_then(|_| run_post_rewrite_subcommand(repository_root, rewrite_method)) -} - -fn hook_runtime_invocation_name(subcommand: &HookSubcommand) -> &'static str { - match subcommand { - HookSubcommand::PreCommit => "pre-commit runtime invocation", - HookSubcommand::CommitMsg { .. } => "commit-msg runtime invocation", - HookSubcommand::PostCommit { .. } => "post-commit runtime invocation", - HookSubcommand::PostRewrite { .. } => "post-rewrite runtime invocation", - HookSubcommand::DiffTrace => "diff-trace runtime invocation", - HookSubcommand::ConversationTrace => "conversation-trace runtime invocation", - HookSubcommand::Codex => "codex runtime invocation", - HookSubcommand::ClaudeModelState => "Claude model-state runtime invocation", - HookSubcommand::MutationScope => "mutation-scope runtime invocation", - HookSubcommand::ClaudeMutationScope => "Claude mutation-scope runtime invocation", - HookSubcommand::CodexMutationScope => "Codex mutation-scope runtime invocation", - HookSubcommand::OpenCodeMutationScope => "OpenCode mutation-scope runtime invocation", - HookSubcommand::PiMutationScope => "Pi mutation-scope runtime invocation", - HookSubcommand::ExternalMutationGuard => "external-mutation-guard runtime invocation", - } -} - -fn read_hook_stdin() -> Result { - let mut stdin_payload = String::new(); - io::stdin() - .read_to_string(&mut stdin_payload) - .context("Failed to read hook input from STDIN.")?; - Ok(stdin_payload) -} - -fn run_git_command_capture_stdout( - repository_root: &Path, - args: &[&str], - context_message: &str, -) -> Result { - let output = Command::new("git") - .args(args) - .current_dir(repository_root) - .output() - .with_context(|| { - format!( - "{} (directory: '{}')", - context_message, - repository_root.display() - ) - })?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let diagnostic = if stderr.is_empty() { - String::from("git command exited with a non-zero status") - } else { - stderr - }; - bail!("{context_message} {diagnostic}"); - } - - String::from_utf8(output.stdout).context("git command output contained invalid UTF-8") -} - -fn resolve_runtime_state(repository_root: &Path) -> Result { - Ok(HookRuntimeState { - sce_disabled: env_flag_is_truthy("SCE_DISABLED"), - attribution_hooks_enabled: config::resolve_hook_runtime_config(repository_root)? - .attribution_hooks_enabled, - }) -} - -fn env_flag_is_truthy(name: &str) -> bool { - std::env::var(name) - .ok() - .is_some_and(|value| env_value_is_truthy(&value)) -} - -fn env_value_is_truthy(value: &str) -> bool { - matches!( - value.trim().to_ascii_lowercase().as_str(), - "1" | "true" | "yes" | "on" - ) -} - -fn commit_msg_policy_gate_passed(runtime: &HookRuntimeState) -> bool { - !runtime.sce_disabled && runtime.attribution_hooks_enabled -} - -fn pre_commit_no_op_reason(runtime: &HookRuntimeState) -> HookNoOpReason { - if runtime.sce_disabled { - HookNoOpReason::Disabled - } else { - HookNoOpReason::AttributionOnlyCommitMsgMode - } -} - -fn post_rewrite_no_op_reason(runtime: &HookRuntimeState) -> HookNoOpReason { - if runtime.sce_disabled { - HookNoOpReason::Disabled - } else { - HookNoOpReason::AttributionOnlyCommitMsgMode - } -} - -pub fn apply_commit_msg_coauthor_policy( - runtime: &HookRuntimeState, - ai_contribution_present: bool, - commit_message: &str, -) -> String { - if !commit_msg_policy_gate_passed(runtime) || !ai_contribution_present { - return commit_message.to_string(); - } - - let mut lines: Vec<&str> = commit_message.lines().collect(); - lines.retain(|line| *line != CANONICAL_SCE_COAUTHOR_TRAILER); - - if !lines.is_empty() && !lines.last().is_some_and(|line| line.is_empty()) { - lines.push(""); - } - lines.push(CANONICAL_SCE_COAUTHOR_TRAILER); - - let mut normalized = lines.join("\n"); - if commit_message.ends_with('\n') { - normalized.push('\n'); - } - - normalized -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct HookRuntimeState { - pub sce_disabled: bool, - pub attribution_hooks_enabled: bool, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum HookNoOpReason { - Disabled, - AttributionOnlyCommitMsgMode, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct PostCommitPatchData { - pub commit_oid: String, - pub commit_time_ms: i64, - pub parsed_patch: ParsedPatch, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct PostCommitIntersectionFlowResult { - pub combined_recent_patch: ParsedPatch, - pub post_commit_data: PostCommitPatchData, - pub tool_name: Option, - pub tool_version: Option, -} - -pub fn capture_post_commit_patch_from_git(repository_root: &Path) -> Result { - let commit_oid = capture_head_oid_from_git(repository_root)?; - let commit_time_ms = capture_head_timestamp_from_git(repository_root)?; - let patch_text = capture_head_patch_from_git(repository_root)?; - let parsed_patch = parse_patch_from_text(&patch_text, None).map_err(|e| { - anyhow!(post_commit_patch_error( - "failed to parse post-commit patch", - &e.to_string() - )) - })?; - - Ok(PostCommitPatchData { - commit_oid, - commit_time_ms, - parsed_patch, - }) -} - -fn capture_head_oid_from_git(repository_root: &Path) -> Result { - let output = run_git_command_capture_stdout( - repository_root, - &["rev-parse", "HEAD"], - "Failed to capture HEAD commit OID from git.", - )?; - Ok(output.trim().to_string()) -} - -fn capture_head_timestamp_from_git(repository_root: &Path) -> Result { - let output = run_git_command_capture_stdout( - repository_root, - &["show", "--format=%ct", "--no-patch", "HEAD"], - "Failed to capture HEAD commit timestamp from git.", - )?; - let timestamp_str = output.trim(); - let timestamp_seconds: i64 = timestamp_str.parse().map_err(|_| { - anyhow!(post_commit_patch_error( - "failed to parse HEAD timestamp", - timestamp_str, - )) - })?; - let timestamp_ms = timestamp_seconds.checked_mul(1000).ok_or_else(|| { - anyhow!(post_commit_patch_error( - "failed to parse HEAD timestamp", - timestamp_str, - )) - })?; - Ok(timestamp_ms) -} - -fn capture_head_patch_from_git(repository_root: &Path) -> Result { - run_git_command_capture_stdout( - repository_root, - &["show", "--format=", "--patch", "--no-ext-diff", "HEAD"], - "Failed to capture HEAD patch from git.", - ) -} - -fn post_commit_patch_error(detail: &str, context: &str) -> String { - format!("Post-commit patch capture error: {detail} ({context}).") -} - -fn transform_claude_user_prompt_submit( - payload: &serde_json::Map, -) -> Result> { - transform_claude_user_prompt_submit_with( - payload, - || { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default(); - let ts = uuid::Timestamp::from_unix(uuid::NoContext, now.as_secs(), now.subsec_nanos()); - uuid::Uuid::new_v7(ts) - }, - || current_unix_time_ms().unwrap_or(0), - ) -} - -fn transform_claude_user_prompt_submit_with( - payload: &serde_json::Map, - generate_message_id: G, - generate_timestamp_ms: T, -) -> Result> -where - G: FnOnce() -> uuid::Uuid, - T: FnOnce() -> i64, -{ - let event_name = required_non_empty_string_field( - payload, - "hook_event_name", - conversation_trace_validation_error, - )?; - - if event_name != "UserPromptSubmit" { - let raw_content = serde_json::to_string(payload).unwrap_or_default(); - bail!(conversation_trace_validation_error(&format!( - "unsupported Claude hook event '{event_name}': only 'UserPromptSubmit' is supported. Raw event: {raw_content}" - ))); - } - - let session_id = required_non_empty_string_field( - payload, - "session_id", - conversation_trace_validation_error, - )?; - let prompt = - required_non_empty_string_field(payload, "prompt", conversation_trace_validation_error)?; - - let message_id = generate_message_id().to_string(); - let generated_at_unix_ms = generate_timestamp_ms(); - - Ok(vec![ - json!({ - "type": CONVERSATION_TRACE_MESSAGE_UPDATED, - "session_id": session_id, - "message_id": message_id, - "role": "user", - "generated_at_unix_ms": generated_at_unix_ms, - }), - json!({ - "type": CONVERSATION_TRACE_MESSAGE_PART_UPDATED, - "session_id": session_id, - "message_id": message_id, - "part_type": "text", - "text": prompt, - "generated_at_unix_ms": generated_at_unix_ms, - }), - ]) -} - -fn transform_claude_stop(payload: &serde_json::Map) -> Result> { - transform_claude_stop_with( - payload, - || { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default(); - let ts = uuid::Timestamp::from_unix(uuid::NoContext, now.as_secs(), now.subsec_nanos()); - uuid::Uuid::new_v7(ts) - }, - || current_unix_time_ms().unwrap_or(0), - ) -} - -fn transform_claude_stop_with( - payload: &serde_json::Map, - generate_message_id: G, - generate_timestamp_ms: T, -) -> Result> -where - G: FnOnce() -> uuid::Uuid, - T: FnOnce() -> i64, -{ - let event_name = required_non_empty_string_field( - payload, - "hook_event_name", - conversation_trace_validation_error, - )?; - - if event_name != "Stop" { - let raw_content = serde_json::to_string(payload).unwrap_or_default(); - bail!(conversation_trace_validation_error(&format!( - "unsupported Claude hook event '{event_name}': only 'Stop' is supported. Raw event: {raw_content}" - ))); - } - - let session_id = required_non_empty_string_field( - payload, - "session_id", - conversation_trace_validation_error, - )?; - let last_assistant_message = required_non_empty_string_field( - payload, - "last_assistant_message", - conversation_trace_validation_error, - )?; - - let message_id = generate_message_id().to_string(); - let generated_at_unix_ms = generate_timestamp_ms(); - - Ok(vec![ - json!({ - "type": CONVERSATION_TRACE_MESSAGE_UPDATED, - "session_id": session_id, - "message_id": message_id, - "role": "assistant", - "generated_at_unix_ms": generated_at_unix_ms, - }), - json!({ - "type": CONVERSATION_TRACE_MESSAGE_PART_UPDATED, - "session_id": session_id, - "message_id": message_id, - "part_type": "text", - "text": last_assistant_message, - "generated_at_unix_ms": generated_at_unix_ms, - }), - ]) -} -fn transform_claude_post_tool_use(payload: &serde_json::Map) -> Result> { - transform_claude_post_tool_use_with( - payload, - || { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default(); - let ts = uuid::Timestamp::from_unix(uuid::NoContext, now.as_secs(), now.subsec_nanos()); - uuid::Uuid::new_v7(ts) - }, - || current_unix_time_ms().unwrap_or(0), - ) -} - -fn transform_claude_post_tool_use_with( - payload: &serde_json::Map, - generate_message_id: G, - generate_timestamp_ms: T, -) -> Result> -where - G: FnOnce() -> uuid::Uuid, - T: FnOnce() -> i64, -{ - let event_name = required_non_empty_string_field( - payload, - "hook_event_name", - conversation_trace_validation_error, - )?; - - if event_name != "PostToolUse" { - let raw_content = serde_json::to_string(payload).unwrap_or_default(); - bail!(conversation_trace_validation_error(&format!( - "unsupported Claude hook event '{event_name}': only 'PostToolUse' is supported. Raw event: {raw_content}" - ))); - } - - let tool_name = payload - .get("tool_name") - .and_then(|v| v.as_str()) - .unwrap_or(""); - if tool_name != "Write" && tool_name != "Edit" { - return Ok(vec![]); - } - - let session_id = required_non_empty_string_field( - payload, - "session_id", - conversation_trace_validation_error, - )?; - - let message_id = generate_message_id().to_string(); - let generated_at_unix_ms = generate_timestamp_ms(); - - match build_claude_post_tool_use_patch(payload) { - PatchBuildResult::Built(parsed_patch) => { - let text = serde_json::to_string(&parsed_patch)?; - let items = vec![ - json!({ - "type": CONVERSATION_TRACE_MESSAGE_UPDATED, - "session_id": session_id, - "message_id": message_id, - "role": "assistant", - "generated_at_unix_ms": generated_at_unix_ms, - }), - json!({ - "type": CONVERSATION_TRACE_MESSAGE_PART_UPDATED, - "session_id": session_id, - "message_id": message_id, - "part_type": "patch", - "text": text, - "generated_at_unix_ms": generated_at_unix_ms, - }), - ]; - Ok(items) - } - PatchBuildResult::Skipped(_) => Ok(vec![]), - } -} - -#[cfg(test)] -mod tests { - use std::{ - cell::RefCell, - fs, - path::{Path, PathBuf}, - process::Command, - thread, - time::{Duration, SystemTime, UNIX_EPOCH}, - }; - - use super::*; - use crate::services::agent_trace_db::{ - ClaudeModelStateObservation, ObservationKind, ParsedDiffTracePatch, SkippedDiffTracePatch, - }; - - #[derive(Debug, Eq, PartialEq)] - struct CapturedPostCommitIntersectionInsert { - commit_id: String, - post_commit_time_ms: i64, - recent_window_cutoff_ms: i64, - recent_window_end_ms: i64, - loaded_diff_trace_count: i64, - skipped_diff_trace_count: i64, - intersection_patch: String, - } - - fn valid_patch_text(path: &str, content: &str) -> String { - format!( - "Index: {path}\n===================================================================\n--- {path}\n+++ {path}\n@@ -0,0 +1,1 @@\n+{content}\n" - ) - } - - fn valid_patch(path: &str, content: &str) -> ParsedPatch { - let patch_text = valid_patch_text(path, content); - - parse_patch_from_text(&patch_text, None).expect("test patch should parse") - } - - #[test] - fn conversation_trace_mixed_payload_maps_to_message_and_part_insert_inputs() { - let patch_text = valid_patch_text("src/lib.rs", "let answer = 42;"); - let question_text = serde_json::json!([ - { - "question": "Proceed?", - "answer": "Yes" - } - ]) - .to_string(); - let payload = serde_json::json!({ - "tool_name": "opencode", - "payloads": [ - { - "type": "message", - "session_id": "session-1", - "message_id": "message-1", - "role": "assistant", - "generated_at_unix_ms": 1_800_000_000_000_i64 - }, - { - "type": "message.part", - "session_id": "session-1", - "message_id": "message-1", - "part_type": "reasoning", - "text": "thinking through validation", - "generated_at_unix_ms": 1_800_000_000_001_i64 - }, - { - "type": "message.part", - "session_id": "session-1", - "message_id": "message-1", - "part_type": "patch", - "text": patch_text, - "generated_at_unix_ms": 1_800_000_000_002_i64 - }, - { - "type": "message.part", - "session_id": "session-1", - "message_id": "message-1", - "part_type": "question", - "text": question_text, - "generated_at_unix_ms": 1_800_000_000_003_i64 - } - ] - }); - - let parsed = parse_conversation_trace_payload(&payload.to_string()) - .expect("conversation-trace mixed payload should parse"); - - assert_eq!(parsed.attempted_count, 4); - assert!(parsed.skipped.is_empty()); - assert!(parsed.message_updated.skipped.is_empty()); - assert!(parsed.message_part_updated.skipped.is_empty()); - - assert_eq!(parsed.message_updated.inserts.len(), 1); - let message = &parsed.message_updated.inserts[0]; - assert_eq!(message.session_id, "oc_session-1"); - assert_eq!(message.message_id, "message-1"); - assert_eq!(message.role, MessageRole::Assistant); - assert_eq!(message.generated_at_unix_ms, 1_800_000_000_000_i64); - - assert_eq!(parsed.message_part_updated.inserts.len(), 3); - let reasoning_part = &parsed.message_part_updated.inserts[0]; - assert_eq!(reasoning_part.session_id, "oc_session-1"); - assert_eq!(reasoning_part.message_id, "message-1"); - assert_eq!(reasoning_part.part_type, PartType::Reasoning); - assert_eq!(reasoning_part.text, "thinking through validation"); - assert_eq!(reasoning_part.generated_at_unix_ms, 1_800_000_000_001_i64); - - let patch_part = &parsed.message_part_updated.inserts[1]; - assert_eq!(patch_part.session_id, "oc_session-1"); - assert_eq!(patch_part.message_id, "message-1"); - assert_eq!(patch_part.part_type, PartType::Patch); - assert_eq!( - patch_part.text, - serialize_to_json(&valid_patch("src/lib.rs", "let answer = 42;")) - .expect("test patch should serialize") - ); - assert_eq!(patch_part.generated_at_unix_ms, 1_800_000_000_002_i64); - - let question_part = &parsed.message_part_updated.inserts[2]; - assert_eq!(question_part.session_id, "oc_session-1"); - assert_eq!(question_part.message_id, "message-1"); - assert_eq!(question_part.part_type, PartType::Question); - assert_eq!(question_part.text, question_text); - assert_eq!(question_part.generated_at_unix_ms, 1_800_000_000_003_i64); - } - - #[test] - fn conversation_trace_mixed_payload_skips_malformed_sibling_items() { - let invalid_question_text = serde_json::json!({ - "question": "Proceed?", - "answer": "Yes" - }) - .to_string(); - let payload = serde_json::json!({ - "tool_name": "opencode", - "payloads": [ - { - "type": "message", - "session_id": "session-1", - "message_id": "message-1", - "role": "assistant", - "generated_at_unix_ms": 1_800_000_000_000_i64 - }, - { - "type": "message", - "session_id": "session-2", - "message_id": "message-2", - "role": "system", - "generated_at_unix_ms": 1_800_000_000_002_i64 - }, - { - "type": "message.part", - "session_id": "session-3", - "message_id": "message-3", - "part_type": "text", - "generated_at_unix_ms": 1_800_000_000_003_i64 - }, - { - "type": "message.part", - "session_id": "session-4", - "message_id": "message-4", - "part_type": "patch", - "text": "--- src/main.rs", - "generated_at_unix_ms": 1_800_000_000_004_i64 - }, - { - "type": "message.part", - "session_id": "session-5", - "message_id": "message-5", - "part_type": "question", - "text": invalid_question_text, - "generated_at_unix_ms": 1_800_000_000_005_i64 - }, - { - "type": "session.started", - "session_id": "session-6" - }, - 42, - { - "type": null, - "session_id": "session-7" - } - ] - }); - - let parsed = parse_conversation_trace_payload(&payload.to_string()) - .expect("conversation-trace mixed payload should parse with skipped items"); - - assert_eq!(parsed.attempted_count, 8); - assert_eq!(parsed.message_updated.inserts.len(), 1); - assert_eq!(parsed.message_updated.skipped.len(), 1); - assert_eq!(parsed.message_updated.skipped[0].index, 1); - assert!(parsed.message_updated.skipped[0] - .reason - .contains("field 'role'")); - assert_eq!(parsed.message_part_updated.inserts.len(), 0); - assert_eq!(parsed.message_part_updated.skipped.len(), 3); - assert_eq!(parsed.message_part_updated.skipped[0].index, 2); - assert!(parsed.message_part_updated.skipped[0] - .reason - .contains("missing required field 'text'")); - assert_eq!(parsed.message_part_updated.skipped[1].index, 3); - assert!(parsed.message_part_updated.skipped[1] - .reason - .contains("neither valid patch-JSON nor a valid patch")); - assert_eq!(parsed.message_part_updated.skipped[2].index, 4); - assert!(parsed.message_part_updated.skipped[2] - .reason - .contains("question part must be a JSON array")); - assert_eq!(parsed.skipped.len(), 3); - assert_eq!(parsed.skipped[0].index, 5); - assert!(parsed.skipped[0].reason.contains("field 'type'")); - assert_eq!(parsed.skipped[1].index, 6); - assert!(parsed.skipped[1] - .reason - .contains("payloads[6] must be an object")); - assert_eq!(parsed.skipped[2].index, 7); - assert!(parsed.skipped[2] - .reason - .contains("field 'type' must be a string")); - } - - fn normalized_conversation_trace_message_payload(tool_name: &str, session_id: &str) -> String { - serde_json::json!({ - "tool_name": tool_name, - "payloads": [ - { - "type": "message", - "session_id": session_id, - "message_id": "message-1", - "role": "assistant", - "generated_at_unix_ms": 1_800_000_000_000_i64 - } - ] - }) - .to_string() - } - - #[test] - fn conversation_trace_normalized_payload_accepts_pi_tool_name_with_prefixed_session_id() { - let stdin_payload = normalized_conversation_trace_message_payload("pi", "session-1"); - - let parsed = parse_conversation_trace_payload(&stdin_payload) - .expect("Pi normalized conversation-trace payload should parse"); - - assert_eq!(parsed.message_updated.inserts.len(), 1); - assert_eq!(parsed.message_updated.inserts[0].session_id, "pi_session-1"); - } - - #[test] - fn conversation_trace_normalized_payload_rejects_unsupported_tool_name() { - let stdin_payload = normalized_conversation_trace_message_payload("cursor", "session-1"); - - let error = parse_conversation_trace_payload(&stdin_payload) - .expect_err("unsupported tool_name should be rejected"); - - assert!(error.to_string().contains("unsupported tool_name 'cursor'")); - assert!(error.to_string().contains("'opencode'")); - assert!(error.to_string().contains("'pi'")); - } - - #[test] - fn conversation_trace_normalized_payload_rejects_empty_tool_name() { - let stdin_payload = normalized_conversation_trace_message_payload("", "session-1"); - - let error = parse_conversation_trace_payload(&stdin_payload) - .expect_err("empty tool_name should be rejected"); - - assert!(error - .to_string() - .contains("field 'tool_name' must be a non-empty string")); - } - - #[test] - fn conversation_trace_normalized_payload_rejects_missing_tool_name() { - let stdin_payload = serde_json::json!({ - "payloads": [ - { - "type": "message", - "session_id": "session-1", - "message_id": "message-1", - "role": "assistant", - "generated_at_unix_ms": 1_800_000_000_000_i64 - } - ] - }) - .to_string(); - - let error = parse_conversation_trace_payload(&stdin_payload) - .expect_err("missing tool_name should be rejected"); - - assert!(error - .to_string() - .contains("missing required field 'tool_name'")); - } - - #[test] - fn conversation_trace_normalized_payload_keeps_already_prefixed_session_id() { - let stdin_payload = - normalized_conversation_trace_message_payload("opencode", "oc_session-1"); - - let parsed = parse_conversation_trace_payload(&stdin_payload) - .expect("already-prefixed OpenCode session ID should parse"); - - assert_eq!(parsed.message_updated.inserts[0].session_id, "oc_session-1"); - } - - #[test] - fn conversation_trace_raw_claude_event_uses_claude_identity_with_cc_prefixed_session_id() { - let stdin_payload = serde_json::json!({ - "hook_event_name": "UserPromptSubmit", - "session_id": "session-1", - "prompt": "hello" - }) - .to_string(); - - let parsed = parse_conversation_trace_payload(&stdin_payload) - .expect("raw Claude UserPromptSubmit event should parse"); - - assert_eq!(parsed.message_updated.inserts.len(), 1); - assert_eq!(parsed.message_updated.inserts[0].session_id, "cc_session-1"); - } - - fn diff_trace_payload(model_id: Option<&str>, tool_version: Option<&str>) -> DiffTracePayload { - diff_trace_payload_with( - "claude", - "session-123", - PAYLOAD_TYPE_STRUCTURED, - model_id, - tool_version, - ) - } - - fn diff_trace_payload_with( - tool_name: &str, - session_id: &str, - payload_type: &str, - model_id: Option<&str>, - tool_version: Option<&str>, - ) -> DiffTracePayload { - DiffTracePayload { - session_id: String::from(session_id), - diff: String::from("diff text"), - time: 1_800_000_000_000_u64, - model_id: model_id.map(String::from), - agent_id: None, - transcript_path: None, - tool_name: String::from(tool_name), - tool_version: tool_version.map(String::from), - payload_type: String::from(payload_type), - } - } - - fn claude_model_test_event(transcript_path: &Path, tool_use_id: &str) -> Value { - json!({ - "hook_event_name": "PostToolUse", - "session_id": "session-123", - "tool_name": "Write", - "tool_use_id": tool_use_id, - "transcript_path": transcript_path, - "tool_input": { - "file_path": "docs/status.md", - "content": "# Status\n\nThe new state is complete.\n" - }, - "tool_response": { - "originalFile": "# Status\n\nThe old state is pending.\n", - "structuredPatch": { - "hunks": [{ - "oldStart": 1, - "oldCount": 3, - "newStart": 1, - "newCount": 3, - "lines": [ - " # Status", - " ", - "-The old state is pending.", - "+The new state is complete." - ] - }] - } - } - }) - } - - fn parsed_claude_model_id(event: &Value) -> Option { - match parse_diff_trace_payload(&event.to_string()) - .expect("Claude PostToolUse diff-trace payload should parse") - { - DiffTraceParseResult::Persist(payload) => payload.model_id, - DiffTraceParseResult::NoOp(message) => { - panic!("Claude Write payload should persist, got no-op: {message}") - } - } - } - - fn parsed_claude_diff_trace(event: &Value) -> DiffTracePayload { - match parse_diff_trace_payload(&event.to_string()) - .expect("Claude PostToolUse diff-trace payload should parse") - { - DiffTraceParseResult::Persist(payload) => payload, - DiffTraceParseResult::NoOp(message) => { - panic!("Claude Write payload should persist, got no-op: {message}") - } - } - } - - fn unique_attribution_db_path(label: &str) -> PathBuf { - let suffix = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time should be after Unix epoch") - .as_nanos(); - std::env::temp_dir() - .join(format!("sce-claude-model-attribution-{label}-{suffix}")) - .join("agent-trace.db") - } - - fn resolved_claude_model_id_with(event: &Value, transcript_lookup: F) -> Option - where - F: FnOnce(&Path, &str) -> Option, - { - resolve_claude_model_id_with( - event.as_object().expect("test event should be an object"), - transcript_lookup, - ) - } - - fn run_attribution_git(repo_root: &Path, args: &[&str]) { - let output = Command::new("git") - .args(args) - .current_dir(repo_root) - .output() - .expect("git should start"); - assert!( - output.status.success(), - "git {args:?} failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - - fn init_attribution_git_repo(label: &str) -> PathBuf { - let repo_root = unique_attribution_db_path(label) - .parent() - .expect("test repository should have a parent") - .to_path_buf(); - fs::create_dir_all(&repo_root).expect("test repository directory should be created"); - run_attribution_git(&repo_root, &["init", "-q"]); - run_attribution_git( - &repo_root, - &["remote", "add", "origin", "git@github.com:acme/widgets.git"], - ); - repo_root - } - - fn model_less_claude_diff_event( - session_id: &str, - tool_use_id: &str, - agent_id: Option<&str>, - ) -> Value { - let mut event = claude_model_test_event(Path::new("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/virtual/missing.jsonl"), tool_use_id); - let object = event - .as_object_mut() - .expect("Claude test event should be an object"); - object.insert("session_id".to_string(), json!(session_id)); - object.remove("transcript_path"); - object.remove("tool_use_id"); - if let Some(agent_id) = agent_id { - object.insert("agent_id".to_string(), json!(agent_id)); - } - event - } - - fn persisted_model_ids(db: &RepositoryAgentTraceDb) -> Vec> { - db.query_map( - "SELECT model_id FROM diff_traces ORDER BY id ASC", - (), - |row| row.get::>(0).map_err(Into::into), - ) - .expect("persisted model IDs should be readable") - } - - #[test] - fn claude_model_direct_nested_metadata_wins_over_transcript_without_double_prefixing() { - let transcript_path = Path::new("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/unused/direct-precedence.jsonl"); - let mut event = claude_model_test_event(transcript_path, "tool-123"); - event - .as_object_mut() - .expect("test event should be an object") - .insert("model".to_string(), json!({ "id": "claude/direct-model" })); - - let model_id = resolved_claude_model_id_with(&event, |_, _| { - panic!("transcript lookup must not run when direct metadata is present") - }); - - assert_eq!(model_id.as_deref(), Some("claude/direct-model")); - assert_eq!(parsed_claude_model_id(&event), model_id); - } - - #[test] - fn claude_model_falls_back_to_matching_transcript_and_normalizes_model() { - let transcript_path = Path::new("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/virtual/transcript-fallback.jsonl"); - let event = claude_model_test_event(transcript_path, "tool-123"); - - let model_id = resolved_claude_model_id_with(&event, |path, tool_use_id| { - assert_eq!(path, transcript_path); - assert_eq!(tool_use_id, "tool-123"); - Some(String::from("claude/claude-opus-4-1")) - }); - - assert_eq!(model_id.as_deref(), Some("claude/claude-opus-4-1")); - } - - #[test] - fn claude_model_remains_none_when_transcript_lookup_cannot_succeed() { - let event = claude_model_test_event(Path::new("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/virtual/missing.jsonl"), "tool-123"); - assert_eq!(resolved_claude_model_id_with(&event, |_, _| None), None); - - let mut event_without_lookup_fields = event; - let payload = event_without_lookup_fields - .as_object_mut() - .expect("test event should be an object"); - payload.remove("transcript_path"); - payload.remove("tool_use_id"); - assert_eq!( - resolved_claude_model_id_with(&event_without_lookup_fields, |_, _| { - panic!("lookup must not run without transcript event metadata") - }), - None - ); - } - - #[test] - fn claude_diff_trace_parser_keeps_agent_id_ephemeral_and_storage_free() { - let mut event = claude_model_test_event(Path::new("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/virtual/missing.jsonl"), "tool-123"); - event - .as_object_mut() - .expect("test event should be an object") - .insert("agent_id".to_string(), json!(" agent-1 ")); - - let payload = parsed_claude_diff_trace(&event); - - assert_eq!(payload.agent_id.as_deref(), Some("agent-1")); - assert!(serde_json::to_value(&payload) - .expect("internal payload should serialize") - .get("agent_id") - .is_none()); - } - - #[test] - fn claude_diff_trace_parser_keeps_transcript_path_ephemeral_and_storage_free() { - let transcript_path = Path::new("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/virtual/session-123.jsonl"); - let event = claude_model_test_event(transcript_path, "tool-123"); - - let payload = parsed_claude_diff_trace(&event); - - assert_eq!( - payload.transcript_path.as_deref(), - Some("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/virtual/session-123.jsonl") - ); - assert!(serde_json::to_value(&payload) - .expect("internal payload should serialize") - .get("transcript_path") - .is_none()); - } - - #[test] - fn claude_diff_trace_parser_leaves_transcript_path_none_without_the_field() { - let mut event = claude_model_test_event(Path::new("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/virtual/missing.jsonl"), "tool-123"); - event - .as_object_mut() - .expect("test event should be an object") - .remove("transcript_path"); - - assert_eq!(parsed_claude_diff_trace(&event).transcript_path, None); - } - - #[test] - fn claude_diff_trace_normalized_opencode_payload_carries_no_transcript_path() { - let stdin_payload = serde_json::json!({ - "sessionID": "session-123", - "diff": "diff text", - "time": 1_800_000_000_000_u64, - "model_id": "anthropic/claude-opus-4", - "tool_name": "opencode", - "tool_version": null - }) - .to_string(); - - let parsed = parse_diff_trace_payload(&stdin_payload) - .expect("normalized OpenCode diff-trace payload should parse"); - let payload = match parsed { - DiffTraceParseResult::Persist(payload) => payload, - DiffTraceParseResult::NoOp(message) => { - panic!("normalized OpenCode payload should persist, got no-op: {message}") - } - }; - - assert_eq!(payload.transcript_path, None); - } - - #[test] - #[allow(clippy::too_many_lines)] - fn claude_model_attribution_end_to_end_persists_lifecycle_fallback_precedence_and_scope() { - let repo_root = init_attribution_git_repo("end-to-end"); - let state_root = unique_attribution_db_path("end-to-end-state") - .parent() - .expect("test state should have a parent") - .to_path_buf(); - let storage = resolve_agent_trace_storage_at_state_root( - &AgentTraceStorageContext { - repository_root: &repo_root, - explicit_repository_id: None, - repository_remote: "origin", - }, - &state_root, - ) - .expect("setup path should initialize the test repository DB"); - drop(storage); - - let session_start = json!({ - "hook_event_name": "SessionStart", - "session_id": "session-123", - "model": "model-a", - "source": "startup" - }); - assert_eq!( - claude_model_state::run_claude_model_state_from_payload_at_state_root( - &repo_root, - &state_root, - &session_start.to_string(), - None, - || Ok(10), - ), - "" - ); - - let db = open_agent_trace_db_for_hook_runtime_at_state_root( - &repo_root, - &state_root, - "test DB should open after SessionStart", - ) - .expect("test DB should open after SessionStart"); - assert_eq!( - db.claude_model_state_by_session_and_agent("cc_session-123", "") - .expect("SessionStart state should be readable") - .expect("SessionStart should seed state") - .model_id, - "claude/model-a" - ); - let session_start_event = model_less_claude_diff_event("session-123", "tool-a", None); - persist_diff_trace_payload_to_agent_trace_db_with_db( - &db, - &parsed_claude_diff_trace(&session_start_event), - ) - .expect("SessionStart state should attribute the next diff trace"); - drop(db); - - let post_model_switch = json!({ - "hook_event_name": "PostModelSwitch", - "session_id": "session-123", - "from_model": "model-a", - "to_model": "model-b", - "source": "picker" - }); - assert_eq!( - claude_model_state::run_claude_model_state_from_payload_at_state_root( - &repo_root, - &state_root, - &post_model_switch.to_string(), - None, - || Ok(20), - ), - "" - ); - - let db = open_agent_trace_db_for_hook_runtime_at_state_root( - &repo_root, - &state_root, - "test DB should open after PostModelSwitch", - ) - .expect("test DB should open after PostModelSwitch"); - assert_eq!( - db.claude_model_state_by_session_and_agent("cc_session-123", "") - .expect("PostModelSwitch state should be readable") - .expect("PostModelSwitch should update state") - .model_id, - "claude/model-b" - ); - let switched_event = model_less_claude_diff_event("session-123", "tool-b", None); - persist_diff_trace_payload_to_agent_trace_db_with_db( - &db, - &parsed_claude_diff_trace(&switched_event), - ) - .expect("PostModelSwitch state should attribute the next diff trace"); - - let mut direct_event = model_less_claude_diff_event("session-123", "tool-direct", None); - direct_event - .as_object_mut() - .expect("Claude test event should be an object") - .insert("model".to_string(), json!("model-c")); - persist_diff_trace_payload_to_agent_trace_db_with_db( - &db, - &parsed_claude_diff_trace(&direct_event), - ) - .expect("direct model attribution should persist"); - - let transcript_path = state_root.join("transcript.jsonl"); - fs::write( - &transcript_path, - concat!( - r#"{"type":"assistant","message":{"role":"assistant","model":"model-c","content":[{"type":"tool_use","id":"tool-transcript"}]}}"#, - "\n" - ), - ) - .expect("transcript fixture should be written"); - let transcript_event = claude_model_test_event(&transcript_path, "tool-transcript"); - persist_diff_trace_payload_to_agent_trace_db_with_db( - &db, - &parsed_claude_diff_trace(&transcript_event), - ) - .expect("transcript model attribution should persist"); - - let no_state_event = - model_less_claude_diff_event("session-without-state", "tool-none", None); - persist_diff_trace_payload_to_agent_trace_db_with_db( - &db, - &parsed_claude_diff_trace(&no_state_event), - ) - .expect("an attribution-less diff trace should still persist"); - - let subagent_event = - model_less_claude_diff_event("session-123", "tool-subagent", Some("subagent-1")); - persist_diff_trace_payload_to_agent_trace_db_with_db( - &db, - &parsed_claude_diff_trace(&subagent_event), - ) - .expect("a subagent diff trace should persist"); - - assert_eq!( - persisted_model_ids(&db), - vec![ - Some(String::from("claude/model-a")), - Some(String::from("claude/model-b")), - Some(String::from("claude/model-c")), - Some(String::from("claude/model-c")), - None, - None, - ] - ); - - drop(db); - fs::remove_file(transcript_path).expect("transcript fixture should be removed"); - fs::remove_dir_all(repo_root).expect("test repository should be removed"); - fs::remove_dir_all(state_root).expect("test state should be removed"); - } - - #[test] - fn claude_model_attribution_bridge_inheritance_seeds_state_and_diff_trace() { - let repo_root = init_attribution_git_repo("bridge-inheritance"); - let state_root = unique_attribution_db_path("bridge-inheritance-state") - .parent() - .expect("test state should have a parent") - .to_path_buf(); - let storage = resolve_agent_trace_storage_at_state_root( - &AgentTraceStorageContext { - repository_root: &repo_root, - explicit_repository_id: None, - repository_remote: "origin", - }, - &state_root, - ) - .expect("setup path should initialize the test repository DB"); - drop(storage); - - let sibling_transcript = state_root.join("session-old.jsonl"); - let current_transcript = state_root.join("session-current.jsonl"); - fs::write( - &sibling_transcript, - concat!( - r#"{"type":"file-history-snapshot"}"#, - "\n", - r#"{"type":"bridge-session","sessionId":"session-old","bridgeSessionId":"cse_shared"}"#, - "\n", - ), - ) - .expect("sibling transcript fixture should be written"); - fs::write( - ¤t_transcript, - concat!( - r#"{"type":"file-history-snapshot"}"#, - "\n", - r#"{"type":"bridge-session","sessionId":"session-current","bridgeSessionId":"cse_shared"}"#, - "\n", - ), - ) - .expect("current transcript fixture should be written"); - - let db = open_agent_trace_db_for_hook_runtime_at_state_root( - &repo_root, - &state_root, - "test DB should open before bridge inheritance", - ) - .expect("test DB should open before bridge inheritance"); - db.upsert_claude_model_state(ClaudeModelStateObservation { - session_id: String::from("cc_session-old"), - agent_id: String::new(), - model_id: String::from("claude/inherited-model"), - observation_kind: ObservationKind::SessionStart, - source: String::from("startup"), - observed_at_ms: 5, - }) - .expect("sibling state should be seeded"); - drop(db); - - let session_start = json!({ - "hook_event_name": "SessionStart", - "session_id": "session-current", - "source": "clear", - "transcript_path": current_transcript, - }); - assert_eq!( - claude_model_state::run_claude_model_state_from_payload_at_state_root( - &repo_root, - &state_root, - &session_start.to_string(), - None, - || Ok(10), - ), - "" - ); - - let db = open_agent_trace_db_for_hook_runtime_at_state_root( - &repo_root, - &state_root, - "test DB should open after bridge inheritance", - ) - .expect("test DB should open after bridge inheritance"); - let inherited = db - .claude_model_state_by_session_and_agent("cc_session-current", "") - .expect("inherited state lookup should succeed") - .expect("current session should inherit sibling state"); - assert_eq!(inherited.model_id, "claude/inherited-model"); - assert_eq!(inherited.source, "bridge_inherited"); - assert_eq!(inherited.observation_kind, ObservationKind::SessionStart); - assert_eq!(inherited.observed_at_ms, 10); - - let diff_event = model_less_claude_diff_event("session-current", "tool-inherited", None); - persist_diff_trace_payload_to_agent_trace_db_with_db( - &db, - &parsed_claude_diff_trace(&diff_event), - ) - .expect("inherited state should attribute the diff trace"); - assert_eq!( - persisted_model_ids(&db), - vec![Some(String::from("claude/inherited-model"))] - ); - - drop(db); - fs::remove_file(sibling_transcript).expect("sibling transcript should be removed"); - fs::remove_file(current_transcript).expect("current transcript should be removed"); - fs::remove_dir_all(repo_root).expect("test repository should be removed"); - fs::remove_dir_all(state_root).expect("test state should be removed"); - } - - #[test] - fn claude_diff_trace_persistence_uses_state_only_after_direct_and_transcript() { - let db_path = unique_attribution_db_path("precedence"); - let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); - db.upsert_claude_model_state(ClaudeModelStateObservation { - session_id: String::from("cc_session-123"), - agent_id: String::new(), - model_id: String::from("claude/state-model"), - observation_kind: ObservationKind::SessionStart, - source: String::from("startup"), - observed_at_ms: 1, - }) - .expect("state should be seeded"); - - let mut state_event = claude_model_test_event(Path::new("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/virtual/missing.jsonl"), "state"); - let state_object = state_event - .as_object_mut() - .expect("test event should be an object"); - state_object.remove("transcript_path"); - state_object.remove("tool_use_id"); - let state_payload = parsed_claude_diff_trace(&state_event); - persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &state_payload) - .expect("state fallback should persist"); - - let mut direct_event = state_event.clone(); - direct_event - .as_object_mut() - .expect("test event should be an object") - .insert("model".to_string(), json!("direct-model")); - let direct_payload = parsed_claude_diff_trace(&direct_event); - persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &direct_payload) - .expect("direct attribution should persist"); - - let transcript_path = db_path.with_extension("jsonl"); - fs::write( - &transcript_path, - concat!( - r#"{"type":"assistant","message":{"role":"assistant","model":"transcript-model","content":[{"type":"tool_use","id":"transcript"}]}}"#, - "\n" - ), - ) - .expect("transcript fixture should be written"); - let transcript_event = claude_model_test_event(&transcript_path, "transcript"); - let transcript_payload = parsed_claude_diff_trace(&transcript_event); - persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &transcript_payload) - .expect("transcript attribution should persist"); - - let models = db - .query_map( - "SELECT model_id FROM diff_traces ORDER BY id ASC", - (), - |row| row.get::>(0).map_err(Into::into), - ) - .expect("persisted models should be readable"); - assert_eq!( - models, - vec![ - Some(String::from("claude/state-model")), - Some(String::from("claude/direct-model")), - Some(String::from("claude/transcript-model")), - ] - ); - - drop(db); - fs::remove_file(transcript_path).expect("transcript fixture should be removed"); - fs::remove_dir_all(db_path.parent().expect("test DB should have a parent")) - .expect("test DB directory should be removed"); - } - - #[test] - fn normalized_claude_tool_name_does_not_use_claude_state_fallback() { - let db_path = unique_attribution_db_path("normalized-claude"); - let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); - db.upsert_claude_model_state(ClaudeModelStateObservation { - session_id: String::from("cc_session-123"), - agent_id: String::new(), - model_id: String::from("claude/parent-model"), - observation_kind: ObservationKind::SessionStart, - source: String::from("startup"), - observed_at_ms: 1, - }) - .expect("parent state should be seeded"); - - let payload = diff_trace_payload_with( - CLAUDE_TOOL_NAME, - "session-123", - PAYLOAD_TYPE_PATCH, - None, - None, - ); - persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &payload) - .expect("normalized Claude payload should persist"); - - let model = db - .query_map("SELECT model_id FROM diff_traces LIMIT 1", (), |row| { - row.get::>(0).map_err(Into::into) - }) - .expect("persisted model should be readable") - .into_iter() - .next() - .expect("diff trace row should exist"); - assert_eq!(model, None); - - drop(db); - fs::remove_dir_all(db_path.parent().expect("test DB should have a parent")) - .expect("test DB directory should be removed"); - } - - #[test] - fn claude_diff_trace_state_lookup_isolated_to_exact_subagent_scope() { - let db_path = unique_attribution_db_path("subagent"); - let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); - db.upsert_claude_model_state(ClaudeModelStateObservation { - session_id: String::from("cc_session-123"), - agent_id: String::new(), - model_id: String::from("claude/parent-model"), - observation_kind: ObservationKind::SessionStart, - source: String::from("startup"), - observed_at_ms: 1, - }) - .expect("parent state should be seeded"); - - let mut event = claude_model_test_event(Path::new("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/virtual/missing.jsonl"), "subagent"); - let event_object = event - .as_object_mut() - .expect("test event should be an object"); - event_object.remove("transcript_path"); - event_object.remove("tool_use_id"); - event_object.insert("agent_id".to_string(), json!("subagent-1")); - let payload = parsed_claude_diff_trace(&event); - persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &payload) - .expect("subagent diff trace should persist"); - - let model = db - .query_map("SELECT model_id FROM diff_traces LIMIT 1", (), |row| { - row.get::>(0).map_err(Into::into) - }) - .expect("persisted model should be readable") - .into_iter() - .next() - .expect("diff trace row should exist"); - assert_eq!(model, None); - - drop(db); - fs::remove_dir_all(db_path.parent().expect("test DB should have a parent")) - .expect("test DB directory should be removed"); - } - - fn write_bridge_transcript(path: &Path, bridge_session_id: &str) { - fs::write( - path, - format!( - concat!( - "{{\"type\":\"file-history-snapshot\"}}\n", - "{{\"type\":\"bridge-session\",\"sessionId\":\"s\",", - "\"bridgeSessionId\":\"{bridge_session_id}\"}}\n" - ), - bridge_session_id = bridge_session_id, - ), - ) - .expect("bridge transcript fixture should be written"); - } - - #[test] - fn claude_diff_trace_seeds_bridge_chain_state_on_state_miss_and_reuses_it() { - let db_path = unique_attribution_db_path("bridge-chain-seed"); - let dir = db_path - .parent() - .expect("test DB should have a parent") - .to_path_buf(); - fs::create_dir_all(&dir).expect("test DB directory should be created"); - let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); - - db.upsert_claude_model_state(ClaudeModelStateObservation { - session_id: String::from("cc_session-member"), - agent_id: String::new(), - model_id: String::from("claude/chain-model"), - observation_kind: ObservationKind::SessionStart, - source: String::from("startup"), - observed_at_ms: 100, - }) - .expect("chain member state should seed"); - - let current_transcript = dir.join("session-current.jsonl"); - let member_transcript = dir.join("session-member.jsonl"); - write_bridge_transcript(¤t_transcript, "cse_chain"); - write_bridge_transcript(&member_transcript, "cse_chain"); - - let payload = parsed_claude_diff_trace(&claude_model_test_event(¤t_transcript, "a")); - persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &payload) - .expect("bridge chain seeding should persist"); - - assert_eq!( - persisted_model_ids(&db), - vec![Some(String::from("claude/chain-model"))] - ); - let seeded = db - .claude_model_state_by_session_and_agent("cc_session-123", "") - .expect("seeded lookup should succeed") - .expect("current session should be seeded"); - assert_eq!(seeded.model_id, "claude/chain-model"); - assert_eq!(seeded.source, "bridge_inherited"); - - fs::remove_file(&member_transcript).expect("member transcript should be removed"); - let payload_two = - parsed_claude_diff_trace(&claude_model_test_event(¤t_transcript, "b")); - persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &payload_two) - .expect("second diff trace should persist"); - assert_eq!( - persisted_model_ids(&db), - vec![ - Some(String::from("claude/chain-model")), - Some(String::from("claude/chain-model")), - ] - ); - let after = db - .claude_model_state_by_session_and_agent("cc_session-123", "") - .expect("lookup should succeed") - .expect("row should still exist"); - assert_eq!(after.observed_at_ms, seeded.observed_at_ms); - - drop(db); - fs::remove_dir_all(&dir).expect("test DB directory should be removed"); - } - - #[test] - fn claude_diff_trace_bridge_chain_selects_newest_observation_across_members() { - let db_path = unique_attribution_db_path("bridge-chain-newest"); - let dir = db_path - .parent() - .expect("test DB should have a parent") - .to_path_buf(); - fs::create_dir_all(&dir).expect("test DB directory should be created"); - let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); - - db.upsert_claude_model_state(ClaudeModelStateObservation { - session_id: String::from("cc_session-root"), - agent_id: String::new(), - model_id: String::from("claude/sonnet-5"), - observation_kind: ObservationKind::SessionStart, - source: String::from("startup"), - observed_at_ms: 10, - }) - .expect("root state should seed"); - db.upsert_claude_model_state(ClaudeModelStateObservation { - session_id: String::from("cc_session-mid"), - agent_id: String::new(), - model_id: String::from("claude/opus-5"), - observation_kind: ObservationKind::PostModelSwitch, - source: String::from("picker"), - observed_at_ms: 20, - }) - .expect("mid state should seed"); - - let current_transcript = dir.join("session-current.jsonl"); - let root_transcript = dir.join("session-root.jsonl"); - let mid_transcript = dir.join("session-mid.jsonl"); - write_bridge_transcript(&mid_transcript, "cse_chain"); - write_bridge_transcript(¤t_transcript, "cse_chain"); - thread::sleep(Duration::from_millis(15)); - write_bridge_transcript(&root_transcript, "cse_chain"); - - let payload = parsed_claude_diff_trace(&claude_model_test_event(¤t_transcript, "x")); - persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &payload) - .expect("newest-observation resolution should persist"); - - assert_eq!( - persisted_model_ids(&db), - vec![Some(String::from("claude/opus-5"))] - ); - - drop(db); - fs::remove_dir_all(&dir).expect("test DB directory should be removed"); - } - - #[test] - fn claude_diff_trace_bridge_chain_fails_open_without_write_or_attribution() { - let db_path = unique_attribution_db_path("bridge-chain-fail-open"); - let dir = db_path - .parent() - .expect("test DB should have a parent") - .to_path_buf(); - fs::create_dir_all(&dir).expect("test DB directory should be created"); - let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); - - let mut event = - claude_model_test_event(Path::new("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/virtual/missing.jsonl"), "no-transcript"); - event - .as_object_mut() - .expect("event should be an object") - .remove("transcript_path"); - persist_diff_trace_payload_to_agent_trace_db_with_db( - &db, - &parsed_claude_diff_trace(&event), - ) - .expect("missing transcript should fail open"); - - let current_transcript = dir.join("session-current.jsonl"); - let member_transcript = dir.join("session-member.jsonl"); - write_bridge_transcript(¤t_transcript, "cse_chain"); - write_bridge_transcript(&member_transcript, "cse_chain"); - persist_diff_trace_payload_to_agent_trace_db_with_db( - &db, - &parsed_claude_diff_trace(&claude_model_test_event(¤t_transcript, "no-state")), - ) - .expect("stateless chain should fail open"); - - assert_eq!(persisted_model_ids(&db), vec![None, None]); - assert!( - db.claude_model_state_by_session_and_agent("cc_session-123", "") - .expect("lookup should succeed") - .is_none(), - "no state row should be written on a fail-open branch" - ); - - drop(db); - fs::remove_dir_all(&dir).expect("test DB directory should be removed"); - } - - #[test] - fn claude_diff_trace_bridge_chain_does_not_seed_subagent_scope() { - let db_path = unique_attribution_db_path("bridge-chain-subagent"); - let dir = db_path - .parent() - .expect("test DB should have a parent") - .to_path_buf(); - fs::create_dir_all(&dir).expect("test DB directory should be created"); - let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); - - db.upsert_claude_model_state(ClaudeModelStateObservation { - session_id: String::from("cc_session-member"), - agent_id: String::new(), - model_id: String::from("claude/chain-model"), - observation_kind: ObservationKind::SessionStart, - source: String::from("startup"), - observed_at_ms: 100, - }) - .expect("chain member state should seed"); - - let current_transcript = dir.join("session-current.jsonl"); - let member_transcript = dir.join("session-member.jsonl"); - write_bridge_transcript(¤t_transcript, "cse_chain"); - write_bridge_transcript(&member_transcript, "cse_chain"); - - let mut event = claude_model_test_event(¤t_transcript, "subagent"); - event - .as_object_mut() - .expect("event should be an object") - .insert("agent_id".to_string(), json!("subagent-1")); - persist_diff_trace_payload_to_agent_trace_db_with_db( - &db, - &parsed_claude_diff_trace(&event), - ) - .expect("subagent diff trace should persist"); - - assert_eq!(persisted_model_ids(&db), vec![None]); - assert!( - db.claude_model_state_by_session_and_agent("cc_session-123", "subagent-1") - .expect("lookup should succeed") - .is_none(), - "subagent scope must not inherit main-session chain state" - ); - - drop(db); - fs::remove_dir_all(&dir).expect("test DB directory should be removed"); - } - - #[test] - fn prefixed_diff_trace_session_id_prefixes_fresh_pi_session_id() { - assert_eq!( - prefixed_diff_trace_session_id("pi", "session-123"), - "pi_session-123" - ); - } - - #[test] - fn prefixed_diff_trace_session_id_keeps_already_prefixed_pi_session_id() { - assert_eq!( - prefixed_diff_trace_session_id("pi", "pi_session-123"), - "pi_session-123" - ); - } - - #[test] - fn prefixed_diff_trace_session_id_prefixes_fresh_codex_session_id() { - assert_eq!( - prefixed_diff_trace_session_id("codex", "session-123"), - "cx_session-123" - ); - } - - #[test] - fn prefixed_diff_trace_session_id_keeps_already_prefixed_codex_session_id() { - assert_eq!( - prefixed_diff_trace_session_id("codex", "cx_session-123"), - "cx_session-123" - ); - } - - #[test] - fn prefixed_diff_trace_session_id_adding_codex_does_not_affect_other_tool_prefixes() { - assert_eq!( - prefixed_diff_trace_session_id("opencode", "session-123"), - "oc_session-123" - ); - assert_eq!( - prefixed_diff_trace_session_id("claude", "session-123"), - "cc_session-123" - ); - assert_eq!( - prefixed_diff_trace_session_id("pi", "session-123"), - "pi_session-123" - ); - } - - #[test] - fn normalize_codex_model_id_preserves_fresh_model_id() { - assert_eq!( - normalize_codex_model_id("gpt-5.6-codex").as_deref(), - Some("gpt-5.6-codex") - ); - } - - #[test] - fn normalize_codex_model_id_preserves_qualified_model_ids() { - for model in ["openai/gpt-x", "qualified/custom-provider/model"] { - assert_eq!(normalize_codex_model_id(model).as_deref(), Some(model)); - } - } - - #[test] - fn normalize_codex_model_id_preserves_unqualified_model_ids() { - assert_eq!( - normalize_codex_model_id("custom-codex-model").as_deref(), - Some("custom-codex-model") - ); - } - - #[test] - fn normalize_codex_model_id_returns_none_for_blank_model_ids() { - assert_eq!(normalize_codex_model_id(" "), None); - } - - #[test] - fn normalize_opencode_model_id_preserves_qualified_and_unqualified_ids() { - for model in [ - "opencode/big-pickle", - "anthropic/claude-sonnet-4", - "custom-model", - ] { - assert_eq!(normalize_opencode_model_id(model).as_deref(), Some(model)); - } - assert_eq!( - normalize_opencode_model_id(" opencode/big-pickle ").as_deref(), - Some("opencode/big-pickle") - ); - } - - #[test] - fn normalize_opencode_model_id_returns_none_for_blank_model_ids() { - assert_eq!(normalize_opencode_model_id(""), None); - assert_eq!(normalize_opencode_model_id(" "), None); - } - - #[test] - fn pi_normalized_diff_trace_payload_persists_with_pi_prefixed_session_id() { - let stdin_payload = serde_json::json!({ - "sessionID": "session-123", - "diff": "diff text", - "time": 1_800_000_000_000_u64, - "model_id": "anthropic/claude-opus-4", - "tool_name": "pi", - "tool_version": null - }) - .to_string(); - - let parsed = parse_diff_trace_payload(&stdin_payload) - .expect("normalized Pi diff-trace payload should parse"); - let payload = match parsed { - DiffTraceParseResult::Persist(payload) => payload, - DiffTraceParseResult::NoOp(message) => { - panic!("Pi payload should persist, got no-op: {message}") - } - }; - - assert_eq!(payload.tool_name, "pi"); - assert_eq!(payload.model_id.as_deref(), Some("anthropic/claude-opus-4")); - assert_eq!(payload.tool_version, None); - - persist_diff_trace_payload_to_agent_trace_db_with( - &payload, - payload.model_id.as_deref(), - payload.tool_version.as_deref(), - |input| { - assert_eq!(input.time_ms, 1_800_000_000_000_i64); - assert_eq!(input.session_id, "pi_session-123"); - assert_eq!(input.model_id, Some("anthropic/claude-opus-4")); - assert_eq!(input.tool_name, "pi"); - assert_eq!(input.tool_version, None); - assert_eq!(input.payload_type, PAYLOAD_TYPE_PATCH); - - Ok(()) - }, - ) - .expect("Pi diff-trace payload should be persisted"); - } - - #[test] - fn post_commit_intersection_flow_preserves_pi_provenance() { - let now_ms = 1_800_000_000_000_i64; - let commit_time_ms = now_ms - 1_000; - - let output = run_post_commit_intersection_flow_with( - Path::new("/repo"), - |_| { - Ok(PostCommitPatchData { - commit_oid: String::from("def456"), - commit_time_ms, - parsed_patch: valid_patch("src/lib.rs", "shared line"), - }) - }, - || Ok(now_ms), - |_, _| { - Ok(RecentDiffTracePatches { - patches: vec![ParsedDiffTracePatch { - id: 9, - time_ms: now_ms - 500, - session_id: String::from("pi_valid-session"), - patch: valid_patch("src/lib.rs", "shared line"), - tool_name: Some(String::from("pi")), - tool_version: None, - payload_type: String::from(PAYLOAD_TYPE_PATCH), - }], - skipped: vec![], - }) - }, - |_| Ok(()), - ) - .expect("post-commit intersection flow should succeed"); - - assert_eq!(output.combined_recent_patch.files.len(), 1); - assert_eq!(output.tool_name, Some(String::from("pi"))); - assert_eq!(output.tool_version, None); - } - - #[test] - fn diff_trace_db_persistence_uses_direct_payload_model_and_tool_version() { - let payload = diff_trace_payload(Some("direct-model"), None); - - persist_diff_trace_payload_to_agent_trace_db_with( - &payload, - Some("direct-model"), - Some("Claude Code 1.2.3"), - |input| { - assert_eq!(input.time_ms, 1_800_000_000_000_i64); - assert_eq!(input.session_id, "cc_session-123"); - assert_eq!(input.model_id, Some("direct-model")); - assert_eq!(input.tool_name, "claude"); - assert_eq!(input.tool_version, Some("Claude Code 1.2.3")); - assert_eq!(input.payload_type, PAYLOAD_TYPE_STRUCTURED); - - Ok(()) - }, - ) - .expect("direct diff-trace attribution should be persisted"); - } - - #[test] - fn post_commit_intersection_flow_uses_same_window_end_for_query_and_persistence() { - let now_ms = 1_800_000_000_000_i64; - let commit_time_ms = now_ms - 1_000; - let expected_cutoff_ms = now_ms - RECENT_DAYS_MILLIS; - let query_window = RefCell::new(None); - let persisted = RefCell::new(None); - - let output = run_post_commit_intersection_flow_with( - Path::new("/repo"), - |_| { - Ok(PostCommitPatchData { - commit_oid: String::from("abc123"), - commit_time_ms, - parsed_patch: valid_patch("src/lib.rs", "shared line"), - }) - }, - || Ok(now_ms), - |cutoff_ms, end_ms| { - *query_window.borrow_mut() = Some((cutoff_ms, end_ms)); - - Ok(RecentDiffTracePatches { - patches: vec![ParsedDiffTracePatch { - id: 7, - time_ms: now_ms - 500, - session_id: String::from("oc_valid-session"), - patch: valid_patch("src/lib.rs", "shared line"), - tool_name: Some(String::from("opencode")), - tool_version: Some(String::from("1.2.3")), - payload_type: String::from(PAYLOAD_TYPE_PATCH), - }], - skipped: vec![SkippedDiffTracePatch { - id: 8, - time_ms: now_ms - 250, - session_id: String::from("oc_malformed-session"), - reason: String::from("invalid hunk header"), - }], - }) - }, - |insert_input| { - *persisted.borrow_mut() = Some(CapturedPostCommitIntersectionInsert { - commit_id: insert_input.commit_id.to_string(), - post_commit_time_ms: insert_input.post_commit_time_ms, - recent_window_cutoff_ms: insert_input.recent_window_cutoff_ms, - recent_window_end_ms: insert_input.recent_window_end_ms, - loaded_diff_trace_count: insert_input.loaded_diff_trace_count, - skipped_diff_trace_count: insert_input.skipped_diff_trace_count, - intersection_patch: insert_input.intersection_patch.to_string(), - }); - - Ok(()) - }, - ) - .expect("post-commit intersection flow should succeed"); - - assert_eq!( - query_window.into_inner(), - Some((expected_cutoff_ms, now_ms)) - ); - - let persisted = persisted - .into_inner() - .expect("intersection row should be persisted"); - assert_eq!(persisted.commit_id, "abc123"); - assert_eq!(persisted.post_commit_time_ms, commit_time_ms); - assert_eq!(persisted.recent_window_cutoff_ms, expected_cutoff_ms); - assert_eq!(persisted.recent_window_end_ms, now_ms); - assert_eq!(persisted.loaded_diff_trace_count, 1); - assert_eq!(persisted.skipped_diff_trace_count, 1); - - let intersection: ParsedPatch = serde_json::from_str(&persisted.intersection_patch) - .expect("persisted intersection patch should deserialize"); - assert_eq!(intersection.files.len(), 1); - assert_eq!(intersection.files[0].new_path, "src/lib.rs"); - assert_eq!(intersection.files[0].hunks[0].lines.len(), 1); - assert_eq!( - intersection.files[0].hunks[0].lines[0].content, - "shared line" - ); - - assert_eq!(output.post_commit_data.commit_oid, "abc123"); - assert_eq!(output.post_commit_data.commit_time_ms, commit_time_ms); - assert_eq!(output.combined_recent_patch.files.len(), 1); - assert_eq!(output.combined_recent_patch.files[0].new_path, "src/lib.rs"); - assert_eq!(output.tool_name, Some(String::from("opencode"))); - assert_eq!(output.tool_version, Some(String::from("1.2.3"))); - } - - fn post_commit_flow_result() -> PostCommitIntersectionFlowResult { - PostCommitIntersectionFlowResult { - combined_recent_patch: valid_patch("src/lib.rs", "shared line"), - post_commit_data: PostCommitPatchData { - commit_oid: String::from("abc123"), - commit_time_ms: 1_800_000_000_000, - parsed_patch: valid_patch("src/lib.rs", "shared line"), - }, - tool_name: None, - tool_version: None, - } - } - - fn minimal_agent_trace() -> AgentTrace { - serde_json::from_value(json!({ "files": [] })) - .expect("minimal Agent Trace should deserialize") - } - - #[test] - fn post_commit_auto_sync_launches_after_successful_persistence_when_enabled() { - let events = RefCell::new(Vec::new()); - - let output = run_post_commit_subcommand_with( - Path::new("/repo"), - None, - "", - |_| { - events.borrow_mut().push("intersection"); - Ok(post_commit_flow_result()) - }, - |_, _, _, _| { - events.borrow_mut().push("persistence"); - Ok(minimal_agent_trace()) - }, - |_| { - events.borrow_mut().push("config"); - Ok(true) - }, - |_| { - events.borrow_mut().push("launch"); - Ok(()) - }, - |_| { - events.borrow_mut().push("checkpoint"); - Ok(()) - }, - None, - ) - .expect("successful post-commit should remain successful"); - - assert!(output.contains("post-commit hook processed intersection")); - assert_eq!( - events.into_inner(), - vec![ - "intersection", - "persistence", - "checkpoint", - "config", - "launch" - ] - ); - } - - #[test] - fn post_commit_validation_failure_does_not_resolve_or_launch_auto_sync() { - let validation_called = RefCell::new(false); - let config_called = RefCell::new(false); - let launch_called = RefCell::new(false); - - let error = run_post_commit_subcommand_with( - Path::new("/repo"), - None, - "", - |_| Ok(post_commit_flow_result()), - |_, flow_result, vcs_type, remote_url| { - run_post_commit_agent_trace_flow_with( - flow_result, - vcs_type, - remote_url, - &ParsedPatch { files: Vec::new() }, - |_| { - *validation_called.borrow_mut() = true; - Err(anyhow!("Agent Trace validation failed")) - }, - |_| panic!("Agent Trace persistence must not run after validation failure"), - ) - }, - |_| { - *config_called.borrow_mut() = true; - Ok(true) - }, - |_| { - *launch_called.borrow_mut() = true; - Ok(()) - }, - |_| panic!("checkpoint must not run after persistence failure"), - None, - ) - .expect_err("validation failure should be returned"); - - assert!(*validation_called.borrow()); - assert!(!error.to_string().is_empty()); - assert!(!*config_called.borrow()); - assert!(!*launch_called.borrow()); - } - - fn post_commit_flow_result_for( - direct: ParsedPatch, - committed: ParsedPatch, - ) -> PostCommitIntersectionFlowResult { - PostCommitIntersectionFlowResult { - combined_recent_patch: direct, - post_commit_data: PostCommitPatchData { - commit_oid: String::from("abc123"), - commit_time_ms: 1_800_000_000_000, - parsed_patch: committed, - }, - tool_name: Some(String::from("claude")), - tool_version: Some(String::from("9.9.9")), - } - } - - fn persisted_post_commit_trace( - flow_result: &PostCommitIntersectionFlowResult, - mutation_ai_patch: &ParsedPatch, - ) -> Value { - let persisted = RefCell::new(None); - - run_post_commit_agent_trace_flow_with( - flow_result, - Some(AgentTraceVcsType::Git), - "", - mutation_ai_patch, - |_| Ok(()), - |insert| { - *persisted.borrow_mut() = Some(insert.trace_json.to_string()); - Ok(()) - }, - ) - .expect("post-commit Agent Trace flow should build and persist"); - - serde_json::from_str( - persisted - .into_inner() - .expect("trace should have been persisted") - .as_str(), - ) - .expect("persisted trace JSON should parse") - } - - #[test] - fn post_commit_agent_trace_flow_attributes_mutation_only_lines_as_ai_without_provenance() { - let flow_result = post_commit_flow_result_for( - ParsedPatch { files: Vec::new() }, - valid_patch("src/lib.rs", "mutated line"), - ); - let mutation_ai_patch = valid_patch("src/lib.rs", "mutated line"); - - let trace = persisted_post_commit_trace(&flow_result, &mutation_ai_patch); - - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["ai"]["added"], - json!(1) - ); - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], - json!(0) - ); - assert!( - trace.get("tool").is_none(), - "mutation-only coverage fabricates no tool provenance" - ); - let contributor = &trace["files"][0]["conversations"][0]["contributor"]; - assert_eq!(contributor["type"], json!("ai")); - assert!( - contributor.get("model_id").is_none(), - "mutation-only coverage carries no model provenance" - ); - assert!( - trace["files"][0]["conversations"][0] - .get("related") - .is_none(), - "mutation-only coverage carries no session provenance" - ); - } - - #[test] - fn post_commit_agent_trace_flow_keeps_direct_provenance_when_direct_covers_the_line() { - let flow_result = post_commit_flow_result_for( - valid_patch("src/lib.rs", "shared line"), - valid_patch("src/lib.rs", "shared line"), - ); - - let trace = persisted_post_commit_trace(&flow_result, &ParsedPatch { files: Vec::new() }); - - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["ai"]["added"], - json!(1) - ); - assert_eq!( - trace["tool"], - json!({ "name": "claude", "version": "9.9.9" }) - ); - assert_eq!( - trace["files"][0]["conversations"][0]["contributor"]["type"], - json!("ai") - ); - } - - #[test] - fn post_commit_agent_trace_flow_with_empty_mutation_patch_leaves_uncovered_lines_unknown() { - let flow_result = post_commit_flow_result_for( - ParsedPatch { files: Vec::new() }, - valid_patch("src/lib.rs", "human line"), - ); - - let trace = persisted_post_commit_trace(&flow_result, &ParsedPatch { files: Vec::new() }); - - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], - json!(1) - ); - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["ai"]["added"], - json!(0) - ); - assert!(trace.get("tool").is_none()); - assert_eq!( - trace["files"][0]["conversations"][0]["contributor"]["type"], - json!("unknown") - ); - } - - mod mutation_attribution_e2e { - use super::*; - use crate::services::mutation_trace::runtime::resolve_post_commit_mutation_ai_patch; - use crate::services::mutation_trace::runtime::resolve_worktree_id; - use crate::services::mutation_trace::store::encode_revision; - - fn git(repo: &Path, args: &[&str]) -> String { - let output = Command::new("git") - .args(args) - .current_dir(repo) - .output() - .expect("git should spawn"); - assert!( - output.status.success(), - "git {args:?} failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - String::from_utf8(output.stdout).expect("git output should be UTF-8") - } - - fn commit_all(repo: &Path, message: &str) { - git(repo, &["add", "-A"]); - git( - repo, - &[ - "-c", - "user.name=SCE Test", - "-c", - "user.email=sce@example.invalid", - "commit", - "-qm", - message, - ], - ); - } - - struct E2eRepo { - _temp: tempfile::TempDir, - root: PathBuf, - db_path: PathBuf, - } - - impl E2eRepo { - fn new(label: &str) -> Self { - let temp = tempfile::Builder::new() - .prefix(&format!("sce-mutation-attr-e2e-{label}-")) - .tempdir() - .expect("temp dir should be created"); - let root = temp.path().join("repo"); - fs::create_dir_all(&root).expect("repo dir should be created"); - git(&root, &["init", "-q"]); - git( - &root, - &["remote", "add", "origin", "git@github.com:acme/widgets.git"], - ); - fs::write(root.join("file.rs"), "one\n").expect("seed file should write"); - commit_all(&root, "base"); - let db_path = temp.path().join("agent-trace.db"); - RepositoryAgentTraceDb::new_at(&db_path) - .expect("repository DB should open with schema"); - Self { - _temp: temp, - root, - db_path, - } - } - - fn db(&self) -> RepositoryAgentTraceDb { - RepositoryAgentTraceDb::open_for_hooks_without_migrations_at(&self.db_path) - .expect("repository DB should reopen") - } - - fn head_tree(&self) -> String { - git(&self.root, &["rev-parse", "HEAD^{tree}"]) - .trim() - .to_owned() - } - - fn parent_tree(&self) -> String { - git(&self.root, &["rev-parse", "HEAD~1^{tree}"]) - .trim() - .to_owned() - } - - fn checkout_id(&self) -> String { - resolve_worktree_id(&self.root) - .expect("worktree identity should resolve") - .0 - } - } - - fn seed_event( - db: &RepositoryAgentTraceDb, - worktree_id: &str, - revision: u64, - before_tree: &str, - after_tree: &str, - attribution_kind: &str, - attribution_scope_id: Option<&str>, - ) { - db.execute( - "INSERT INTO mutation_trace_events - (worktree_id, revision, before_tree, after_tree, tainted, failure_kind, - attribution_kind, attribution_scope_id, boundary_kind, boundary_scope_id, - boundary_event_id) - VALUES (?1, ?2, ?3, ?4, 0, 'healthy', ?5, ?6, 'flush', NULL, NULL)", - ( - worktree_id, - encode_revision(revision).as_slice(), - before_tree, - after_tree, - attribution_kind, - attribution_scope_id, - ), - ) - .expect("mutation event insert should succeed"); - } - - fn row_count(db: &RepositoryAgentTraceDb, table: &str) -> i64 { - db.query_map(&format!("SELECT COUNT(*) FROM {table}"), (), |row| { - row.get::(0).map_err(anyhow::Error::from) - }) - .expect("count query should succeed") - .into_iter() - .next() - .expect("count row should exist") - } - - fn touched_line_count(patch: &ParsedPatch) -> usize { - patch - .files - .iter() - .flat_map(|file| file.hunks.iter()) - .map(|hunk| hunk.lines.len()) - .sum() - } - - fn flow_result_for( - repo: &E2eRepo, - direct: ParsedPatch, - ) -> PostCommitIntersectionFlowResult { - let post_commit_data = capture_post_commit_patch_from_git(&repo.root) - .expect("capturing the post-commit patch should succeed"); - PostCommitIntersectionFlowResult { - combined_recent_patch: direct, - post_commit_data, - tool_name: None, - tool_version: None, - } - } - - fn resolve_mutation_ai( - repo: &E2eRepo, - db: &RepositoryAgentTraceDb, - flow_result: &PostCommitIntersectionFlowResult, - ) -> ParsedPatch { - let direct_intersection = intersect_patches_fn( - &flow_result.combined_recent_patch, - &flow_result.post_commit_data.parsed_patch, - ); - resolve_post_commit_mutation_ai_patch( - &repo.root, - db, - &direct_intersection, - &flow_result.post_commit_data.parsed_patch, - ) - } - - fn persist_trace( - flow_result: &PostCommitIntersectionFlowResult, - db: &RepositoryAgentTraceDb, - mutation_ai_patch: &ParsedPatch, - ) -> Value { - let persisted = RefCell::new(None); - run_post_commit_agent_trace_flow_with( - flow_result, - Some(AgentTraceVcsType::Git), - "git@github.com:acme/widgets.git", - mutation_ai_patch, - |value| { - validate_agent_trace_value(value).map_err(|error| anyhow!(error.to_string())) - }, - |insert| { - *persisted.borrow_mut() = Some(insert.trace_json.to_string()); - db.insert_agent_trace(insert).map(|_| ()) - }, - ) - .expect("the post-commit Agent Trace flow should build, validate, and persist"); - - serde_json::from_str( - persisted - .into_inner() - .expect("a trace should have been persisted") - .as_str(), - ) - .expect("the persisted trace JSON should parse") - } - - #[test] - fn a_mutation_only_line_persists_as_ai_without_fabricated_provenance() { - let repo = E2eRepo::new("mutation-only"); - fs::write(repo.root.join("file.rs"), "one\ntwo\n").expect("the edit should write"); - commit_all(&repo.root, "add two"); - - let db = repo.db(); - seed_event( - &db, - &repo.checkout_id(), - 1, - &repo.parent_tree(), - &repo.head_tree(), - "ai_exclusive", - Some("scope-x"), - ); - - let flow_result = flow_result_for(&repo, ParsedPatch { files: Vec::new() }); - let mutation_ai_patch = resolve_mutation_ai(&repo, &db, &flow_result); - assert_eq!( - touched_line_count(&mutation_ai_patch), - 1, - "a healthy untainted exclusive event covers the committed line" - ); - - let trace = persist_trace(&flow_result, &db, &mutation_ai_patch); - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["ai"]["added"], - json!(1) - ); - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], - json!(0) - ); - assert!( - trace.get("tool").is_none(), - "mutation-only coverage fabricates no tool provenance" - ); - let contributor = &trace["files"][0]["conversations"][0]["contributor"]; - assert_eq!(contributor["type"], json!("ai")); - assert!( - contributor.get("model_id").is_none(), - "mutation-only coverage carries no model provenance" - ); - - assert_eq!( - row_count(&db, "diff_traces"), - 0, - "mutation evidence is never inserted into diff_traces" - ); - assert_eq!( - row_count(&db, "post_commit_patch_intersections"), - 0, - "the direct-only intersection table is untouched by this flow" - ); - assert_eq!(row_count(&db, "agent_traces"), 1); - } - - #[test] - fn direct_plus_mutation_evidence_completes_hunk_coverage_and_keeps_direct_provenance() { - let repo = E2eRepo::new("direct-plus-mutation"); - fs::write(repo.root.join("file.rs"), "one\ntwo\nthree\n") - .expect("the edit should write"); - commit_all(&repo.root, "add two and three"); - - let db = repo.db(); - seed_event( - &db, - &repo.checkout_id(), - 1, - &repo.parent_tree(), - &repo.head_tree(), - "ai_exclusive", - Some("scope-x"), - ); - - let direct = parse_patch_from_text( - "diff --git a/file.rs b/file.rs\n--- a/file.rs\n+++ b/file.rs\n@@ -1,1 +1,2 @@\n one\n+two\n", - None, - ) - .expect("the direct patch should parse"); - let mut flow_result = flow_result_for(&repo, direct); - flow_result.tool_name = Some(String::from("claude")); - flow_result.tool_version = Some(String::from("9.9.9")); - - let mutation_ai_patch = resolve_mutation_ai(&repo, &db, &flow_result); - assert_eq!( - touched_line_count(&mutation_ai_patch), - 1, - "only the line direct evidence did not cover is resolved from mutation history" - ); - - let trace = persist_trace(&flow_result, &db, &mutation_ai_patch); - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["ai"]["added"], - json!(2), - "the union of direct and mutation coverage classifies the hunk ai" - ); - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], - json!(0) - ); - assert_eq!( - trace["tool"], - json!({ "name": "claude", "version": "9.9.9" }) - ); - } - - #[test] - fn a_newer_nonexclusive_event_keeps_the_line_non_ai() { - let repo = E2eRepo::new("newer-nonexclusive"); - fs::write(repo.root.join("file.rs"), "one\ntwo\n").expect("the edit should write"); - commit_all(&repo.root, "add two"); - - let db = repo.db(); - let worktree = repo.checkout_id(); - seed_event( - &db, - &worktree, - 1, - &repo.parent_tree(), - &repo.head_tree(), - "ai_exclusive", - Some("scope-old"), - ); - seed_event( - &db, - &worktree, - 2, - &repo.parent_tree(), - &repo.head_tree(), - "ai_contended", - None, - ); - - let flow_result = flow_result_for(&repo, ParsedPatch { files: Vec::new() }); - let mutation_ai_patch = resolve_mutation_ai(&repo, &db, &flow_result); - assert_eq!( - touched_line_count(&mutation_ai_patch), - 0, - "the newer contended match resolves the line and blocks the older exclusive event" - ); - - let trace = persist_trace(&flow_result, &db, &mutation_ai_patch); - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], - json!(1) - ); - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["ai"]["added"], - json!(0) - ); - assert_eq!( - trace["files"][0]["conversations"][0]["contributor"]["type"], - json!("unknown") - ); - } - - #[test] - fn an_adversarial_foreign_worktree_event_cannot_block_the_current_worktrees_exclusive_event( - ) { - let repo = E2eRepo::new("adversarial-linked"); - - let linked_root = repo - .root - .parent() - .expect("the repo should have a parent directory") - .join("linked"); - git( - &repo.root, - &[ - "worktree", - "add", - "-q", - linked_root.to_str().expect("worktree path should be UTF-8"), - ], - ); - - fs::write(repo.root.join("file.rs"), "one\ntwo\n").expect("the edit should write"); - commit_all(&repo.root, "add two"); - - let db = repo.db(); - let current_worktree = repo.checkout_id(); - let foreign_worktree = resolve_worktree_id(&linked_root) - .expect("the linked worktree's identity should resolve") - .0; - assert_ne!( - current_worktree, foreign_worktree, - "the linked worktree must derive its own distinct identity" - ); - - seed_event( - &db, - ¤t_worktree, - 1, - &repo.parent_tree(), - &repo.head_tree(), - "ai_exclusive", - Some("scope-current"), - ); - seed_event( - &db, - &foreign_worktree, - 2, - &repo.parent_tree(), - &repo.head_tree(), - "ai_contended", - None, - ); - - let flow_result = flow_result_for(&repo, ParsedPatch { files: Vec::new() }); - let mutation_ai_patch = resolve_mutation_ai(&repo, &db, &flow_result); - assert_eq!( - touched_line_count(&mutation_ai_patch), - 1, - "only the current worktree's history is eligible, so the older exclusive event contributes" - ); - - let trace = persist_trace(&flow_result, &db, &mutation_ai_patch); - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["ai"]["added"], - json!(1), - "worktree isolation lets the current worktree's exclusive event classify the target ai" - ); - assert_eq!( - trace["files"][0]["conversations"][0]["contributor"]["type"], - json!("ai") - ); - assert!(trace.get("tool").is_none()); - } - - fn touched_contents(patch: &ParsedPatch) -> Vec { - patch - .files - .iter() - .flat_map(|file| file.hunks.iter()) - .flat_map(|hunk| hunk.lines.iter()) - .map(|line| line.content.clone()) - .collect() - } - - #[test] - #[allow(clippy::too_many_lines)] - fn persistence_boundaries_stay_separated_across_diff_traces_intersection_and_agent_trace() { - let repo = E2eRepo::new("persistence-boundary"); - - fs::write(repo.root.join("file.rs"), "one\ntwo\n") - .expect("the direct edit should write"); - git(&repo.root, &["add", "-A"]); - let intermediate_tree = git(&repo.root, &["write-tree"]).trim().to_owned(); - - fs::write(repo.root.join("file.rs"), "one\ntwo\nthree\n") - .expect("the mutation edit should write"); - commit_all(&repo.root, "add two and three"); - - let base_tree = repo.parent_tree(); - let final_tree = repo.head_tree(); - assert_ne!( - base_tree, intermediate_tree, - "the direct edit must move the tree" - ); - assert_ne!( - intermediate_tree, final_tree, - "the mutation edit must move the tree again" - ); - - let db = repo.db(); - - let now_ms = current_unix_time_ms().expect("the clock should resolve"); - db.insert_diff_trace(DiffTraceInsert { - time_ms: now_ms - 60_000, - session_id: "cc_session-direct", - patch: "diff --git a/file.rs b/file.rs\n--- a/file.rs\n+++ b/file.rs\n@@ -1,1 +1,2 @@\n one\n+two\n", - model_id: Some("claude/model-direct"), - tool_name: "claude", - tool_version: Some("9.9.9"), - payload_type: PAYLOAD_TYPE_PATCH, - }) - .expect("the direct diff_traces row should insert"); - - seed_event( - &db, - &repo.checkout_id(), - 1, - &intermediate_tree, - &final_tree, - "ai_exclusive", - Some("scope-mutation"), - ); - - let flow_result = run_post_commit_intersection_flow_with( - &repo.root, - capture_post_commit_patch_from_git, - current_unix_time_ms, - |cutoff_ms, end_ms| db.recent_diff_trace_patches(cutoff_ms, end_ms), - |insert| db.insert_post_commit_patch_intersection(insert).map(|_| ()), - ) - .expect("the real post-commit intersection flow should run"); - assert_eq!( - touched_contents(&flow_result.combined_recent_patch), - vec!["two".to_owned()], - "the combined recent patch comes from the real diff_traces query, not an in-memory patch" - ); - - let mutation_ai_patch = resolve_mutation_ai(&repo, &db, &flow_result); - assert_eq!( - touched_contents(&mutation_ai_patch), - vec!["three".to_owned()], - "mutation history resolves only the committed line direct evidence missed" - ); - - persist_trace(&flow_result, &db, &mutation_ai_patch); - - assert_eq!( - row_count(&db, "diff_traces"), - 1, - "mutation attribution must not create another diff_traces row" - ); - let stored_direct_patch: String = db - .query_map("SELECT patch FROM diff_traces", (), |row| { - row.get::(0).map_err(anyhow::Error::from) - }) - .expect("diff_traces query should succeed") - .into_iter() - .next() - .expect("one diff_traces row should exist"); - let stored_direct = parse_patch_from_text(&stored_direct_patch, None) - .expect("the stored direct patch should parse"); - assert_eq!( - touched_contents(&stored_direct), - vec!["two".to_owned()], - "the direct diff_traces row contains 'two' and never 'three'" - ); - - assert_eq!( - row_count(&db, "post_commit_patch_intersections"), - 1, - "the intersection flow persists exactly one direct-only row" - ); - let stored_intersection_json: String = db - .query_map( - "SELECT intersection_patch FROM post_commit_patch_intersections", - (), - |row| row.get::(0).map_err(anyhow::Error::from), - ) - .expect("intersection query should succeed") - .into_iter() - .next() - .expect("one intersection row should exist"); - let stored_intersection = load_patch_from_json(&stored_intersection_json) - .expect("the persisted intersection patch should reconstruct"); - assert_eq!( - touched_contents(&stored_intersection), - vec!["two".to_owned()], - "post_commit_patch_intersections stays direct-only; the mutation line 'three' \ - must never contaminate this table" - ); - - assert_eq!(row_count(&db, "agent_traces"), 1); - let stored_trace_json: String = db - .query_map("SELECT trace_json FROM agent_traces", (), |row| { - row.get::(0).map_err(anyhow::Error::from) - }) - .expect("agent_traces query should succeed") - .into_iter() - .next() - .expect("one Agent Trace row should exist"); - let trace: Value = serde_json::from_str(&stored_trace_json) - .expect("the persisted Agent Trace JSON should parse"); - validate_agent_trace_value(&trace).expect( - "the persisted agent_traces.trace_json validates against the embedded Agent Trace schema", - ); - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["ai"]["added"], - json!(2), - "direct + mutation coverage classifies both committed added lines as ai" - ); - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], - json!(0) - ); - assert_eq!( - trace["files"][0]["conversations"][0]["contributor"]["type"], - json!("ai") - ); - - assert_eq!( - trace["tool"], - json!({ "name": "claude", "version": "9.9.9" }) - ); - - assert_eq!( - row_count(&db, "mutation_trace_events"), - 1, - "attribution performs no mutation-cursor write" - ); - } - } - - mod mutation_provenance_e2e { - use super::*; - use crate::services::agent_trace_db::{ClaudeModelStateObservation, ObservationKind}; - use crate::services::agent_trace_storage::{ - resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, - }; - use crate::services::hooks::claude_mutation_scope; - use crate::services::hooks::codex_mutation_scope; - use crate::services::hooks::opencode_mutation_scope; - use crate::services::hooks::pi_mutation_scope; - use crate::services::mutation_trace::runtime::resolve_git_dir; - use crate::services::mutation_trace::runtime::resolve_post_commit_mutation_ai_patch; - - fn git(repo: &Path, args: &[&str]) -> String { - let output = Command::new("git") - .args(args) - .current_dir(repo) - .output() - .expect("git should spawn"); - assert!( - output.status.success(), - "git {args:?} failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - String::from_utf8(output.stdout).expect("git output should be UTF-8") - } - - fn row_count(db: &RepositoryAgentTraceDb, table: &str) -> i64 { - db.query_map(&format!("SELECT COUNT(*) FROM {table}"), (), |row| { - row.get::(0).map_err(anyhow::Error::from) - }) - .expect("count query should succeed") - .into_iter() - .next() - .expect("count row should exist") - } - - struct ProvenanceE2eRepo { - _temp: tempfile::TempDir, - root: PathBuf, - state_root: PathBuf, - db_path: PathBuf, - } - - impl ProvenanceE2eRepo { - fn new(label: &str) -> Self { - let temp = tempfile::Builder::new() - .prefix(&format!("sce-mutation-provenance-e2e-{label}-")) - .tempdir() - .expect("temp dir should be created"); - let root = temp.path().join("repo"); - fs::create_dir_all(&root).expect("repo dir should be created"); - git(&root, &["init", "-q"]); - git(&root, &["config", "user.email", "test@example.invalid"]); - git(&root, &["config", "user.name", "SCE Test"]); - git( - &root, - &["remote", "add", "origin", "git@github.com:acme/widgets.git"], - ); - fs::write(root.join("file.txt"), "one\n").expect("seed file should write"); - git(&root, &["add", "-A"]); - git(&root, &["commit", "-qm", "base"]); - - let state_root = temp.path().join("state"); - fs::create_dir_all(&state_root).expect("state root should be created"); - let storage = resolve_agent_trace_storage_at_state_root( - &AgentTraceStorageContext { - repository_root: &root, - explicit_repository_id: None, - repository_remote: "origin", - }, - &state_root, - ) - .expect("state-root storage should initialize the repository DB"); - - Self { - _temp: temp, - root, - state_root, - db_path: storage.db_path, - } - } - - fn db(&self) -> RepositoryAgentTraceDb { - RepositoryAgentTraceDb::open_for_hooks_without_migrations_at(&self.db_path) - .expect("repository DB should reopen") - } - - fn cwd(&self) -> String { - self.root.to_string_lossy().into_owned() - } - - fn write_change(&self, content: &str) { - fs::write(self.root.join("file.txt"), content).expect("mutation should write"); - } - - fn commit_change(&self) { - git(&self.root, &["add", "-A"]); - git(&self.root, &["commit", "-qm", "AI mutation"]); - } - - fn mutation_events(&self) -> Vec<(String, Option)> { - self.db() - .query_map( - "SELECT attribution_kind, attribution_scope_id \ - FROM mutation_trace_events ORDER BY revision", - (), - |row| { - let attribution_kind = - row.get::(0).map_err(anyhow::Error::from)?; - let attribution_scope_id = - row.get::>(1).map_err(anyhow::Error::from)?; - Ok((attribution_kind, attribution_scope_id)) - }, - ) - .expect("mutation-events query should succeed") - } - - fn run_post_commit(&self) -> Value { - let db = self.db(); - run_post_commit_subcommand_with( - &self.root, - Some(AgentTraceVcsType::Git), - "git@github.com:acme/widgets.git", - |root| { - run_post_commit_intersection_flow_with( - root, - capture_post_commit_patch_from_git, - current_unix_time_ms, - |cutoff_ms, end_ms| db.recent_diff_trace_patches(cutoff_ms, end_ms), - |insert| db.insert_post_commit_patch_intersection(insert).map(|_| ()), - ) - }, - |root, flow_result, vcs_type, remote_url| { - let direct_intersection = intersect_patches_fn( - &flow_result.combined_recent_patch, - &flow_result.post_commit_data.parsed_patch, - ); - let mutation_ai_patch = resolve_post_commit_mutation_ai_patch( - root, - &db, - &direct_intersection, - &flow_result.post_commit_data.parsed_patch, - ); - - run_post_commit_agent_trace_flow_with( - flow_result, - vcs_type, - remote_url, - &mutation_ai_patch, - |value| { - validate_agent_trace_value(value) - .map_err(|error| anyhow!(error.to_string())) - }, - |insert| db.insert_agent_trace(insert).map(|_| ()), - ) - }, - |_| Ok(false), - |_| Ok(()), - |_| db.passive_checkpoint(), - None, - ) - .expect("the real post-commit hook flow should persist Agent Trace"); - - db.query_map("SELECT trace_json FROM agent_traces", (), |row| { - row.get::(0).map_err(anyhow::Error::from) - }) - .expect("persisted Agent Trace should be readable") - .into_iter() - .next() - .map(|trace| serde_json::from_str(&trace).expect("trace JSON should parse")) - .expect("one Agent Trace row should exist") - } - } - - fn assert_mutation_trace_provenance(trace: &Value, model_id: &str, session_id: &str) { - assert_eq!(trace["files"][0]["path"], json!("file.txt")); - assert_eq!( - trace["files"][0]["conversations"][0]["contributor"], - json!({"type": "ai", "model_id": model_id}) - ); - assert_eq!( - trace["files"][0]["conversations"][0]["related"], - json!([{ - "type": "session", - "url": format!("https://sce.crocoder.dev/sessions/{session_id}"), - }]) - ); - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["ai"]["added"], - json!(1) - ); - assert_eq!( - trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], - json!(0) - ); - } - - fn opencode_before( - cwd: &str, - session_id: &str, - call_id: &str, - tool_name: &str, - model: Option<&str>, - ) -> String { - let mut payload = json!({ - "hook_event_name": "ToolExecuteBefore", - "session_id": session_id, - "call_id": call_id, - "cwd": cwd, - "tool_name": tool_name, - }); - if let Some(model) = model { - payload["model"] = json!(model); - } - payload.to_string() - } - - fn opencode_shell_env( - cwd: &str, - session_id: &str, - call_id: &str, - model: Option<&str>, - ) -> String { - let mut payload = json!({ - "hook_event_name": "ShellEnv", - "session_id": session_id, - "call_id": call_id, - "cwd": cwd, - }); - if let Some(model) = model { - payload["model"] = json!(model); - } - payload.to_string() - } - - fn opencode_after(cwd: &str, session_id: &str, call_id: &str, tool_name: &str) -> String { - json!({ - "hook_event_name": "ToolExecuteAfter", - "session_id": session_id, - "call_id": call_id, - "cwd": cwd, - "tool_name": tool_name, - }) - .to_string() - } - - fn opencode_tool_error( - cwd: &str, - session_id: &str, - call_id: &str, - tool_name: &str, - ) -> String { - json!({ - "hook_event_name": "ToolError", - "session_id": session_id, - "call_id": call_id, - "cwd": cwd, - "tool_name": tool_name, - }) - .to_string() - } - - fn drive_opencode(repo: &ProvenanceE2eRepo, payload: &str) -> Result { - opencode_mutation_scope::run_opencode_mutation_scope_from_payload_at_state_root( - &repo.state_root, - payload, - None, - ) - } - - #[test] - fn opencode_bash_mutation_persists_model_and_session_in_agent_trace() { - let repo = ProvenanceE2eRepo::new("opencode-bash"); - let session_id = "ses_opencode_bash"; - let cwd = repo.cwd(); - - drive_opencode( - &repo, - &opencode_shell_env(&cwd, session_id, "call_bash", Some("opencode/big-pickle")), - ) - .expect("OpenCode shell.env should establish the bash scope"); - - repo.write_change("one\nopencode bash mutation\n"); - - drive_opencode( - &repo, - &opencode_after(&cwd, session_id, "call_bash", "bash"), - ) - .expect("OpenCode ToolExecuteAfter should close the bash scope"); - repo.commit_change(); - - let trace = repo.run_post_commit(); - assert_mutation_trace_provenance(&trace, "opencode/big-pickle", "oc_ses_opencode_bash"); - assert_eq!(row_count(&repo.db(), "diff_traces"), 0); - assert_eq!(row_count(&repo.db(), "post_commit_patch_intersections"), 1); - assert_eq!(row_count(&repo.db(), "mutation_trace_events"), 1); - assert_eq!(row_count(&repo.db(), "agent_traces"), 1); - } - - #[test] - fn opencode_apply_patch_mutation_with_missing_model_persists_no_model_in_agent_trace() { - let repo = ProvenanceE2eRepo::new("opencode-apply-patch"); - let session_id = "ses_opencode_no_model"; - let cwd = repo.cwd(); - - drive_opencode( - &repo, - &opencode_before(&cwd, session_id, "call_patch", "apply_patch", None), - ) - .expect("OpenCode ToolExecuteBefore should establish the apply_patch scope"); - - repo.write_change("one\npatched without model evidence\n"); - - drive_opencode( - &repo, - &opencode_after(&cwd, session_id, "call_patch", "apply_patch"), - ) - .expect("OpenCode ToolExecuteAfter should close the apply_patch scope"); - repo.commit_change(); - - let trace = repo.run_post_commit(); - assert_eq!(trace["files"][0]["path"], json!("file.txt")); - let contributor = &trace["files"][0]["conversations"][0]["contributor"]; - assert_eq!(contributor["type"], json!("ai")); - assert!( - contributor.get("model_id").is_none(), - "absent model evidence must never be guessed or fabricated" - ); - assert_eq!( - trace["files"][0]["conversations"][0]["related"], - json!([{ - "type": "session", - "url": "https://sce.crocoder.dev/sessions/oc_ses_opencode_no_model", - }]) - ); - } - - #[test] - fn opencode_write_mutation_persists_model_while_task_delegation_stays_zero_footprint() { - let repo = ProvenanceE2eRepo::new("opencode-write-task"); - let session_id = "ses_opencode_write"; - let cwd = repo.cwd(); - - drive_opencode( - &repo, - &opencode_before(&cwd, session_id, "call_task", "task", None), - ) - .expect("task delegation ToolExecuteBefore is neutral"); - drive_opencode( - &repo, - &opencode_after(&cwd, session_id, "call_task", "task"), - ) - .expect("task delegation ToolExecuteAfter is neutral"); - - assert_eq!( - row_count(&repo.db(), "mutation_trace_scopes"), - 0, - "a delegation event must create no mutation scope" - ); - - drive_opencode( - &repo, - &opencode_before( - &cwd, - session_id, - "call_write", - "write", - Some("opencode/big-pickle"), - ), - ) - .expect("OpenCode write ToolExecuteBefore should establish the scope"); - repo.write_change("one\nwrite mutation\n"); - drive_opencode( - &repo, - &opencode_after(&cwd, session_id, "call_write", "write"), - ) - .expect("OpenCode write ToolExecuteAfter should close the scope"); - repo.commit_change(); - - let trace = repo.run_post_commit(); - assert_mutation_trace_provenance( - &trace, - "opencode/big-pickle", - "oc_ses_opencode_write", - ); - assert_eq!( - row_count(&repo.db(), "mutation_trace_scopes"), - 1, - "only the tracked write call created a scope" - ); - } - - #[test] - fn opencode_unknown_tool_events_create_no_scope_or_mutation_state() { - let repo = ProvenanceE2eRepo::new("opencode-untracked"); - let session_id = "ses_opencode_untracked"; - let cwd = repo.cwd(); - - for tool_name in ["read", "custom_mcp_tool", "totally_unknown_future_tool"] { - let call_id = format!("call_{tool_name}"); - drive_opencode( - &repo, - &opencode_before(&cwd, session_id, &call_id, tool_name, None), - ) - .unwrap_or_else(|_| panic!("{tool_name} ToolExecuteBefore should be neutral")); - drive_opencode( - &repo, - &opencode_after(&cwd, session_id, &call_id, tool_name), - ) - .unwrap_or_else(|_| panic!("{tool_name} ToolExecuteAfter should be neutral")); - drive_opencode( - &repo, - &opencode_tool_error(&cwd, session_id, &call_id, tool_name), - ) - .unwrap_or_else(|_| panic!("{tool_name} ToolError should be neutral")); - } - - assert_eq!(row_count(&repo.db(), "mutation_trace_scopes"), 0); - assert_eq!(row_count(&repo.db(), "mutation_trace_events"), 0); - } - - #[test] - fn opencode_child_task_session_gets_its_own_independent_scope_and_provenance() { - let repo = ProvenanceE2eRepo::new("opencode-child-session"); - let parent_session = "ses_opencode_parent"; - let child_session = "ses_opencode_child"; - let cwd = repo.cwd(); - - drive_opencode( - &repo, - &opencode_before(&cwd, parent_session, "call_task", "task", None), - ) - .expect("the parent's task delegation is neutral"); - - drive_opencode( - &repo, - &opencode_before( - &cwd, - child_session, - "call_child_write", - "write", - Some("opencode/child-model"), - ), - ) - .expect("the child session's write ToolExecuteBefore should establish its own scope"); - repo.write_change("one\nchild session mutation\n"); - drive_opencode( - &repo, - &opencode_after(&cwd, child_session, "call_child_write", "write"), - ) - .expect("the child session's write ToolExecuteAfter should close its own scope"); - repo.commit_change(); - - let trace = repo.run_post_commit(); - assert_mutation_trace_provenance( - &trace, - "opencode/child-model", - "oc_ses_opencode_child", - ); - assert_eq!( - row_count(&repo.db(), "mutation_trace_scopes"), - 1, - "the parent's task delegation created no scope; only the child session's write did" - ); - } - - #[test] - fn opencode_concurrent_reject_and_confirm_keeps_only_the_confirmed_mutation_ai() { - let repo = ProvenanceE2eRepo::new("opencode-concurrent-reject"); - let session_id = "ses_opencode_concurrent"; - let cwd = repo.cwd(); - - drive_opencode( - &repo, - &opencode_before( - &cwd, - session_id, - "call_a_edit", - "edit", - Some("opencode/big-pickle"), - ), - ) - .expect("A's edit ToolExecuteBefore should establish a scope"); - drive_opencode( - &repo, - &opencode_before( - &cwd, - session_id, - "call_b_write", - "write", - Some("opencode/big-pickle"), - ), - ) - .expect("B's write ToolExecuteBefore should establish a distinct concurrent scope"); - - fs::write(repo.root.join("rejected.txt"), "rejected mutation\n") - .expect("A's mutation should write"); - fs::write( - repo.root.join("ambiguous.txt"), - "B's mutation before recovery\n", - ) - .expect("B's pre-recovery mutation should write"); - - drive_opencode( - &repo, - &opencode_tool_error(&cwd, session_id, "call_a_edit", "edit"), - ) - .expect( - "A's ToolError should abandon A's scope and consume the shared ambiguous interval", - ); - - fs::write( - repo.root.join("confirmed.txt"), - "B's mutation after recovery\n", - ) - .expect("B's post-recovery mutation should write"); - drive_opencode( - &repo, - &opencode_after(&cwd, session_id, "call_b_write", "write"), - ) - .expect("B's ToolExecuteAfter should confirm exactly B's own surviving scope"); - - git(&repo.root, &["add", "-A"]); - git(&repo.root, &["commit", "-qm", "concurrent mutation"]); - - let db = repo.db(); - let post_commit_data = capture_post_commit_patch_from_git(&repo.root) - .expect("capturing the post-commit patch should succeed"); - let mutation_ai_patch = resolve_post_commit_mutation_ai_patch( - &repo.root, - &db, - &ParsedPatch { files: Vec::new() }, - &post_commit_data.parsed_patch, - ); - - let ai_paths: Vec<&str> = mutation_ai_patch - .files - .iter() - .map(|file| file.new_path.as_str()) - .collect(); - assert!( - !ai_paths.contains(&"rejected.txt"), - "the abandoned scope's own mutation must never enter mutation_ai_patch" - ); - assert!( - !ai_paths.contains(&"ambiguous.txt"), - "B's mutation made before the ambiguity-consuming flush is genuinely \ - indistinguishable from A's and must stay non-AI, not merely non-A" - ); - assert!( - ai_paths.contains(&"confirmed.txt"), - "B's own later mutation, made after A's interval was consumed and confirmed \ - by B's own Close, must be attributed AI" - ); - } - - #[test] - fn opencode_and_codex_unconfirmed_overlap_stays_ineligible_until_codex_confirms() { - let repo = ProvenanceE2eRepo::new("opencode-codex-overlap"); - let oc_session = "ses_opencode_overlap"; - let codex_session = "codex-overlap-session"; - let cwd = repo.cwd(); - - drive_opencode( - &repo, - &opencode_before( - &cwd, - oc_session, - "call_oc", - "write", - Some("opencode/big-pickle"), - ), - ) - .expect("OpenCode write should establish a scope"); - - let codex_pre = json!({ - "hook_event_name": "PreToolUse", - "session_id": codex_session, - "turn_id": "codex-overlap-turn", - "cwd": cwd, - "tool_name": "Bash", - "tool_use_id": "codex-overlap-bash", - "model": "gpt-5.6-sol", - "tool_input": {"command": "true"}, - }); - codex_mutation_scope::run_codex_mutation_scope_from_payload_at_state_root( - &repo.state_root, - &codex_pre.to_string(), - None, - ) - .expect("Codex Bash PreToolUse should establish a concurrent scope"); - - repo.write_change("one\nopencode overlap mutation\n"); - - drive_opencode(&repo, &opencode_after(&cwd, oc_session, "call_oc", "write")) - .expect("OpenCode ToolExecuteAfter should close its own scope"); - - let attribution_after_first_close = repo.mutation_events(); - assert_eq!( - attribution_after_first_close - .last() - .map(|(kind, _)| kind.as_str()), - Some("ineligible_unscoped"), - "an unconfirmed live Codex scope must suppress OpenCode's own confirming close" - ); - - let codex_post = json!({ - "hook_event_name": "PostToolUse", - "session_id": codex_session, - "turn_id": "codex-overlap-turn", - "cwd": cwd, - "tool_name": "Bash", - "tool_use_id": "codex-overlap-bash", - }); - codex_mutation_scope::run_codex_mutation_scope_from_payload_at_state_root( - &repo.state_root, - &codex_post.to_string(), - None, - ) - .expect("Codex PostToolUse should close its own scope"); - - drive_opencode( - &repo, - &opencode_before( - &cwd, - oc_session, - "call_oc_2", - "write", - Some("opencode/big-pickle"), - ), - ) - .expect("a fresh OpenCode write should establish a new scope"); - repo.write_change("one\nopencode overlap mutation\nsecond change\n"); - drive_opencode( - &repo, - &opencode_after(&cwd, oc_session, "call_oc_2", "write"), - ) - .expect("the fresh OpenCode scope should close cleanly once Codex is confirmed"); - - let attribution_after_second_close = repo.mutation_events(); - assert_eq!( - attribution_after_second_close - .last() - .map(|(kind, _)| kind.as_str()), - Some("ai_exclusive"), - "once every other live scope is confirmation-safe, a solo confirming close is AiExclusive" - ); - } - - #[test] - fn opencode_and_claude_overlap_produces_ai_contended() { - let repo = ProvenanceE2eRepo::new("opencode-claude-overlap"); - let oc_session = "ses_opencode_contended"; - let claude_session = "claude-overlap-session"; - let cwd = repo.cwd(); - - drive_opencode( - &repo, - &opencode_before( - &cwd, - oc_session, - "call_oc_contended", - "write", - Some("opencode/big-pickle"), - ), - ) - .expect("OpenCode write should establish a scope"); - - let claude_pre = json!({ - "hook_event_name": "PreToolUse", - "session_id": claude_session, - "cwd": cwd, - "tool_name": "Bash", - "tool_use_id": "claude-overlap-bash", - "tool_input": {"command": "printf mutation"}, - }); - claude_mutation_scope::run_claude_mutation_scope_from_payload_at_state_root( - &repo.state_root, - &claude_pre.to_string(), - None, - ) - .expect( - "Claude Bash PreToolUse should establish a concurrent, non-confirmation-required scope", - ); - - repo.write_change("one\ncontended mutation\n"); - - drive_opencode( - &repo, - &opencode_after(&cwd, oc_session, "call_oc_contended", "write"), - ) - .expect("OpenCode ToolExecuteAfter should confirm its own scope"); - - let attribution = repo.mutation_events(); - assert_eq!( - attribution.last().map(|(kind, _)| kind.as_str()), - Some("ai_contended"), - "a confirmed OpenCode close alongside a live non-confirmation-required Claude scope is contended, not suppressed" - ); - - let claude_post = json!({ - "hook_event_name": "PostToolUse", - "session_id": claude_session, - "cwd": cwd, - "tool_name": "Bash", - "tool_use_id": "claude-overlap-bash", - }); - claude_mutation_scope::run_claude_mutation_scope_from_payload_at_state_root( - &repo.state_root, - &claude_post.to_string(), - None, - ) - .expect("Claude PostToolUse should close its own scope"); - } - - #[test] - fn claude_bash_mutation_persists_model_and_session_in_agent_trace() { - let repo = ProvenanceE2eRepo::new("claude"); - let session_id = "claude-session-e2e"; - let db = repo.db(); - db.upsert_claude_model_state(ClaudeModelStateObservation { - session_id: format!("cc_{session_id}"), - agent_id: String::new(), - model_id: String::from("claude/opus-4-1"), - observation_kind: ObservationKind::SessionStart, - source: String::from("test"), - observed_at_ms: 1, - }) - .expect("Claude model state should be persisted"); - - let cwd = repo.cwd(); - let pre = json!({ - "hook_event_name": "PreToolUse", - "session_id": session_id, - "cwd": cwd, - "tool_name": "Bash", - "tool_use_id": "claude-bash-e2e", - "tool_input": {"command": "printf mutation"}, - }); - claude_mutation_scope::run_claude_mutation_scope_from_payload_at_state_root( - &repo.state_root, - &pre.to_string(), - None, - ) - .expect("Claude Bash PreToolUse should establish a scope"); - - let post = json!({ - "hook_event_name": "PostToolUse", - "session_id": session_id, - "cwd": repo.cwd(), - "tool_name": "Bash", - "tool_use_id": "claude-bash-e2e", - }); - repo.write_change("one\nclaude mutation\n"); - claude_mutation_scope::run_claude_mutation_scope_from_payload_at_state_root( - &repo.state_root, - &post.to_string(), - None, - ) - .expect("Claude Bash PostToolUse should close the scope"); - repo.commit_change(); - - let trace = repo.run_post_commit(); - assert_mutation_trace_provenance(&trace, "claude/opus-4-1", "cc_claude-session-e2e"); - assert_eq!(row_count(&repo.db(), "diff_traces"), 0); - assert_eq!(row_count(&repo.db(), "post_commit_patch_intersections"), 1); - assert_eq!(row_count(&repo.db(), "mutation_trace_events"), 1); - assert_eq!(row_count(&repo.db(), "agent_traces"), 1); - } - - #[test] - fn codex_bash_mutation_persists_model_and_session_in_agent_trace() { - let repo = ProvenanceE2eRepo::new("codex"); - let session_id = "codex-session-e2e"; - let cwd = repo.cwd(); - let pre = json!({ - "hook_event_name": "PreToolUse", - "session_id": session_id, - "turn_id": "codex-turn-e2e", - "cwd": cwd, - "tool_name": "Bash", - "tool_use_id": "codex-bash-e2e", - "model": "gpt-5.6-sol", - "tool_input": {"command": "true"}, - }); - codex_mutation_scope::run_codex_mutation_scope_from_payload_at_state_root( - &repo.state_root, - &pre.to_string(), - None, - ) - .expect("Codex Bash PreToolUse should establish a scope"); - - let post = json!({ - "hook_event_name": "PostToolUse", - "session_id": session_id, - "turn_id": "codex-turn-e2e", - "cwd": repo.cwd(), - "tool_name": "Bash", - "tool_use_id": "codex-bash-e2e", - }); - repo.write_change("one\ncodex mutation\n"); - codex_mutation_scope::run_codex_mutation_scope_from_payload_at_state_root( - &repo.state_root, - &post.to_string(), - None, - ) - .expect("Codex Bash PostToolUse should close the scope"); - repo.commit_change(); - - let trace = repo.run_post_commit(); - assert_mutation_trace_provenance(&trace, "gpt-5.6-sol", "cx_codex-session-e2e"); - assert_eq!(row_count(&repo.db(), "diff_traces"), 0); - assert_eq!(row_count(&repo.db(), "post_commit_patch_intersections"), 1); - assert_eq!(row_count(&repo.db(), "mutation_trace_events"), 1); - assert_eq!(row_count(&repo.db(), "agent_traces"), 1); - } - - fn pi_tool_call( - cwd: &str, - session_id: &str, - tool_call_id: &str, - tool_name: &str, - model: Option<&str>, - ) -> String { - let mut payload = json!({ - "hook_event_name": "ToolCall", - "session_id": session_id, - "tool_call_id": tool_call_id, - "cwd": cwd, - "tool_name": tool_name, - }); - if let Some(model) = model { - payload["model"] = json!(model); - } - payload.to_string() - } - - fn pi_tool_result( - cwd: &str, - session_id: &str, - tool_call_id: &str, - tool_name: &str, - ) -> String { - json!({ - "hook_event_name": "ToolResult", - "session_id": session_id, - "tool_call_id": tool_call_id, - "cwd": cwd, - "tool_name": tool_name, - }) - .to_string() - } - - fn pi_tool_execution_end( - cwd: &str, - session_id: &str, - tool_call_id: &str, - tool_name: &str, - ) -> String { - json!({ - "hook_event_name": "ToolExecutionEnd", - "session_id": session_id, - "tool_call_id": tool_call_id, - "cwd": cwd, - "tool_name": tool_name, - }) - .to_string() - } - - fn drive_pi(repo: &ProvenanceE2eRepo, payload: &str) -> Result { - pi_mutation_scope::run_pi_mutation_scope_from_payload_at_state_root( - &repo.state_root, - payload, - None, - ) - } - - fn pi_confirmed_tool_case(tool_name: &str, label: &str) { - let repo = ProvenanceE2eRepo::new(label); - let session_id = format!("ses-{label}"); - let call_id = format!("call-{label}"); - let cwd = repo.cwd(); - - drive_pi( - &repo, - &pi_tool_call( - &cwd, - &session_id, - &call_id, - tool_name, - Some("anthropic/opus-5"), - ), - ) - .expect("Pi ToolCall should establish the tracked scope before execution"); - - repo.write_change(&format!("one\npi {tool_name} mutation\n")); - - drive_pi( - &repo, - &pi_tool_result(&cwd, &session_id, &call_id, tool_name), - ) - .expect("Pi ToolResult should mark the attempt executed"); - drive_pi( - &repo, - &pi_tool_execution_end(&cwd, &session_id, &call_id, tool_name), - ) - .expect("Pi ToolExecutionEnd paired with an observed ToolResult should Close"); - repo.commit_change(); - - let trace = repo.run_post_commit(); - assert_mutation_trace_provenance( - &trace, - "anthropic/opus-5", - &format!("pi_{session_id}"), - ); - assert_eq!(row_count(&repo.db(), "diff_traces"), 0); - assert_eq!(row_count(&repo.db(), "post_commit_patch_intersections"), 1); - assert_eq!(row_count(&repo.db(), "mutation_trace_events"), 1); - assert_eq!(row_count(&repo.db(), "agent_traces"), 1); - } - - #[test] - fn pi_bash_mutation_persists_model_and_session_in_agent_trace() { - pi_confirmed_tool_case("bash", "pi-bash"); - } - - #[test] - fn pi_write_mutation_persists_model_and_session_in_agent_trace() { - pi_confirmed_tool_case("write", "pi-write"); - } - - #[test] - fn pi_edit_mutation_persists_model_and_session_in_agent_trace() { - pi_confirmed_tool_case("edit", "pi-edit"); - } - - #[test] - fn pi_missing_model_preserves_session_with_null_model_in_agent_trace() { - let repo = ProvenanceE2eRepo::new("pi-no-model"); - let session_id = "ses-pi-no-model"; - let cwd = repo.cwd(); - - drive_pi( - &repo, - &pi_tool_call(&cwd, session_id, "call-1", "bash", None), - ) - .expect("Pi ToolCall should establish the scope without model evidence"); - - repo.write_change("one\npi mutation without model\n"); - - drive_pi(&repo, &pi_tool_result(&cwd, session_id, "call-1", "bash")) - .expect("Pi ToolResult should mark the attempt executed"); - drive_pi( - &repo, - &pi_tool_execution_end(&cwd, session_id, "call-1", "bash"), - ) - .expect("Pi ToolExecutionEnd should close the scope"); - repo.commit_change(); - - let trace = repo.run_post_commit(); - assert_eq!(trace["files"][0]["path"], json!("file.txt")); - let contributor = &trace["files"][0]["conversations"][0]["contributor"]; - assert_eq!(contributor["type"], json!("ai")); - assert!( - contributor.get("model_id").is_none(), - "absent model evidence must never be guessed or fabricated" - ); - assert_eq!( - trace["files"][0]["conversations"][0]["related"], - json!([{ - "type": "session", - "url": "https://sce.crocoder.dev/sessions/pi_ses-pi-no-model", - }]) - ); - } - - #[test] - fn pi_read_only_and_unknown_tools_create_no_scope_or_mutation_state() { - let repo = ProvenanceE2eRepo::new("pi-untracked"); - let session_id = "ses-pi-untracked"; - let cwd = repo.cwd(); - - for tool_name in [ - "read", - "grep", - "find", - "ls", - "custom_mcp_tool", - "totally_unknown_future_tool", - "user_bash", - ] { - let call_id = format!("call-{tool_name}"); - drive_pi( - &repo, - &pi_tool_call(&cwd, session_id, &call_id, tool_name, None), - ) - .unwrap_or_else(|_| panic!("{tool_name} ToolCall should be neutral")); - drive_pi( - &repo, - &pi_tool_result(&cwd, session_id, &call_id, tool_name), - ) - .unwrap_or_else(|_| panic!("{tool_name} ToolResult should be neutral")); - drive_pi( - &repo, - &pi_tool_execution_end(&cwd, session_id, &call_id, tool_name), - ) - .unwrap_or_else(|_| panic!("{tool_name} ToolExecutionEnd should be neutral")); - } - - assert_eq!(row_count(&repo.db(), "mutation_trace_scopes"), 0); - assert_eq!(row_count(&repo.db(), "mutation_trace_events"), 0); - } - - #[test] - fn pi_later_extension_rejection_after_start_produces_no_mutation_ai_patch() { - let repo = ProvenanceE2eRepo::new("pi-later-rejection"); - let session_id = "ses-pi-rejected"; - let cwd = repo.cwd(); - - drive_pi( - &repo, - &pi_tool_call(&cwd, session_id, "call-1", "bash", Some("anthropic/opus-5")), - ) - .expect( - "Pi ToolCall should establish the scope before a later extension can reject it", - ); - - fs::write( - repo.root.join("rejected.txt"), - "should never be attributed AI\n", - ) - .expect("the blocked attempt's incidental write should still land on disk"); - - drive_pi( - &repo, - &pi_tool_execution_end(&cwd, session_id, "call-1", "bash"), - ) - .expect("ToolExecutionEnd with no preceding ToolResult must abandon, not error"); - - let scope_status = repo - .db() - .query_map("SELECT status FROM mutation_trace_scopes", (), |row| { - row.get::(0).map_err(anyhow::Error::from) - }) - .expect("scope-status query should succeed"); - assert_eq!( - scope_status, - vec!["abandoned".to_string()], - "the D7 abandon path must leave the scope durably abandoned, never closed or active" - ); - - git(&repo.root, &["add", "-A"]); - git(&repo.root, &["commit", "-qm", "rejected mutation"]); - - let db = repo.db(); - let post_commit_data = capture_post_commit_patch_from_git(&repo.root) - .expect("capturing the post-commit patch should succeed"); - let mutation_ai_patch = resolve_post_commit_mutation_ai_patch( - &repo.root, - &db, - &ParsedPatch { files: Vec::new() }, - &post_commit_data.parsed_patch, - ); - - assert!( - mutation_ai_patch.files.is_empty(), - "a Start that never reached a confirmed Close must never produce mutation_ai_patch entries" - ); - } - - #[test] - fn pi_mutate_then_error_still_persists_confirmed_mutation_through_close() { - let repo = ProvenanceE2eRepo::new("pi-error-executed"); - let session_id = "ses-pi-error"; - let cwd = repo.cwd(); - - drive_pi( - &repo, - &pi_tool_call(&cwd, session_id, "call-1", "bash", Some("anthropic/opus-5")), - ) - .expect("Pi ToolCall should establish the scope"); - - repo.write_change("one\npartial mutation before failure\n"); - - let mut result_payload: Value = - serde_json::from_str(&pi_tool_result(&cwd, session_id, "call-1", "bash")) - .expect("tool_result payload should parse as JSON"); - result_payload["isError"] = json!(true); - drive_pi(&repo, &result_payload.to_string()) - .expect("a failed-but-executed ToolResult is still positive execution evidence"); - - drive_pi( - &repo, - &pi_tool_execution_end(&cwd, session_id, "call-1", "bash"), - ) - .expect("ToolExecutionEnd paired with an observed ToolResult must Close, not abandon"); - repo.commit_change(); - - let trace = repo.run_post_commit(); - assert_mutation_trace_provenance(&trace, "anthropic/opus-5", "pi_ses-pi-error"); - } - - #[test] - fn pi_concurrent_reject_and_confirm_keeps_only_the_confirmed_mutation_ai() { - let repo = ProvenanceE2eRepo::new("pi-concurrent-reject"); - let session_id = "ses-pi-concurrent"; - let cwd = repo.cwd(); - - drive_pi( - &repo, - &pi_tool_call( - &cwd, - session_id, - "call-a-edit", - "edit", - Some("anthropic/opus-5"), - ), - ) - .expect("A's edit ToolCall should establish a scope"); - drive_pi( - &repo, - &pi_tool_call( - &cwd, - session_id, - "call-b-write", - "write", - Some("anthropic/opus-5"), - ), - ) - .expect("B's write ToolCall should establish a distinct concurrent scope"); - - fs::write(repo.root.join("rejected.txt"), "rejected mutation\n") - .expect("A's mutation should write"); - fs::write( - repo.root.join("ambiguous.txt"), - "B's mutation before recovery\n", - ) - .expect("B's pre-recovery mutation should write"); - - drive_pi( - &repo, - &pi_tool_execution_end(&cwd, session_id, "call-a-edit", "edit"), - ) - .expect( - "A's ToolExecutionEnd with no ToolResult should abandon A's scope and consume \ - the shared ambiguous interval", - ); - - fs::write( - repo.root.join("confirmed.txt"), - "B's mutation after recovery\n", - ) - .expect("B's post-recovery mutation should write"); - - drive_pi( - &repo, - &pi_tool_result(&cwd, session_id, "call-b-write", "write"), - ) - .expect("B's ToolResult should mark it executed"); - drive_pi( - &repo, - &pi_tool_execution_end(&cwd, session_id, "call-b-write", "write"), - ) - .expect("B's ToolExecutionEnd should confirm exactly B's own surviving scope"); - - git(&repo.root, &["add", "-A"]); - git(&repo.root, &["commit", "-qm", "concurrent mutation"]); - - let db = repo.db(); - let post_commit_data = capture_post_commit_patch_from_git(&repo.root) - .expect("capturing the post-commit patch should succeed"); - let mutation_ai_patch = resolve_post_commit_mutation_ai_patch( - &repo.root, - &db, - &ParsedPatch { files: Vec::new() }, - &post_commit_data.parsed_patch, - ); - - let ai_paths: Vec<&str> = mutation_ai_patch - .files - .iter() - .map(|file| file.new_path.as_str()) - .collect(); - assert!( - !ai_paths.contains(&"rejected.txt"), - "the abandoned scope's own mutation must never enter mutation_ai_patch" - ); - assert!( - !ai_paths.contains(&"ambiguous.txt"), - "B's mutation made before the ambiguity-consuming flush is genuinely \ - indistinguishable from A's and must stay non-AI, not merely non-A" - ); - assert!( - ai_paths.contains(&"confirmed.txt"), - "B's own later mutation, made after A's interval was consumed and confirmed \ - by B's own Close, must be attributed AI" - ); - } - - #[test] - fn pi_and_claude_overlap_produces_ai_contended() { - let repo = ProvenanceE2eRepo::new("pi-claude-overlap"); - let pi_session = "ses-pi-contended"; - let claude_session = "claude-pi-overlap-session"; - let cwd = repo.cwd(); - - drive_pi( - &repo, - &pi_tool_call( - &cwd, - pi_session, - "call-pi-contended", - "write", - Some("anthropic/opus-5"), - ), - ) - .expect("Pi write should establish a scope"); - - let claude_pre = json!({ - "hook_event_name": "PreToolUse", - "session_id": claude_session, - "cwd": cwd, - "tool_name": "Bash", - "tool_use_id": "claude-pi-overlap-bash", - "tool_input": {"command": "printf mutation"}, - }); - claude_mutation_scope::run_claude_mutation_scope_from_payload_at_state_root( - &repo.state_root, - &claude_pre.to_string(), - None, - ) - .expect( - "Claude Bash PreToolUse should establish a concurrent, non-confirmation-required scope", - ); - - repo.write_change("one\npi+claude contended mutation\n"); - - drive_pi( - &repo, - &pi_tool_result(&cwd, pi_session, "call-pi-contended", "write"), - ) - .expect("Pi ToolResult should mark the attempt executed"); - drive_pi( - &repo, - &pi_tool_execution_end(&cwd, pi_session, "call-pi-contended", "write"), - ) - .expect("Pi ToolExecutionEnd should confirm its own scope"); - - let attribution = repo.mutation_events(); - assert_eq!( - attribution.last().map(|(kind, _)| kind.as_str()), - Some("ai_contended"), - "a confirmed Pi close alongside a live non-confirmation-required Claude scope is contended, not suppressed" - ); - - let claude_post = json!({ - "hook_event_name": "PostToolUse", - "session_id": claude_session, - "cwd": cwd, - "tool_name": "Bash", - "tool_use_id": "claude-pi-overlap-bash", - }); - claude_mutation_scope::run_claude_mutation_scope_from_payload_at_state_root( - &repo.state_root, - &claude_post.to_string(), - None, - ) - .expect("Claude PostToolUse should close its own scope"); - } - - #[test] - fn pi_and_codex_overlap_stays_ineligible_until_codex_confirms() { - let repo = ProvenanceE2eRepo::new("pi-codex-overlap"); - let pi_session = "ses-pi-overlap"; - let codex_session = "codex-pi-overlap-session"; - let cwd = repo.cwd(); - - drive_pi( - &repo, - &pi_tool_call( - &cwd, - pi_session, - "call-pi", - "write", - Some("anthropic/opus-5"), - ), - ) - .expect("Pi write should establish a scope"); - - let codex_pre = json!({ - "hook_event_name": "PreToolUse", - "session_id": codex_session, - "turn_id": "codex-pi-overlap-turn", - "cwd": cwd, - "tool_name": "Bash", - "tool_use_id": "codex-pi-overlap-bash", - "model": "gpt-5.6-sol", - "tool_input": {"command": "true"}, - }); - codex_mutation_scope::run_codex_mutation_scope_from_payload_at_state_root( - &repo.state_root, - &codex_pre.to_string(), - None, - ) - .expect("Codex Bash PreToolUse should establish a concurrent scope"); - - repo.write_change("one\npi codex overlap mutation\n"); - - drive_pi(&repo, &pi_tool_result(&cwd, pi_session, "call-pi", "write")) - .expect("Pi ToolResult should mark the attempt executed"); - drive_pi( - &repo, - &pi_tool_execution_end(&cwd, pi_session, "call-pi", "write"), - ) - .expect("Pi ToolExecutionEnd should attempt to confirm its own scope"); - - let attribution_after_first_close = repo.mutation_events(); - assert_eq!( - attribution_after_first_close - .last() - .map(|(kind, _)| kind.as_str()), - Some("ineligible_unscoped"), - "an unconfirmed live Codex scope must suppress Pi's own confirming close" - ); - - let codex_post = json!({ - "hook_event_name": "PostToolUse", - "session_id": codex_session, - "turn_id": "codex-pi-overlap-turn", - "cwd": cwd, - "tool_name": "Bash", - "tool_use_id": "codex-pi-overlap-bash", - }); - codex_mutation_scope::run_codex_mutation_scope_from_payload_at_state_root( - &repo.state_root, - &codex_post.to_string(), - None, - ) - .expect("Codex PostToolUse should close its own scope"); - - drive_pi( - &repo, - &pi_tool_call( - &cwd, - pi_session, - "call-pi-2", - "write", - Some("anthropic/opus-5"), - ), - ) - .expect("a fresh Pi write should establish a new scope"); - repo.write_change("one\npi codex overlap mutation\nsecond change\n"); - drive_pi( - &repo, - &pi_tool_result(&cwd, pi_session, "call-pi-2", "write"), - ) - .expect("Pi ToolResult should mark the fresh attempt executed"); - drive_pi( - &repo, - &pi_tool_execution_end(&cwd, pi_session, "call-pi-2", "write"), - ) - .expect("the fresh Pi scope should close cleanly once Codex is confirmed"); - - let attribution_after_second_close = repo.mutation_events(); - assert_eq!( - attribution_after_second_close - .last() - .map(|(kind, _)| kind.as_str()), - Some("ai_exclusive"), - "once every other live scope is confirmation-safe, a solo confirming close is AiExclusive" - ); - } - - #[test] - fn pi_and_opencode_overlap_stays_ineligible_until_opencode_confirms() { - let repo = ProvenanceE2eRepo::new("pi-opencode-overlap"); - let pi_session = "ses-pi-oc-overlap"; - let oc_session = "ses_opencode_pi_overlap"; - let cwd = repo.cwd(); - - drive_pi( - &repo, - &pi_tool_call( - &cwd, - pi_session, - "call-pi", - "write", - Some("anthropic/opus-5"), - ), - ) - .expect("Pi write should establish a scope"); - - drive_opencode( - &repo, - &opencode_before( - &cwd, - oc_session, - "call_oc", - "write", - Some("opencode/big-pickle"), - ), - ) - .expect("OpenCode write ToolExecuteBefore should establish a concurrent scope"); - - repo.write_change("one\npi opencode overlap mutation\n"); - - drive_pi(&repo, &pi_tool_result(&cwd, pi_session, "call-pi", "write")) - .expect("Pi ToolResult should mark the attempt executed"); - drive_pi( - &repo, - &pi_tool_execution_end(&cwd, pi_session, "call-pi", "write"), - ) - .expect("Pi ToolExecutionEnd should attempt to confirm its own scope"); - - let attribution_after_first_close = repo.mutation_events(); - assert_eq!( - attribution_after_first_close - .last() - .map(|(kind, _)| kind.as_str()), - Some("ineligible_unscoped"), - "an unconfirmed live OpenCode scope must suppress Pi's own confirming close" - ); - - drive_opencode(&repo, &opencode_after(&cwd, oc_session, "call_oc", "write")) - .expect("OpenCode ToolExecuteAfter should close its own scope"); - - drive_pi( - &repo, - &pi_tool_call( - &cwd, - pi_session, - "call-pi-2", - "write", - Some("anthropic/opus-5"), - ), - ) - .expect("a fresh Pi write should establish a new scope"); - repo.write_change("one\npi opencode overlap mutation\nsecond change\n"); - drive_pi( - &repo, - &pi_tool_result(&cwd, pi_session, "call-pi-2", "write"), - ) - .expect("Pi ToolResult should mark the fresh attempt executed"); - drive_pi( - &repo, - &pi_tool_execution_end(&cwd, pi_session, "call-pi-2", "write"), - ) - .expect("the fresh Pi scope should close cleanly once OpenCode is confirmed"); - - let attribution_after_second_close = repo.mutation_events(); - assert_eq!( - attribution_after_second_close - .last() - .map(|(kind, _)| kind.as_str()), - Some("ai_exclusive"), - "once every other live scope is confirmation-safe, a solo confirming close is AiExclusive" - ); - } - - #[test] - fn pi_stale_process_recovery_discards_ambiguous_interval_while_fresh_pi_work_remains_usable( - ) { - let repo = ProvenanceE2eRepo::new("pi-stale-recovery"); - let stale_session = "ses-pi-stale"; - let fresh_session = "ses-pi-fresh"; - let cwd = repo.cwd(); - - drive_pi( - &repo, - &pi_tool_call( - &cwd, - stale_session, - "call-stale", - "bash", - Some("anthropic/opus-5"), - ), - ) - .expect("the stale attempt's Pi ToolCall should establish a scope"); - - let git_dir = resolve_git_dir(&repo.root).expect("git dir should resolve"); - let scope_id = pi_mutation_scope::state::read_state(&git_dir) - .expect("state should be readable") - .attempts - .iter() - .find(|attempt| attempt.session_id == stale_session) - .expect("the stale attempt should exist") - .scope_id - .clone(); - pi_mutation_scope::force_attempt_owner_dead_for_tests(&git_dir, &scope_id); - - fs::write( - repo.root.join("ambiguous.txt"), - "left behind by the dead Pi process\n", - ) - .expect("the stale attempt's own mutation should still land on disk"); - - drive_pi( - &repo, - &pi_tool_call( - &cwd, - fresh_session, - "call-fresh", - "write", - Some("anthropic/opus-5"), - ), - ) - .expect("a fresh Pi ToolCall should trigger dead-owner recovery and then establish its own scope"); - - fs::write(repo.root.join("confirmed.txt"), "the fresh Pi work\n") - .expect("the fresh attempt's mutation should write"); - - drive_pi( - &repo, - &pi_tool_result(&cwd, fresh_session, "call-fresh", "write"), - ) - .expect("the fresh attempt's ToolResult should mark it executed"); - drive_pi( - &repo, - &pi_tool_execution_end(&cwd, fresh_session, "call-fresh", "write"), - ) - .expect("the fresh attempt should close and reach AiExclusive"); - - git(&repo.root, &["add", "-A"]); - git(&repo.root, &["commit", "-qm", "stale recovery"]); - - let db = repo.db(); - let post_commit_data = capture_post_commit_patch_from_git(&repo.root) - .expect("capturing the post-commit patch should succeed"); - let mutation_ai_patch = resolve_post_commit_mutation_ai_patch( - &repo.root, - &db, - &ParsedPatch { files: Vec::new() }, - &post_commit_data.parsed_patch, - ); - - let ai_paths: Vec<&str> = mutation_ai_patch - .files - .iter() - .map(|file| file.new_path.as_str()) - .collect(); - assert!( - !ai_paths.contains(&"ambiguous.txt"), - "the dead process's ambiguous interval must never be attributed AI" - ); - assert!( - ai_paths.contains(&"confirmed.txt"), - "later fresh Pi work must remain usable and reach AiExclusive" - ); - - let attribution = repo.mutation_events(); - assert_eq!( - attribution.last().map(|(kind, _)| kind.as_str()), - Some("ai_exclusive"), - "the fresh attempt, unencumbered by the recovered stale scope, should reach AiExclusive" - ); - } - } - - #[test] - fn post_commit_auto_sync_does_not_launch_when_disabled() { - let launch_called = RefCell::new(false); - - run_post_commit_subcommand_with( - Path::new("/repo"), - None, - "", - |_| Ok(post_commit_flow_result()), - |_, _, _, _| Ok(minimal_agent_trace()), - |_| Ok(false), - |_| { - *launch_called.borrow_mut() = true; - Ok(()) - }, - |_| Ok(()), - None, - ) - .expect("disabled auto-sync should not affect post-commit success"); - - assert!(!*launch_called.borrow()); - } - - #[test] - fn post_commit_persistence_failure_does_not_launch_auto_sync() { - let launch_called = RefCell::new(false); - - let error = run_post_commit_subcommand_with( - Path::new("/repo"), - None, - "", - |_| Ok(post_commit_flow_result()), - |_, _, _, _| Err(anyhow!("Agent Trace persistence failed")), - |_| panic!("auto-sync config must not be resolved after persistence failure"), - |_| { - *launch_called.borrow_mut() = true; - Ok(()) - }, - |_| panic!("checkpoint must not run after persistence failure"), - None, - ) - .expect_err("persistence failure should be returned"); - - assert!(error.to_string().contains("persistence failed")); - assert!(!*launch_called.borrow()); - } - - #[test] - fn post_commit_auto_sync_launcher_failure_is_fail_open() { - let output = run_post_commit_subcommand_with( - Path::new("/repo"), - None, - "", - |_| Ok(post_commit_flow_result()), - |_, _, _, _| Ok(minimal_agent_trace()), - |_| Ok(true), - |_| Err(anyhow!("spawn unavailable")), - |_| Ok(()), - None, - ) - .expect("launcher failure must not affect post-commit success"); - - assert!(output.contains("post-commit hook processed intersection")); - } - - #[derive(Default)] - struct RecordingLogger { - warnings: std::sync::Mutex>, - } - - impl Logger for RecordingLogger { - fn info( - &self, - _event_id: &str, - _message: &str, - _fields: &[(&str, &str)], - _session_id: Option<&str>, - ) { - } - - fn debug( - &self, - _event_id: &str, - _message: &str, - _fields: &[(&str, &str)], - _session_id: Option<&str>, - ) { - } - - fn warn( - &self, - event_id: &str, - message: &str, - _fields: &[(&str, &str)], - _session_id: Option<&str>, - ) { - self.warnings - .lock() - .expect("warnings mutex should not be poisoned") - .push((event_id.to_string(), message.to_string())); - } - - fn error( - &self, - _event_id: &str, - _message: &str, - _fields: &[(&str, &str)], - _session_id: Option<&str>, - ) { - } - - fn log_cli_error( - &self, - _error: &crate::services::error::CliError, - _session_id: Option<&str>, - ) { - } - } - - #[test] - fn post_commit_checkpoint_runs_once_after_successful_persistence() { - let events = RefCell::new(Vec::new()); - - let output = run_post_commit_subcommand_with( - Path::new("/repo"), - None, - "", - |_| Ok(post_commit_flow_result()), - |_, _, _, _| { - events.borrow_mut().push("persistence"); - Ok(minimal_agent_trace()) - }, - |_| Ok(false), - |_| Ok(()), - |_| { - events.borrow_mut().push("checkpoint"); - Ok(()) - }, - None, - ) - .expect("successful checkpoint should not affect post-commit success"); - - assert!(output.contains("post-commit hook processed intersection")); - assert_eq!(events.into_inner(), vec!["persistence", "checkpoint"]); - } - - #[test] - fn post_commit_checkpoint_failure_is_fail_open_and_logs_warning() { - let logger = RecordingLogger::default(); - let persisted = RefCell::new(false); - - let output = run_post_commit_subcommand_with( - Path::new("/repo"), - None, - "", - |_| Ok(post_commit_flow_result()), - |_, _, _, _| { - *persisted.borrow_mut() = true; - Ok(minimal_agent_trace()) - }, - |_| Ok(false), - |_| Ok(()), - |_| Err(anyhow!("checkpoint failed")), - Some(&logger), - ) - .expect("checkpoint failure must not affect post-commit success"); - - assert!(output.contains("post-commit hook processed intersection")); - assert!(*persisted.borrow()); - assert_eq!( - logger - .warnings - .into_inner() - .expect("warnings mutex should not be poisoned"), - vec![( - String::from("sce.agent_trace_db.passive_checkpoint_failed"), - String::from("checkpoint failed") - )] - ); - } -} diff --git a/cli/src/services/hooks/mutation_scope_health.rs b/cli/src/services/hooks/mutation_scope_health.rs index 783f2eb3f..2fbd2ec26 100644 --- a/cli/src/services/hooks/mutation_scope_health.rs +++ b/cli/src/services/hooks/mutation_scope_health.rs @@ -9,6 +9,13 @@ pub(crate) enum MutationScopeHealthStatus { Invalid, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[allow(dead_code)] +pub(crate) enum Repairability { + AutoFixable, + ManualOnly, +} + #[derive(Clone, Debug, Eq, PartialEq)] #[allow(dead_code)] pub(crate) struct MutationScopeAdapterHealth { @@ -63,6 +70,12 @@ mod tests { ); } + #[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( diff --git a/cli/src/services/hooks/pi_mutation_scope/process_owner.rs b/cli/src/services/hooks/mutation_scope_owner.rs similarity index 98% rename from cli/src/services/hooks/pi_mutation_scope/process_owner.rs rename to cli/src/services/hooks/mutation_scope_owner.rs index bb252b01f..73f466406 100644 --- a/cli/src/services/hooks/pi_mutation_scope/process_owner.rs +++ b/cli/src/services/hooks/mutation_scope_owner.rs @@ -83,7 +83,7 @@ mod tests { #[test] fn no_ttl_or_elapsed_time_primitive_is_used_by_this_module() { - let source = include_str!("process_owner.rs"); + let source = include_str!("mutation_scope_owner.rs"); let production_source = source .split_once("#[cfg(test)]") .expect("this module has a #[cfg(test)] boundary") diff --git a/cli/src/services/hooks/opencode_mutation_scope/events.rs b/cli/src/services/hooks/opencode_mutation_scope/events.rs new file mode 100644 index 000000000..132b7d682 --- /dev/null +++ b/cli/src/services/hooks/opencode_mutation_scope/events.rs @@ -0,0 +1,316 @@ +use anyhow::{anyhow, bail, Context, Result}; +use serde_json::{Map, Value}; + +use crate::services::hooks::{ + normalize_opencode_model_id, prefixed_diff_trace_session_id, OPENCODE_TOOL_NAME, +}; + +pub(super) const HOOK_EVENT_NAME_FIELD: &str = "hook_event_name"; +pub(super) const SESSION_ID_FIELD: &str = "session_id"; +pub(super) const CALL_ID_FIELD: &str = "call_id"; +pub(super) const CWD_FIELD: &str = "cwd"; +pub(super) const TOOL_NAME_FIELD: &str = "tool_name"; +pub(super) const MODEL_FIELD: &str = "model"; + +pub(super) const HOOK_EVENT_TOOL_EXECUTE_BEFORE: &str = "ToolExecuteBefore"; +pub(super) const HOOK_EVENT_SHELL_ENV: &str = "ShellEnv"; +pub(super) const HOOK_EVENT_TOOL_EXECUTE_AFTER: &str = "ToolExecuteAfter"; +pub(super) const HOOK_EVENT_TOOL_ERROR: &str = "ToolError"; +pub(super) const HOOK_EVENT_SESSION_IDLE: &str = "SessionIdle"; +pub(super) const HOOK_EVENT_SESSION_ERROR: &str = "SessionError"; +pub(super) const HOOK_EVENT_SESSION_DELETED: &str = "SessionDeleted"; +pub(super) const HOOK_EVENT_SERVER_DISPOSED: &str = "ServerDisposed"; + +pub(super) const OPENCODE_TRACKED_TOOL_BASH: &str = "bash"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum OpenCodeHookEvent { + ToolExecuteBefore(OpenCodeToolExecution), + ShellEnv(OpenCodeShellStart), + ToolExecuteAfter(OpenCodeToolIdentity), + ToolError(OpenCodeCallIdentity), + SessionIdle(OpenCodeSessionIdentity), + SessionError(OpenCodeSessionIdentity), + SessionDeleted(OpenCodeSessionIdentity), + ServerDisposed(OpenCodeWorkspaceIdentity), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct OpenCodeCallIdentity { + pub session_id: String, + pub call_id: String, + pub cwd: String, + pub tool_name: String, +} + +impl OpenCodeCallIdentity { + pub(crate) fn attempt_key(&self) -> AttemptKey { + AttemptKey { + session_id: self.session_id.clone(), + call_id: self.call_id.clone(), + } + } + + pub(crate) fn classification(&self) -> ToolClassification { + classify_tool(&self.tool_name) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct OpenCodeToolIdentity { + pub session_id: String, + pub call_id: String, + pub cwd: String, + pub tool_name: String, +} + +impl OpenCodeToolIdentity { + pub(crate) fn attempt_key(&self) -> AttemptKey { + AttemptKey { + session_id: self.session_id.clone(), + call_id: self.call_id.clone(), + } + } + + pub(crate) fn classification(&self) -> ToolClassification { + classify_tool(&self.tool_name) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct OpenCodeToolExecution { + pub identity: OpenCodeToolIdentity, + pub model: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct OpenCodeShellStart { + pub session_id: String, + pub call_id: String, + pub cwd: String, + pub model: Option, +} + +impl OpenCodeShellStart { + pub(crate) fn attempt_key(&self) -> AttemptKey { + AttemptKey { + session_id: self.session_id.clone(), + call_id: self.call_id.clone(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct OpenCodeSessionIdentity { + pub session_id: String, + pub cwd: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct OpenCodeWorkspaceIdentity { + pub cwd: String, +} + +#[allow(clippy::struct_field_names)] +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub(crate) struct AttemptKey { + pub session_id: String, + pub call_id: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ToolClassification { + TrackedMutation, + Delegation, + Untracked, +} + +pub(super) const TRACKED_MUTATION_TOOL_NAMES: &[&str] = &["bash", "write", "edit", "apply_patch"]; +pub(super) const DELEGATION_TOOL_NAMES: &[&str] = &["task"]; + +pub(crate) fn classify_tool(tool_name: &str) -> ToolClassification { + if TRACKED_MUTATION_TOOL_NAMES.contains(&tool_name) { + ToolClassification::TrackedMutation + } else if DELEGATION_TOOL_NAMES.contains(&tool_name) { + ToolClassification::Delegation + } else { + ToolClassification::Untracked + } +} + +pub(super) const OPENCODE_SCOPE_ID_SCHEME: &str = "oc-tool-v1"; + +pub(crate) fn format_opencode_scope_id(key: &AttemptKey) -> String { + format!( + "{OPENCODE_SCOPE_ID_SCHEME}|s={}:{}|c={}:{}", + key.session_id.len(), + key.session_id, + key.call_id.len(), + key.call_id, + ) +} + +pub(crate) fn opencode_scope_start_event_id(scope_id: &str) -> String { + format!("{scope_id}|start") +} + +pub(crate) fn opencode_scope_close_event_id(scope_id: &str) -> String { + format!("{scope_id}|close") +} + +pub(super) const ACTOR_KIND_OPENCODE: &str = "opencode"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct OpenCodeScopeProvenance { + pub session_id: String, + pub model_id: Option, +} + +pub(crate) fn opencode_scope_provenance( + session_id: &str, + model: Option<&str>, +) -> OpenCodeScopeProvenance { + OpenCodeScopeProvenance { + session_id: prefixed_diff_trace_session_id(OPENCODE_TOOL_NAME, session_id), + model_id: model.and_then(normalize_opencode_model_id), + } +} + +pub(crate) fn parse_opencode_hook_event(stdin_payload: &str) -> Result { + if stdin_payload.trim().is_empty() { + bail!(validation_error( + "expected a JSON object, got an empty payload" + )); + } + + let parsed: Value = serde_json::from_str(stdin_payload) + .with_context(|| validation_error("expected valid JSON"))?; + let object = parsed + .as_object() + .ok_or_else(|| anyhow!(validation_error("expected a JSON object")))?; + + let hook_event_name = required_non_blank_str(object, HOOK_EVENT_NAME_FIELD)?; + + match hook_event_name.as_str() { + HOOK_EVENT_TOOL_EXECUTE_BEFORE => { + parse_tool_execution(object).map(OpenCodeHookEvent::ToolExecuteBefore) + } + HOOK_EVENT_SHELL_ENV => parse_shell_start(object).map(OpenCodeHookEvent::ShellEnv), + HOOK_EVENT_TOOL_EXECUTE_AFTER => { + parse_tool_identity(object).map(OpenCodeHookEvent::ToolExecuteAfter) + } + HOOK_EVENT_TOOL_ERROR => parse_call_identity(object).map(OpenCodeHookEvent::ToolError), + HOOK_EVENT_SESSION_IDLE => { + parse_session_identity(object).map(OpenCodeHookEvent::SessionIdle) + } + HOOK_EVENT_SESSION_ERROR => { + parse_session_identity(object).map(OpenCodeHookEvent::SessionError) + } + HOOK_EVENT_SESSION_DELETED => { + parse_session_identity(object).map(OpenCodeHookEvent::SessionDeleted) + } + HOOK_EVENT_SERVER_DISPOSED => { + parse_workspace_identity(object).map(OpenCodeHookEvent::ServerDisposed) + } + other => bail!(validation_error(&format!( + "unsupported hook_event_name '{other}'" + ))), + } +} + +fn parse_tool_identity(object: &Map) -> Result { + Ok(OpenCodeToolIdentity { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + call_id: required_non_blank_str(object, CALL_ID_FIELD)?, + cwd: required_non_blank_str(object, CWD_FIELD)?, + tool_name: required_non_blank_str(object, TOOL_NAME_FIELD)?, + }) +} + +fn parse_tool_execution(object: &Map) -> Result { + Ok(OpenCodeToolExecution { + identity: parse_tool_identity(object)?, + model: optional_non_blank_str(object, MODEL_FIELD)?, + }) +} + +fn parse_shell_start(object: &Map) -> Result { + Ok(OpenCodeShellStart { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + call_id: required_non_blank_str(object, CALL_ID_FIELD)?, + cwd: required_non_blank_str(object, CWD_FIELD)?, + model: optional_non_blank_str(object, MODEL_FIELD)?, + }) +} + +fn parse_call_identity(object: &Map) -> Result { + Ok(OpenCodeCallIdentity { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + call_id: required_non_blank_str(object, CALL_ID_FIELD)?, + cwd: required_non_blank_str(object, CWD_FIELD)?, + tool_name: required_non_blank_str(object, TOOL_NAME_FIELD)?, + }) +} + +fn parse_session_identity(object: &Map) -> Result { + Ok(OpenCodeSessionIdentity { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + cwd: required_non_blank_str(object, CWD_FIELD)?, + }) +} + +fn parse_workspace_identity(object: &Map) -> Result { + Ok(OpenCodeWorkspaceIdentity { + cwd: required_non_blank_str(object, CWD_FIELD)?, + }) +} + +fn required_field<'a>(object: &'a Map, field: &str) -> Result<&'a Value> { + object.get(field).ok_or_else(|| { + anyhow!(validation_error(&format!( + "missing required field '{field}'" + ))) + }) +} + +fn required_str(object: &Map, field: &str) -> Result { + required_field(object, field)? + .as_str() + .map(str::to_owned) + .ok_or_else(|| { + anyhow!(validation_error(&format!( + "field '{field}' must be a string" + ))) + }) +} + +fn required_non_blank_str(object: &Map, field: &str) -> Result { + let value = required_str(object, field)?; + if value.trim().is_empty() { + bail!(validation_error(&format!( + "field '{field}' must be a non-blank string" + ))); + } + Ok(value) +} + +fn optional_non_blank_str(object: &Map, field: &str) -> Result> { + match object.get(field) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) => { + if value.trim().is_empty() { + bail!(validation_error(&format!( + "field '{field}' must be null, absent, or a non-blank string" + ))); + } + Ok(Some(value.clone())) + } + Some(_) => bail!(validation_error(&format!( + "field '{field}' must be null, absent, or a non-blank string" + ))), + } +} + +fn validation_error(detail: &str) -> String { + format!("Invalid OpenCode hook event payload from STDIN: {detail}.") +} diff --git a/cli/src/services/hooks/opencode_mutation_scope/health.rs b/cli/src/services/hooks/opencode_mutation_scope/health.rs index f861646b2..45dbfaef9 100644 --- a/cli/src/services/hooks/opencode_mutation_scope/health.rs +++ b/cli/src/services/hooks/opencode_mutation_scope/health.rs @@ -3,10 +3,43 @@ use std::path::Path; use crate::services::hooks::mutation_scope_health::{ MutationScopeAdapterHealth, MutationScopeHealthStatus, }; +use crate::services::hooks::mutation_scope_owner::is_definitely_dead; use crate::services::mutation_trace::types::ActorKind; use super::state::{self, AttemptPhase, RecoveryState}; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum Repairability { + AutoFixable, + ManualOnly, +} + +pub(crate) fn assess_repairability(git_dir: &Path) -> Repairability { + let Ok(state) = state::read_state(git_dir) else { + return Repairability::ManualOnly; + }; + + let pending_start: Vec<&state::AdapterAttempt> = state + .attempts + .iter() + .filter(|attempt| attempt.phase == AttemptPhase::PendingStart) + .collect(); + + if pending_start.is_empty() { + return Repairability::ManualOnly; + } + + let every_owner_is_positively_dead = pending_start + .iter() + .all(|attempt| attempt.owner.as_ref().is_some_and(is_definitely_dead)); + + if every_owner_is_positively_dead { + Repairability::AutoFixable + } else { + Repairability::ManualOnly + } +} + pub(crate) fn classify_health(git_dir: &Path) -> MutationScopeAdapterHealth { let state = match state::read_state(git_dir) { Ok(state) => state, @@ -66,7 +99,8 @@ mod tests { use serde_json::{json, Value}; - use super::super::{run_opencode_mutation_scope_from_payload_with_seams, AttemptKey}; + use super::super::events::AttemptKey; + use super::super::lifecycle::run_opencode_mutation_scope_from_payload_with_seams; use super::*; use crate::services::observability::traits::Logger; @@ -382,12 +416,6 @@ mod tests { .find(|a| a.call_id == "call-a") .expect("A is tracked"); - // Simulate a crash between begin_terminal_cleanup arming Flushing and - // resolve_recovery ever running: this is the only way to observe a - // literal orphaned `Flushing` at rest, since every in-process caller of - // begin_terminal_cleanup always calls resolve_recovery immediately - // afterward under the same boundary lock. A real process crash at this - // exact point is a legitimately persisted, production-reachable shape. state::begin_terminal_cleanup(&git_dir, std::slice::from_ref(&doomed.scope_id)) .expect("seeding an orphaned flush should succeed"); let seeded = state::read_state(&git_dir).expect("state readable"); @@ -648,6 +676,7 @@ mod tests { call_id: key.call_id, tool_name: "write".to_string(), phase, + owner: None, } } @@ -720,4 +749,364 @@ mod tests { remove_test_git_dir(&git_dir); } } + + fn dead_owner() -> crate::services::hooks::mutation_scope_owner::ProcessOwner { + let mut child = std::process::Command::new("true") + .spawn() + .expect("spawning 'true' should succeed"); + let pid = i32::try_from(child.id()).expect("pid fits in i32"); + child.wait().expect("child should exit and be reaped"); + crate::services::hooks::mutation_scope_owner::ProcessOwner { + pid, + instance_token: None, + } + } + + fn live_owner() -> crate::services::hooks::mutation_scope_owner::ProcessOwner { + crate::services::hooks::mutation_scope_owner::current_process_owner() + } + + fn seed_pending_start_with_owner( + git_dir: &Path, + call_id: &str, + owner: Option, + ) -> state::AdapterAttempt { + let attempt = state::seed_attempt_for_tests( + git_dir, + &key(call_id), + "write", + AttemptPhase::PendingStart, + ); + state::set_attempt_owner_for_tests(git_dir, &attempt.scope_id, owner) + } + + #[test] + fn assess_repairability_is_manual_only_when_the_adapter_is_not_blocked() { + let git_dir = unique_test_git_dir("assess-not-blocked"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + assert_eq!(assess_repairability(&git_dir), Repairability::ManualOnly); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn assess_repairability_is_manual_only_for_a_legacy_pending_start_attempt_with_no_recorded_owner( + ) { + let git_dir = unique_test_git_dir("assess-legacy-no-owner"); + 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_start", + }], + }) + .to_string(), + ) + .expect("legacy state file with no owner field should be writable"); + + assert_eq!( + classify_health(&git_dir).status, + MutationScopeHealthStatus::Blocked, + "a legacy state file predating owner evidence must still classify Blocked" + ); + assert_eq!( + assess_repairability(&git_dir), + Repairability::ManualOnly, + "no recorded owner is never treated as proof of death" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn assess_repairability_is_manual_only_when_the_pending_start_owner_is_live() { + let git_dir = unique_test_git_dir("assess-live-owner"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + seed_pending_start_with_owner(&git_dir, "call-1", Some(live_owner())); + + assert_eq!(assess_repairability(&git_dir), Repairability::ManualOnly); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn assess_repairability_is_auto_fixable_when_every_pending_start_owner_is_positively_dead() { + let git_dir = unique_test_git_dir("assess-dead-owner"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + seed_pending_start_with_owner(&git_dir, "call-1", Some(dead_owner())); + + assert_eq!(assess_repairability(&git_dir), Repairability::AutoFixable); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn assess_repairability_is_manual_only_when_one_of_several_pending_start_owners_is_live() { + let git_dir = unique_test_git_dir("assess-mixed-owners"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + seed_pending_start_with_owner(&git_dir, "call-dead", Some(dead_owner())); + seed_pending_start_with_owner(&git_dir, "call-live", Some(live_owner())); + + assert_eq!( + assess_repairability(&git_dir), + Repairability::ManualOnly, + "every contributing PendingStart attempt must have a proven-dead owner" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn repair_blocked_clears_a_dead_owner_pending_start_end_to_end() { + let git_dir = unique_test_git_dir("repair-end-to-end"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + seed_pending_start_with_owner(&git_dir, "call-1", Some(dead_owner())); + + assert_eq!( + classify_health(&git_dir).status, + MutationScopeHealthStatus::Blocked + ); + + let healthy = RecordingSeam::new(); + let repository_root = Path::new(CWD); + let outcome = super::super::lifecycle::repair_blocked( + &git_dir, + repository_root, + None, + &|_root: &Path, payload: &str, _logger: Option<&dyn Logger>| healthy.handle(payload), + ) + .expect("repair should not error"); + + assert_eq!(outcome, super::super::lifecycle::RepairOutcome::Repaired); + let final_status = classify_health(&git_dir).status; + assert!( + matches!( + final_status, + MutationScopeHealthStatus::Healthy | MutationScopeHealthStatus::Recovering + ), + "a reported repair must never leave the final health Blocked: {final_status:?}" + ); + assert!(state::read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn repair_blocked_is_a_safe_no_op_when_the_pending_start_owner_is_live() { + let git_dir = unique_test_git_dir("repair-live-owner-noop"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let attempt = seed_pending_start_with_owner(&git_dir, "call-1", Some(live_owner())); + + let seam = RecordingSeam::new(); + let repository_root = Path::new(CWD); + let outcome = super::super::lifecycle::repair_blocked( + &git_dir, + repository_root, + None, + &|_root: &Path, payload: &str, _logger: Option<&dyn Logger>| seam.handle(payload), + ) + .expect("repair should not error"); + + assert_eq!(outcome, super::super::lifecycle::RepairOutcome::NoOp); + assert!( + seam.calls.lock().expect("seam mutex").is_empty(), + "a live owner must never trigger any seam call" + ); + let state = state::read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].scope_id, attempt.scope_id); + assert_eq!(state.attempts[0].phase, AttemptPhase::PendingStart); + assert_eq!( + classify_health(&git_dir).status, + MutationScopeHealthStatus::Blocked + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn repair_blocked_refuses_to_abandon_an_attempt_a_concurrent_process_already_started_before_the_lock_is_acquired( + ) { + let git_dir = unique_test_git_dir("repair-concurrent-race"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + let attempt = seed_pending_start_with_owner(&git_dir, "call-1", Some(dead_owner())); + + assert_eq!(assess_repairability(&git_dir), Repairability::AutoFixable); + + state::mark_active(&git_dir, &attempt.scope_id) + .expect("simulating the concurrent owning process completing its own Start"); + + let seam = RecordingSeam::new(); + let repository_root = Path::new(CWD); + let outcome = super::super::lifecycle::repair_blocked( + &git_dir, + repository_root, + None, + &|_root: &Path, payload: &str, _logger: Option<&dyn Logger>| seam.handle(payload), + ) + .expect("repair should not error"); + + assert_eq!( + outcome, + super::super::lifecycle::RepairOutcome::NoOp, + "the fresh, lock-protected re-proof must refuse to act on state assessed before it changed" + ); + assert!( + seam.calls.lock().expect("seam mutex").is_empty(), + "no seam call may fire once the attempt is no longer PendingStart" + ); + let state = state::read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].phase, AttemptPhase::Active); + assert_eq!( + classify_health(&git_dir).status, + MutationScopeHealthStatus::Healthy, + "the concurrently-started attempt must be left exactly as the owning process left it" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn repair_blocked_is_all_or_nothing_when_auto_fixable_assessment_becomes_stale() { + let git_dir = unique_test_git_dir("repair-all-or-nothing-stale"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + + let attempt0 = seed_pending_start_with_owner(&git_dir, "call-0", Some(dead_owner())); + let attempt1 = seed_pending_start_with_owner(&git_dir, "call-1", Some(dead_owner())); + + assert_eq!( + assess_repairability(&git_dir), + Repairability::AutoFixable, + "doctor's initial, unlocked assessment sees both owners positively dead" + ); + + state::set_attempt_owner_for_tests(&git_dir, &attempt1.scope_id, Some(live_owner())); + + let seam = RecordingSeam::new(); + let repository_root = Path::new(CWD); + let outcome = super::super::lifecycle::repair_blocked( + &git_dir, + repository_root, + None, + &|_root: &Path, payload: &str, _logger: Option<&dyn Logger>| seam.handle(payload), + ) + .expect("repair should not error"); + + assert_eq!( + outcome, + super::super::lifecycle::RepairOutcome::NoOp, + "the fresh repair-time re-proof must reject the whole batch once any current \ + PendingStart owner is no longer positively dead" + ); + + let state = state::read_state(&git_dir).expect("state readable"); + assert_eq!( + state + .attempts + .iter() + .find(|a| a.scope_id == attempt0.scope_id) + .expect("attempt0 is still tracked") + .phase, + AttemptPhase::PendingStart, + "the still-dead attempt0 must not be transitioned to PendingAbandon when another \ + current blocker fails the all-dead proof" + ); + assert_eq!( + state + .attempts + .iter() + .find(|a| a.scope_id == attempt1.scope_id) + .expect("attempt1 is still tracked") + .phase, + AttemptPhase::PendingStart, + ); + + assert!( + state.recovery.is_clear(), + "a failed re-proof must perform no durable recovery transition: {:?}", + state.recovery + ); + + assert!( + seam.calls.lock().expect("seam mutex").is_empty(), + "the state transaction must return None before resolve_recovery is entered, so no \ + seam call (flush/abandon) may fire" + ); + + assert_eq!( + classify_health(&git_dir).status, + MutationScopeHealthStatus::Blocked, + "both attempts remain PendingStart, so the adapter stays Blocked" + ); + + remove_test_git_dir(&git_dir); + } + + #[test] + fn repair_blocked_interrupted_before_the_seam_resolves_leaves_state_the_ordinary_recovery_path_completes_without_duplication( + ) { + let git_dir = unique_test_git_dir("repair-interrupted-resume"); + std::fs::create_dir_all(&git_dir).expect("git dir should be created"); + seed_pending_start_with_owner(&git_dir, "call-1", Some(dead_owner())); + + let failing_abandon = RecordingSeam::failing_on(&["abandon"]); + let repository_root = Path::new(CWD); + let outcome = super::super::lifecycle::repair_blocked( + &git_dir, + repository_root, + None, + &|_root: &Path, payload: &str, _logger: Option<&dyn Logger>| { + failing_abandon.handle(payload) + }, + ) + .expect("repair should not error even though the seam abandon call fails"); + + assert_eq!(outcome, super::super::lifecycle::RepairOutcome::NoOp); + let interrupted = state::read_state(&git_dir).expect("state readable"); + assert_eq!(interrupted.attempts.len(), 1); + assert_eq!(interrupted.attempts[0].phase, AttemptPhase::PendingAbandon); + assert!( + matches!(interrupted.recovery, RecoveryState::Pending { .. }), + "a failed seam call during repair must relinquish Flushing back to Pending: {:?}", + interrupted.recovery + ); + assert_eq!( + classify_health(&git_dir).status, + MutationScopeHealthStatus::Recovering, + "an interrupted repair must never remain Blocked" + ); + + let healthy = RecordingSeam::new(); + drive(&git_dir, &healthy, &tool_before("write", "call-2")) + .expect("the ordinary recovery path resumes the interrupted repair automatically"); + + let resolved = state::read_state(&git_dir).expect("state readable"); + assert!(resolved.recovery.is_clear()); + assert_eq!( + resolved.attempts.len(), + 1, + "the interrupted repair's attempt must not be duplicated" + ); + assert_eq!(resolved.attempts[0].call_id, "call-2"); + assert_eq!( + classify_health(&git_dir).status, + MutationScopeHealthStatus::Healthy + ); + + remove_test_git_dir(&git_dir); + } } diff --git a/cli/src/services/hooks/opencode_mutation_scope/lifecycle.rs b/cli/src/services/hooks/opencode_mutation_scope/lifecycle.rs new file mode 100644 index 000000000..8f8a7492c --- /dev/null +++ b/cli/src/services/hooks/opencode_mutation_scope/lifecycle.rs @@ -0,0 +1,440 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, bail, Result}; + +use crate::services::hooks; +use crate::services::mutation_trace::runtime::resolve_git_dir; +use crate::services::observability::traits::Logger; + +use super::boundary_lock::{AdapterBoundaryLock, DEFAULT_BOUNDARY_LOCK_TIMEOUT}; +use super::events::{ + opencode_scope_close_event_id, opencode_scope_provenance, opencode_scope_start_event_id, + parse_opencode_hook_event, AttemptKey, OpenCodeHookEvent, OpenCodeScopeProvenance, + ToolClassification, OPENCODE_TRACKED_TOOL_BASH, +}; +use super::payload::{abandon_payload, flush_payload, scope_boundary_payload, scope_start_payload}; +use super::state::{self, AdmitDecision, RecoveryFlushCompletion}; + +pub(crate) fn run_opencode_mutation_scope_subcommand( + logger: Option<&dyn Logger>, +) -> Result { + let stdin_payload = hooks::read_hook_stdin()?; + run_opencode_mutation_scope_from_payload(&stdin_payload, logger) +} + +pub(crate) fn run_opencode_mutation_scope_from_payload( + stdin_payload: &str, + logger: Option<&dyn Logger>, +) -> Result { + let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); + let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { + hooks::mutation_scope::run_mutation_scope_from_payload(repository_root, payload, logger) + }; + + run_opencode_mutation_scope_from_payload_with_seams( + stdin_payload, + logger, + &resolve_git_dir_fn, + &seam_fn, + ) +} + +#[cfg(test)] +pub(crate) fn run_opencode_mutation_scope_from_payload_at_state_root( + state_root: &Path, + stdin_payload: &str, + logger: Option<&dyn Logger>, +) -> Result { + let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); + let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { + hooks::mutation_scope::run_mutation_scope_from_payload_at_state_root( + repository_root, + state_root, + payload, + logger, + ) + }; + + run_opencode_mutation_scope_from_payload_with_seams( + stdin_payload, + logger, + &resolve_git_dir_fn, + &seam_fn, + ) +} + +pub(super) type GitDirResolver<'a> = &'a dyn Fn(&str) -> Result; + +pub(super) type IngressSeam<'a> = &'a dyn Fn(&Path, &str, Option<&dyn Logger>) -> Result; + +pub(super) const FAIL_CLOSED_MESSAGE: &str = + "SCE could not establish OpenCode mutation attribution for this tool execution."; + +pub(super) const FAIL_CLOSED_EVENT: &str = "sce.hooks.opencode_mutation_scope.start_fail_closed"; + +pub(super) fn log_fail_closed(logger: Option<&dyn Logger>, context: &str, error: &anyhow::Error) { + if let Some(log) = logger { + log.warn( + FAIL_CLOSED_EVENT, + &error.to_string(), + &[("context", context)], + None, + ); + } +} + +pub(super) fn run_opencode_mutation_scope_from_payload_with_seams( + stdin_payload: &str, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, +) -> Result { + let event = parse_opencode_hook_event(stdin_payload)?; + dispatch_opencode_hook_event(event, logger, resolve_git_dir, seam) +} + +pub(super) fn dispatch_opencode_hook_event( + event: OpenCodeHookEvent, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, +) -> Result { + match event { + OpenCodeHookEvent::ToolExecuteBefore(execution) => { + match execution.identity.classification() { + ToolClassification::TrackedMutation => { + if execution.identity.tool_name == OPENCODE_TRACKED_TOOL_BASH { + return Ok(String::new()); + } + let provenance = opencode_scope_provenance( + &execution.identity.session_id, + execution.model.as_deref(), + ); + establish_tracked_start( + &execution.identity.cwd, + &execution.identity.attempt_key(), + &execution.identity.tool_name, + &provenance, + logger, + resolve_git_dir, + seam, + ) + } + ToolClassification::Delegation | ToolClassification::Untracked => Ok(String::new()), + } + } + OpenCodeHookEvent::ShellEnv(shell) => { + let provenance = opencode_scope_provenance(&shell.session_id, shell.model.as_deref()); + establish_tracked_start( + &shell.cwd, + &shell.attempt_key(), + OPENCODE_TRACKED_TOOL_BASH, + &provenance, + logger, + resolve_git_dir, + seam, + ) + } + OpenCodeHookEvent::ToolExecuteAfter(identity) => { + if !matches!( + identity.classification(), + ToolClassification::TrackedMutation + ) { + return Ok(String::new()); + } + let git_dir = resolve_git_dir(&identity.cwd)?; + let repository_root = Path::new(&identity.cwd); + let key = identity.attempt_key(); + with_boundary_lock(&git_dir, || { + state::normalize_recovery_after_boundary_lock_acquired(&git_dir)?; + handle_close(&git_dir, repository_root, &key, logger, seam) + }) + } + OpenCodeHookEvent::ToolError(call) => { + if !matches!(call.classification(), ToolClassification::TrackedMutation) { + return Ok(String::new()); + } + let git_dir = resolve_git_dir(&call.cwd)?; + let repository_root = Path::new(&call.cwd); + let key = call.attempt_key(); + with_boundary_lock(&git_dir, || { + state::normalize_recovery_after_boundary_lock_acquired(&git_dir)?; + abandon_and_consume(&git_dir, repository_root, logger, seam, |attempt| { + attempt.session_id == key.session_id && attempt.call_id == key.call_id + }) + }) + } + OpenCodeHookEvent::SessionIdle(_) + | OpenCodeHookEvent::SessionError(_) + | OpenCodeHookEvent::SessionDeleted(_) + | OpenCodeHookEvent::ServerDisposed(_) => Ok(String::new()), + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum RepairOutcome { + Repaired, + NoOp, +} + +pub(crate) fn repair_blocked( + git_dir: &Path, + repository_root: &Path, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result { + with_boundary_lock(git_dir, || { + state::normalize_recovery_after_boundary_lock_acquired(git_dir)?; + + let Some(generation) = state::reprove_dead_owner_pending_start_and_begin_repair(git_dir)? + else { + return Ok(RepairOutcome::NoOp); + }; + + match resolve_recovery(git_dir, repository_root, generation, logger, seam)? { + RecoveryResolution::Cleared => Ok(RepairOutcome::Repaired), + RecoveryResolution::Unresolved => Ok(RepairOutcome::NoOp), + } + }) +} + +pub(super) fn with_boundary_lock( + git_dir: &Path, + operation: impl FnOnce() -> Result, +) -> Result { + let _boundary = AdapterBoundaryLock::acquire(git_dir, DEFAULT_BOUNDARY_LOCK_TIMEOUT) + .map_err(|error| anyhow!("Failed to acquire adapter boundary lock: {error}"))?; + operation() +} + +pub(super) enum Admission { + Admitted(state::AllocatedAttempt), + Denied, +} + +pub(super) enum StartOutcome { + Established, + Denied, +} + +pub(super) fn establish_tracked_start( + cwd: &str, + key: &AttemptKey, + tool_name: &str, + provenance: &OpenCodeScopeProvenance, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, +) -> Result { + let git_dir = match resolve_git_dir(cwd) { + Ok(git_dir) => git_dir, + Err(error) => { + log_fail_closed(logger, "resolve_git_dir", &error); + return Err(error.context(FAIL_CLOSED_MESSAGE)); + } + }; + let repository_root = Path::new(cwd); + + let outcome = with_boundary_lock(&git_dir, || { + state::normalize_recovery_after_boundary_lock_acquired(&git_dir)?; + + match admit_or_recover(&git_dir, repository_root, key, tool_name, logger, seam)? { + Admission::Admitted(allocated) => { + establish_start( + &git_dir, + repository_root, + &allocated, + provenance, + logger, + seam, + )?; + Ok(StartOutcome::Established) + } + Admission::Denied => Ok(StartOutcome::Denied), + } + }); + + match outcome { + Ok(StartOutcome::Established) => Ok(String::new()), + Ok(StartOutcome::Denied) => bail!(FAIL_CLOSED_MESSAGE), + Err(error) => { + log_fail_closed(logger, "establish_tracked_start", &error); + Err(error.context(FAIL_CLOSED_MESSAGE)) + } + } +} + +pub(super) fn admit_or_recover( + git_dir: &Path, + repository_root: &Path, + key: &AttemptKey, + tool_name: &str, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result { + match state::admit_tracked_attempt(git_dir, key, tool_name)? { + AdmitDecision::Admitted(allocated) => Ok(Admission::Admitted(allocated)), + AdmitDecision::RecoveryBlocked + | AdmitDecision::UncertainAttemptBlocked + | AdmitDecision::TerminalAttemptBlocked => Ok(Admission::Denied), + AdmitDecision::FlushClaimed { generation } => { + match resolve_recovery(git_dir, repository_root, generation, logger, seam)? { + RecoveryResolution::Cleared => readmit_after_flush(git_dir, key, tool_name), + RecoveryResolution::Unresolved => Ok(Admission::Denied), + } + } + } +} + +pub(super) fn readmit_after_flush( + git_dir: &Path, + key: &AttemptKey, + tool_name: &str, +) -> Result { + match state::admit_tracked_attempt(git_dir, key, tool_name)? { + AdmitDecision::Admitted(allocated) => Ok(Admission::Admitted(allocated)), + AdmitDecision::FlushClaimed { generation } => { + state::relinquish_recovery_flush(git_dir, generation)?; + Ok(Admission::Denied) + } + AdmitDecision::RecoveryBlocked + | AdmitDecision::UncertainAttemptBlocked + | AdmitDecision::TerminalAttemptBlocked => Ok(Admission::Denied), + } +} + +pub(super) fn establish_start( + git_dir: &Path, + repository_root: &Path, + allocated: &state::AllocatedAttempt, + provenance: &OpenCodeScopeProvenance, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result<()> { + let scope_id = &allocated.attempt.scope_id; + + if allocated.reused && allocated.attempt.phase == state::AttemptPhase::Active { + return Ok(()); + } + + let start_payload = scope_start_payload( + scope_id, + &opencode_scope_start_event_id(scope_id), + provenance, + ); + + seam(repository_root, &start_payload, logger)?; + state::mark_active(git_dir, scope_id)?; + Ok(()) +} + +pub(super) fn handle_close( + git_dir: &Path, + repository_root: &Path, + key: &AttemptKey, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result { + let current = state::read_state(git_dir)?; + let Some(attempt) = current + .attempts + .iter() + .find(|attempt| attempt.session_id == key.session_id && attempt.call_id == key.call_id) + .cloned() + else { + return Ok(String::new()); + }; + + let doomed_scope_id = attempt.scope_id.clone(); + + if matches!( + attempt.phase, + state::AttemptPhase::PendingStart | state::AttemptPhase::PendingAbandon + ) { + return abandon_and_consume(git_dir, repository_root, logger, seam, move |candidate| { + candidate.scope_id == doomed_scope_id + }); + } + + let close_payload = scope_boundary_payload( + "close", + &attempt.scope_id, + &opencode_scope_close_event_id(&attempt.scope_id), + ); + + if seam(repository_root, &close_payload, logger).is_ok() { + state::remove_attempt(git_dir, &attempt.scope_id)?; + Ok(String::new()) + } else { + abandon_and_consume(git_dir, repository_root, logger, seam, move |candidate| { + candidate.scope_id == doomed_scope_id + }) + } +} + +pub(super) enum RecoveryResolution { + Cleared, + Unresolved, +} + +pub(super) fn abandon_and_consume( + git_dir: &Path, + repository_root: &Path, + logger: Option<&dyn Logger>, + seam: IngressSeam, + doomed: impl Fn(&state::AdapterAttempt) -> bool, +) -> Result { + let doomed_scope_ids: Vec = state::read_state(git_dir)? + .attempts + .into_iter() + .filter(|attempt| doomed(attempt)) + .map(|attempt| attempt.scope_id) + .collect(); + if doomed_scope_ids.is_empty() { + return Ok(String::new()); + } + + let generation = state::begin_terminal_cleanup(git_dir, &doomed_scope_ids)?; + resolve_recovery(git_dir, repository_root, generation, logger, seam)?; + Ok(String::new()) +} + +pub(super) fn resolve_recovery( + git_dir: &Path, + repository_root: &Path, + generation: u64, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result { + let pending_abandon: Vec = state::read_state(git_dir)? + .attempts + .into_iter() + .filter(|attempt| attempt.phase == state::AttemptPhase::PendingAbandon) + .collect(); + + if let Err(error) = seam(repository_root, &flush_payload(), logger) { + log_fail_closed(logger, "recovery_ambiguity_flush", &error); + state::relinquish_recovery_flush(git_dir, generation)?; + return Ok(RecoveryResolution::Unresolved); + } + + for attempt in &pending_abandon { + if let Err(error) = seam(repository_root, &abandon_payload(&attempt.scope_id), logger) { + log_fail_closed(logger, "recovery_abandon", &error); + state::relinquish_recovery_flush(git_dir, generation)?; + return Ok(RecoveryResolution::Unresolved); + } + state::remove_attempt(git_dir, &attempt.scope_id)?; + } + + if let Err(error) = seam(repository_root, &flush_payload(), logger) { + log_fail_closed(logger, "recovery_rebaseline_flush", &error); + state::relinquish_recovery_flush(git_dir, generation)?; + return Ok(RecoveryResolution::Unresolved); + } + + match state::complete_recovery_flush(git_dir, generation)? { + RecoveryFlushCompletion::Cleared => Ok(RecoveryResolution::Cleared), + RecoveryFlushCompletion::Superseded => Ok(RecoveryResolution::Unresolved), + } +} diff --git a/cli/src/services/hooks/opencode_mutation_scope/mod.rs b/cli/src/services/hooks/opencode_mutation_scope/mod.rs index 8a42f86b8..bae7d60bd 100644 --- a/cli/src/services/hooks/opencode_mutation_scope/mod.rs +++ b/cli/src/services/hooks/opencode_mutation_scope/mod.rs @@ -1,2740 +1,22 @@ #![allow(dead_code)] mod boundary_lock; +mod events; pub(crate) mod health; +mod lifecycle; mod os_lock; +mod payload; pub(crate) mod state; -use std::path::{Path, PathBuf}; - -use anyhow::{anyhow, bail, Context, Result}; -use serde_json::{json, Map, Value}; - -use crate::services::hooks::{ - normalize_opencode_model_id, prefixed_diff_trace_session_id, OPENCODE_TOOL_NAME, -}; -use crate::services::mutation_trace::runtime::resolve_git_dir; -use crate::services::observability::traits::Logger; - -use boundary_lock::{AdapterBoundaryLock, DEFAULT_BOUNDARY_LOCK_TIMEOUT}; -use state::{AdmitDecision, RecoveryFlushCompletion}; - -const HOOK_EVENT_NAME_FIELD: &str = "hook_event_name"; -const SESSION_ID_FIELD: &str = "session_id"; -const CALL_ID_FIELD: &str = "call_id"; -const CWD_FIELD: &str = "cwd"; -const TOOL_NAME_FIELD: &str = "tool_name"; -const MODEL_FIELD: &str = "model"; - -const HOOK_EVENT_TOOL_EXECUTE_BEFORE: &str = "ToolExecuteBefore"; -const HOOK_EVENT_SHELL_ENV: &str = "ShellEnv"; -const HOOK_EVENT_TOOL_EXECUTE_AFTER: &str = "ToolExecuteAfter"; -const HOOK_EVENT_TOOL_ERROR: &str = "ToolError"; -const HOOK_EVENT_SESSION_IDLE: &str = "SessionIdle"; -const HOOK_EVENT_SESSION_ERROR: &str = "SessionError"; -const HOOK_EVENT_SESSION_DELETED: &str = "SessionDeleted"; -const HOOK_EVENT_SERVER_DISPOSED: &str = "ServerDisposed"; - -const OPENCODE_TRACKED_TOOL_BASH: &str = "bash"; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) enum OpenCodeHookEvent { - ToolExecuteBefore(OpenCodeToolExecution), - ShellEnv(OpenCodeShellStart), - ToolExecuteAfter(OpenCodeToolIdentity), - ToolError(OpenCodeCallIdentity), - SessionIdle(OpenCodeSessionIdentity), - SessionError(OpenCodeSessionIdentity), - SessionDeleted(OpenCodeSessionIdentity), - ServerDisposed(OpenCodeWorkspaceIdentity), -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct OpenCodeCallIdentity { - pub session_id: String, - pub call_id: String, - pub cwd: String, - pub tool_name: String, -} - -impl OpenCodeCallIdentity { - pub(crate) fn attempt_key(&self) -> AttemptKey { - AttemptKey { - session_id: self.session_id.clone(), - call_id: self.call_id.clone(), - } - } - - pub(crate) fn classification(&self) -> ToolClassification { - classify_tool(&self.tool_name) - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct OpenCodeToolIdentity { - pub session_id: String, - pub call_id: String, - pub cwd: String, - pub tool_name: String, -} - -impl OpenCodeToolIdentity { - pub(crate) fn attempt_key(&self) -> AttemptKey { - AttemptKey { - session_id: self.session_id.clone(), - call_id: self.call_id.clone(), - } - } - - pub(crate) fn classification(&self) -> ToolClassification { - classify_tool(&self.tool_name) - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct OpenCodeToolExecution { - pub identity: OpenCodeToolIdentity, - pub model: Option, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct OpenCodeShellStart { - pub session_id: String, - pub call_id: String, - pub cwd: String, - pub model: Option, -} - -impl OpenCodeShellStart { - pub(crate) fn attempt_key(&self) -> AttemptKey { - AttemptKey { - session_id: self.session_id.clone(), - call_id: self.call_id.clone(), - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct OpenCodeSessionIdentity { - pub session_id: String, - pub cwd: String, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct OpenCodeWorkspaceIdentity { - pub cwd: String, -} - -#[allow(clippy::struct_field_names)] -#[derive(Clone, Debug, Eq, PartialEq, Hash)] -pub(crate) struct AttemptKey { - pub session_id: String, - pub call_id: String, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum ToolClassification { - TrackedMutation, - Delegation, - Untracked, -} - -const TRACKED_MUTATION_TOOL_NAMES: &[&str] = &["bash", "write", "edit", "apply_patch"]; -const DELEGATION_TOOL_NAMES: &[&str] = &["task"]; - -pub(crate) fn classify_tool(tool_name: &str) -> ToolClassification { - if TRACKED_MUTATION_TOOL_NAMES.contains(&tool_name) { - ToolClassification::TrackedMutation - } else if DELEGATION_TOOL_NAMES.contains(&tool_name) { - ToolClassification::Delegation - } else { - ToolClassification::Untracked - } -} - -const OPENCODE_SCOPE_ID_SCHEME: &str = "oc-tool-v1"; - -pub(crate) fn format_opencode_scope_id(key: &AttemptKey) -> String { - format!( - "{OPENCODE_SCOPE_ID_SCHEME}|s={}:{}|c={}:{}", - key.session_id.len(), - key.session_id, - key.call_id.len(), - key.call_id, - ) -} - -pub(crate) fn opencode_scope_start_event_id(scope_id: &str) -> String { - format!("{scope_id}|start") -} - -pub(crate) fn opencode_scope_close_event_id(scope_id: &str) -> String { - format!("{scope_id}|close") -} - -const ACTOR_KIND_OPENCODE: &str = "opencode"; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct OpenCodeScopeProvenance { - pub session_id: String, - pub model_id: Option, -} - -pub(crate) fn opencode_scope_provenance( - session_id: &str, - model: Option<&str>, -) -> OpenCodeScopeProvenance { - OpenCodeScopeProvenance { - session_id: prefixed_diff_trace_session_id(OPENCODE_TOOL_NAME, session_id), - model_id: model.and_then(normalize_opencode_model_id), - } -} - -pub(crate) fn parse_opencode_hook_event(stdin_payload: &str) -> Result { - if stdin_payload.trim().is_empty() { - bail!(validation_error( - "expected a JSON object, got an empty payload" - )); - } - - let parsed: Value = serde_json::from_str(stdin_payload) - .with_context(|| validation_error("expected valid JSON"))?; - let object = parsed - .as_object() - .ok_or_else(|| anyhow!(validation_error("expected a JSON object")))?; - - let hook_event_name = required_non_blank_str(object, HOOK_EVENT_NAME_FIELD)?; - - match hook_event_name.as_str() { - HOOK_EVENT_TOOL_EXECUTE_BEFORE => { - parse_tool_execution(object).map(OpenCodeHookEvent::ToolExecuteBefore) - } - HOOK_EVENT_SHELL_ENV => parse_shell_start(object).map(OpenCodeHookEvent::ShellEnv), - HOOK_EVENT_TOOL_EXECUTE_AFTER => { - parse_tool_identity(object).map(OpenCodeHookEvent::ToolExecuteAfter) - } - HOOK_EVENT_TOOL_ERROR => parse_call_identity(object).map(OpenCodeHookEvent::ToolError), - HOOK_EVENT_SESSION_IDLE => { - parse_session_identity(object).map(OpenCodeHookEvent::SessionIdle) - } - HOOK_EVENT_SESSION_ERROR => { - parse_session_identity(object).map(OpenCodeHookEvent::SessionError) - } - HOOK_EVENT_SESSION_DELETED => { - parse_session_identity(object).map(OpenCodeHookEvent::SessionDeleted) - } - HOOK_EVENT_SERVER_DISPOSED => { - parse_workspace_identity(object).map(OpenCodeHookEvent::ServerDisposed) - } - other => bail!(validation_error(&format!( - "unsupported hook_event_name '{other}'" - ))), - } -} - -fn parse_tool_identity(object: &Map) -> Result { - Ok(OpenCodeToolIdentity { - session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, - call_id: required_non_blank_str(object, CALL_ID_FIELD)?, - cwd: required_non_blank_str(object, CWD_FIELD)?, - tool_name: required_non_blank_str(object, TOOL_NAME_FIELD)?, - }) -} - -fn parse_tool_execution(object: &Map) -> Result { - Ok(OpenCodeToolExecution { - identity: parse_tool_identity(object)?, - model: optional_non_blank_str(object, MODEL_FIELD)?, - }) -} - -fn parse_shell_start(object: &Map) -> Result { - Ok(OpenCodeShellStart { - session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, - call_id: required_non_blank_str(object, CALL_ID_FIELD)?, - cwd: required_non_blank_str(object, CWD_FIELD)?, - model: optional_non_blank_str(object, MODEL_FIELD)?, - }) -} - -fn parse_call_identity(object: &Map) -> Result { - Ok(OpenCodeCallIdentity { - session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, - call_id: required_non_blank_str(object, CALL_ID_FIELD)?, - cwd: required_non_blank_str(object, CWD_FIELD)?, - tool_name: required_non_blank_str(object, TOOL_NAME_FIELD)?, - }) -} - -fn parse_session_identity(object: &Map) -> Result { - Ok(OpenCodeSessionIdentity { - session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, - cwd: required_non_blank_str(object, CWD_FIELD)?, - }) -} - -fn parse_workspace_identity(object: &Map) -> Result { - Ok(OpenCodeWorkspaceIdentity { - cwd: required_non_blank_str(object, CWD_FIELD)?, - }) -} - -fn required_field<'a>(object: &'a Map, field: &str) -> Result<&'a Value> { - object.get(field).ok_or_else(|| { - anyhow!(validation_error(&format!( - "missing required field '{field}'" - ))) - }) -} - -fn required_str(object: &Map, field: &str) -> Result { - required_field(object, field)? - .as_str() - .map(str::to_owned) - .ok_or_else(|| { - anyhow!(validation_error(&format!( - "field '{field}' must be a string" - ))) - }) -} - -fn required_non_blank_str(object: &Map, field: &str) -> Result { - let value = required_str(object, field)?; - if value.trim().is_empty() { - bail!(validation_error(&format!( - "field '{field}' must be a non-blank string" - ))); - } - Ok(value) -} - -fn optional_non_blank_str(object: &Map, field: &str) -> Result> { - match object.get(field) { - None | Some(Value::Null) => Ok(None), - Some(Value::String(value)) => { - if value.trim().is_empty() { - bail!(validation_error(&format!( - "field '{field}' must be null, absent, or a non-blank string" - ))); - } - Ok(Some(value.clone())) - } - Some(_) => bail!(validation_error(&format!( - "field '{field}' must be null, absent, or a non-blank string" - ))), - } -} - -fn validation_error(detail: &str) -> String { - format!("Invalid OpenCode hook event payload from STDIN: {detail}.") -} - -pub(crate) fn run_opencode_mutation_scope_subcommand( - logger: Option<&dyn Logger>, -) -> Result { - let stdin_payload = super::read_hook_stdin()?; - run_opencode_mutation_scope_from_payload(&stdin_payload, logger) -} - -pub(crate) fn run_opencode_mutation_scope_from_payload( - stdin_payload: &str, - logger: Option<&dyn Logger>, -) -> Result { - let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); - let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { - super::mutation_scope::run_mutation_scope_from_payload(repository_root, payload, logger) - }; - - run_opencode_mutation_scope_from_payload_with_seams( - stdin_payload, - logger, - &resolve_git_dir_fn, - &seam_fn, - ) -} +pub(crate) use events::{format_opencode_scope_id, AttemptKey}; +#[allow(unused_imports)] +pub(crate) use health::{assess_repairability, Repairability}; #[cfg(test)] -pub(crate) fn run_opencode_mutation_scope_from_payload_at_state_root( - state_root: &Path, - stdin_payload: &str, - logger: Option<&dyn Logger>, -) -> Result { - let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); - let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { - super::mutation_scope::run_mutation_scope_from_payload_at_state_root( - repository_root, - state_root, - payload, - logger, - ) - }; - - run_opencode_mutation_scope_from_payload_with_seams( - stdin_payload, - logger, - &resolve_git_dir_fn, - &seam_fn, - ) -} - -type GitDirResolver<'a> = &'a dyn Fn(&str) -> Result; - -type IngressSeam<'a> = &'a dyn Fn(&Path, &str, Option<&dyn Logger>) -> Result; - -const FAIL_CLOSED_MESSAGE: &str = - "SCE could not establish OpenCode mutation attribution for this tool execution."; - -const FAIL_CLOSED_EVENT: &str = "sce.hooks.opencode_mutation_scope.start_fail_closed"; - -fn log_fail_closed(logger: Option<&dyn Logger>, context: &str, error: &anyhow::Error) { - if let Some(log) = logger { - log.warn( - FAIL_CLOSED_EVENT, - &error.to_string(), - &[("context", context)], - None, - ); - } -} - -fn run_opencode_mutation_scope_from_payload_with_seams( - stdin_payload: &str, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - seam: IngressSeam, -) -> Result { - let event = parse_opencode_hook_event(stdin_payload)?; - dispatch_opencode_hook_event(event, logger, resolve_git_dir, seam) -} - -fn dispatch_opencode_hook_event( - event: OpenCodeHookEvent, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - seam: IngressSeam, -) -> Result { - match event { - OpenCodeHookEvent::ToolExecuteBefore(execution) => { - match execution.identity.classification() { - ToolClassification::TrackedMutation => { - if execution.identity.tool_name == OPENCODE_TRACKED_TOOL_BASH { - return Ok(String::new()); - } - let provenance = opencode_scope_provenance( - &execution.identity.session_id, - execution.model.as_deref(), - ); - establish_tracked_start( - &execution.identity.cwd, - &execution.identity.attempt_key(), - &execution.identity.tool_name, - &provenance, - logger, - resolve_git_dir, - seam, - ) - } - ToolClassification::Delegation | ToolClassification::Untracked => Ok(String::new()), - } - } - OpenCodeHookEvent::ShellEnv(shell) => { - let provenance = opencode_scope_provenance(&shell.session_id, shell.model.as_deref()); - establish_tracked_start( - &shell.cwd, - &shell.attempt_key(), - OPENCODE_TRACKED_TOOL_BASH, - &provenance, - logger, - resolve_git_dir, - seam, - ) - } - OpenCodeHookEvent::ToolExecuteAfter(identity) => { - if !matches!( - identity.classification(), - ToolClassification::TrackedMutation - ) { - return Ok(String::new()); - } - let git_dir = resolve_git_dir(&identity.cwd)?; - let repository_root = Path::new(&identity.cwd); - let key = identity.attempt_key(); - with_boundary_lock(&git_dir, || { - state::normalize_recovery_after_boundary_lock_acquired(&git_dir)?; - handle_close(&git_dir, repository_root, &key, logger, seam) - }) - } - OpenCodeHookEvent::ToolError(call) => { - if !matches!(call.classification(), ToolClassification::TrackedMutation) { - return Ok(String::new()); - } - let git_dir = resolve_git_dir(&call.cwd)?; - let repository_root = Path::new(&call.cwd); - let key = call.attempt_key(); - with_boundary_lock(&git_dir, || { - state::normalize_recovery_after_boundary_lock_acquired(&git_dir)?; - abandon_and_consume(&git_dir, repository_root, logger, seam, |attempt| { - attempt.session_id == key.session_id && attempt.call_id == key.call_id - }) - }) - } - OpenCodeHookEvent::SessionIdle(_) - | OpenCodeHookEvent::SessionError(_) - | OpenCodeHookEvent::SessionDeleted(_) - | OpenCodeHookEvent::ServerDisposed(_) => Ok(String::new()), - } -} - -fn with_boundary_lock(git_dir: &Path, operation: impl FnOnce() -> Result) -> Result { - let _boundary = AdapterBoundaryLock::acquire(git_dir, DEFAULT_BOUNDARY_LOCK_TIMEOUT) - .map_err(|error| anyhow!("Failed to acquire adapter boundary lock: {error}"))?; - operation() -} - -enum Admission { - Admitted(state::AllocatedAttempt), - Denied, -} - -enum StartOutcome { - Established, - Denied, -} - -fn establish_tracked_start( - cwd: &str, - key: &AttemptKey, - tool_name: &str, - provenance: &OpenCodeScopeProvenance, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - seam: IngressSeam, -) -> Result { - let git_dir = match resolve_git_dir(cwd) { - Ok(git_dir) => git_dir, - Err(error) => { - log_fail_closed(logger, "resolve_git_dir", &error); - return Err(error.context(FAIL_CLOSED_MESSAGE)); - } - }; - let repository_root = Path::new(cwd); - - let outcome = with_boundary_lock(&git_dir, || { - state::normalize_recovery_after_boundary_lock_acquired(&git_dir)?; - - match admit_or_recover(&git_dir, repository_root, key, tool_name, logger, seam)? { - Admission::Admitted(allocated) => { - establish_start( - &git_dir, - repository_root, - &allocated, - provenance, - logger, - seam, - )?; - Ok(StartOutcome::Established) - } - Admission::Denied => Ok(StartOutcome::Denied), - } - }); - - match outcome { - Ok(StartOutcome::Established) => Ok(String::new()), - Ok(StartOutcome::Denied) => bail!(FAIL_CLOSED_MESSAGE), - Err(error) => { - log_fail_closed(logger, "establish_tracked_start", &error); - Err(error.context(FAIL_CLOSED_MESSAGE)) - } - } -} - -fn admit_or_recover( - git_dir: &Path, - repository_root: &Path, - key: &AttemptKey, - tool_name: &str, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result { - match state::admit_tracked_attempt(git_dir, key, tool_name)? { - AdmitDecision::Admitted(allocated) => Ok(Admission::Admitted(allocated)), - AdmitDecision::RecoveryBlocked - | AdmitDecision::UncertainAttemptBlocked - | AdmitDecision::TerminalAttemptBlocked => Ok(Admission::Denied), - AdmitDecision::FlushClaimed { generation } => { - match resolve_recovery(git_dir, repository_root, generation, logger, seam)? { - RecoveryResolution::Cleared => readmit_after_flush(git_dir, key, tool_name), - RecoveryResolution::Unresolved => Ok(Admission::Denied), - } - } - } -} - -fn readmit_after_flush(git_dir: &Path, key: &AttemptKey, tool_name: &str) -> Result { - match state::admit_tracked_attempt(git_dir, key, tool_name)? { - AdmitDecision::Admitted(allocated) => Ok(Admission::Admitted(allocated)), - AdmitDecision::FlushClaimed { generation } => { - state::relinquish_recovery_flush(git_dir, generation)?; - Ok(Admission::Denied) - } - AdmitDecision::RecoveryBlocked - | AdmitDecision::UncertainAttemptBlocked - | AdmitDecision::TerminalAttemptBlocked => Ok(Admission::Denied), - } -} - -fn establish_start( - git_dir: &Path, - repository_root: &Path, - allocated: &state::AllocatedAttempt, - provenance: &OpenCodeScopeProvenance, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result<()> { - let scope_id = &allocated.attempt.scope_id; - - if allocated.reused && allocated.attempt.phase == state::AttemptPhase::Active { - return Ok(()); - } - - let start_payload = scope_start_payload( - scope_id, - &opencode_scope_start_event_id(scope_id), - provenance, - ); - - seam(repository_root, &start_payload, logger)?; - state::mark_active(git_dir, scope_id)?; - Ok(()) -} - -fn handle_close( - git_dir: &Path, - repository_root: &Path, - key: &AttemptKey, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result { - let current = state::read_state(git_dir)?; - let Some(attempt) = current - .attempts - .iter() - .find(|attempt| attempt.session_id == key.session_id && attempt.call_id == key.call_id) - .cloned() - else { - return Ok(String::new()); - }; - - let doomed_scope_id = attempt.scope_id.clone(); - - if matches!( - attempt.phase, - state::AttemptPhase::PendingStart | state::AttemptPhase::PendingAbandon - ) { - return abandon_and_consume(git_dir, repository_root, logger, seam, move |candidate| { - candidate.scope_id == doomed_scope_id - }); - } - - let close_payload = scope_boundary_payload( - "close", - &attempt.scope_id, - &opencode_scope_close_event_id(&attempt.scope_id), - ); - - if seam(repository_root, &close_payload, logger).is_ok() { - state::remove_attempt(git_dir, &attempt.scope_id)?; - Ok(String::new()) - } else { - abandon_and_consume(git_dir, repository_root, logger, seam, move |candidate| { - candidate.scope_id == doomed_scope_id - }) - } -} - -enum RecoveryResolution { - Cleared, - Unresolved, -} - -fn abandon_and_consume( - git_dir: &Path, - repository_root: &Path, - logger: Option<&dyn Logger>, - seam: IngressSeam, - doomed: impl Fn(&state::AdapterAttempt) -> bool, -) -> Result { - let doomed_scope_ids: Vec = state::read_state(git_dir)? - .attempts - .into_iter() - .filter(|attempt| doomed(attempt)) - .map(|attempt| attempt.scope_id) - .collect(); - if doomed_scope_ids.is_empty() { - return Ok(String::new()); - } - - let generation = state::begin_terminal_cleanup(git_dir, &doomed_scope_ids)?; - resolve_recovery(git_dir, repository_root, generation, logger, seam)?; - Ok(String::new()) -} - -fn resolve_recovery( - git_dir: &Path, - repository_root: &Path, - generation: u64, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result { - let pending_abandon: Vec = state::read_state(git_dir)? - .attempts - .into_iter() - .filter(|attempt| attempt.phase == state::AttemptPhase::PendingAbandon) - .collect(); - - if let Err(error) = seam(repository_root, &flush_payload(), logger) { - log_fail_closed(logger, "recovery_ambiguity_flush", &error); - state::relinquish_recovery_flush(git_dir, generation)?; - return Ok(RecoveryResolution::Unresolved); - } - - for attempt in &pending_abandon { - if let Err(error) = seam(repository_root, &abandon_payload(&attempt.scope_id), logger) { - log_fail_closed(logger, "recovery_abandon", &error); - state::relinquish_recovery_flush(git_dir, generation)?; - return Ok(RecoveryResolution::Unresolved); - } - state::remove_attempt(git_dir, &attempt.scope_id)?; - } - - if let Err(error) = seam(repository_root, &flush_payload(), logger) { - log_fail_closed(logger, "recovery_rebaseline_flush", &error); - state::relinquish_recovery_flush(git_dir, generation)?; - return Ok(RecoveryResolution::Unresolved); - } - - match state::complete_recovery_flush(git_dir, generation)? { - RecoveryFlushCompletion::Cleared => Ok(RecoveryResolution::Cleared), - RecoveryFlushCompletion::Superseded => Ok(RecoveryResolution::Unresolved), - } -} - -fn scope_boundary_payload(operation: &str, scope_id: &str, event_id: &str) -> String { - json!({ - "operation": operation, - "scope_id": scope_id, - "event_id": event_id, - "actor_kind": ACTOR_KIND_OPENCODE, - }) - .to_string() -} - -fn scope_start_payload( - scope_id: &str, - event_id: &str, - provenance: &OpenCodeScopeProvenance, -) -> String { - json!({ - "operation": "start", - "scope_id": scope_id, - "event_id": event_id, - "actor_kind": ACTOR_KIND_OPENCODE, - "provenance": { - "session_id": provenance.session_id, - "model_id": provenance.model_id, - }, - }) - .to_string() -} - -fn abandon_payload(scope_id: &str) -> String { - json!({ - "operation": "abandon", - "scope_id": scope_id, - }) - .to_string() -} - -fn flush_payload() -> String { - json!({ "operation": "flush" }).to_string() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn tool_event_json(hook_event_name: &str, overrides: &[(&str, Value)]) -> String { - let mut object = Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(hook_event_name.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String("ses_main".to_string()), - ); - object.insert( - CALL_ID_FIELD.to_string(), - Value::String("call_1".to_string()), - ); - object.insert( - CWD_FIELD.to_string(), - Value::String("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/repo/checkout".to_string()), - ); - object.insert( - TOOL_NAME_FIELD.to_string(), - Value::String("write".to_string()), - ); - for (field, value) in overrides { - object.insert((*field).to_string(), value.clone()); - } - Value::Object(object).to_string() - } - - fn key(session_id: &str, call_id: &str) -> AttemptKey { - AttemptKey { - session_id: session_id.to_string(), - call_id: call_id.to_string(), - } - } - - fn tool_execution(payload: &str) -> OpenCodeToolExecution { - match parse_opencode_hook_event(payload).expect("valid ToolExecuteBefore parses") { - OpenCodeHookEvent::ToolExecuteBefore(execution) => execution, - other => panic!("expected ToolExecuteBefore, got {other:?}"), - } - } - - #[test] - fn empty_payload_is_rejected() { - let error = parse_opencode_hook_event(" ").unwrap_err().to_string(); - assert_eq!( - error, - "Invalid OpenCode hook event payload from STDIN: expected a JSON object, got an empty payload." - ); - } - - #[test] - fn non_object_json_is_rejected() { - for payload in ["[]", "\"ToolExecuteBefore\"", "42", "null"] { - let error = parse_opencode_hook_event(payload).unwrap_err().to_string(); - assert!( - error.contains("expected a JSON object"), - "payload {payload:?} produced {error:?}" - ); - } - } - - #[test] - fn invalid_json_is_rejected() { - let error = parse_opencode_hook_event("{not json") - .unwrap_err() - .to_string(); - assert!( - error.contains("Invalid OpenCode hook event payload from STDIN: expected valid JSON"), - "{error:?}" - ); - } - - #[test] - fn unsupported_hook_event_name_is_rejected() { - for name in ["PreToolUse", "ToolExecute", "chat.params", ""] { - let payload = tool_event_json(name, &[]); - let error = parse_opencode_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains("hook_event_name"), - "name {name:?} produced {error:?}" - ); - } - } - - #[test] - fn missing_required_fields_are_rejected_without_fabricating_identity() { - for field in [SESSION_ID_FIELD, CALL_ID_FIELD, CWD_FIELD, TOOL_NAME_FIELD] { - let mut object: Map = - serde_json::from_str(&tool_event_json(HOOK_EVENT_TOOL_EXECUTE_BEFORE, &[])) - .unwrap(); - object.remove(field); - let payload = Value::Object(object).to_string(); - - let error = parse_opencode_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains(&format!("'{field}'")), - "missing {field} produced {error:?}" - ); - } - } - - #[test] - fn blank_required_fields_are_rejected() { - for field in [SESSION_ID_FIELD, CALL_ID_FIELD, CWD_FIELD, TOOL_NAME_FIELD] { - let payload = tool_event_json( - HOOK_EVENT_TOOL_EXECUTE_BEFORE, - &[(field, Value::String(" ".to_string()))], - ); - let error = parse_opencode_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains(&format!("field '{field}' must be a non-blank string")), - "blank {field} produced {error:?}" - ); - } - } - - #[test] - fn wrong_typed_fields_are_rejected() { - let payload = tool_event_json( - HOOK_EVENT_TOOL_EXECUTE_BEFORE, - &[(CALL_ID_FIELD, Value::Bool(true))], - ); - let error = parse_opencode_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains("field 'call_id' must be a string"), - "{error:?}" - ); - } - - #[test] - fn wrong_typed_optional_model_is_rejected() { - let payload = tool_event_json( - HOOK_EVENT_TOOL_EXECUTE_BEFORE, - &[(MODEL_FIELD, Value::Bool(false))], - ); - let error = parse_opencode_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains("field 'model' must be null, absent, or a non-blank string"), - "{error:?}" - ); - } - - #[test] - fn tool_execute_before_parses_identity_and_model() { - let execution = tool_execution(&tool_event_json( - HOOK_EVENT_TOOL_EXECUTE_BEFORE, - &[ - (TOOL_NAME_FIELD, Value::String("edit".to_string())), - ( - MODEL_FIELD, - Value::String("opencode/big-pickle".to_string()), - ), - ], - )); - assert_eq!(execution.identity.session_id, "ses_main"); - assert_eq!(execution.identity.call_id, "call_1"); - assert_eq!(execution.identity.tool_name, "edit"); - assert_eq!(execution.model.as_deref(), Some("opencode/big-pickle")); - assert_eq!( - execution.identity.classification(), - ToolClassification::TrackedMutation - ); - } - - #[test] - fn tool_execute_before_model_is_optional() { - let execution = tool_execution(&tool_event_json(HOOK_EVENT_TOOL_EXECUTE_BEFORE, &[])); - assert_eq!(execution.model, None); - } - - #[test] - fn shell_env_parses_without_a_tool_name() { - let mut object = Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(HOOK_EVENT_SHELL_ENV.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String("ses_main".to_string()), - ); - object.insert( - CALL_ID_FIELD.to_string(), - Value::String("call_bash".to_string()), - ); - object.insert(CWD_FIELD.to_string(), Value::String("/repo".to_string())); - object.insert( - MODEL_FIELD.to_string(), - Value::String("opencode/big-pickle".to_string()), - ); - let payload = Value::Object(object).to_string(); - - let OpenCodeHookEvent::ShellEnv(shell) = parse_opencode_hook_event(&payload).unwrap() - else { - panic!("expected ShellEnv"); - }; - assert_eq!(shell.call_id, "call_bash"); - assert_eq!(shell.model.as_deref(), Some("opencode/big-pickle")); - assert_eq!(shell.attempt_key(), key("ses_main", "call_bash")); - } - - #[test] - fn tool_execute_after_parses_tool_identity() { - let OpenCodeHookEvent::ToolExecuteAfter(identity) = - parse_opencode_hook_event(&tool_event_json(HOOK_EVENT_TOOL_EXECUTE_AFTER, &[])) - .unwrap() - else { - panic!("expected ToolExecuteAfter"); - }; - assert_eq!(identity.attempt_key(), key("ses_main", "call_1")); - assert_eq!(identity.tool_name, "write"); - } - - #[test] - fn terminal_events_parse_their_minimal_identity() { - for name in [ - HOOK_EVENT_SESSION_IDLE, - HOOK_EVENT_SESSION_ERROR, - HOOK_EVENT_SESSION_DELETED, - ] { - let mut object = Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(name.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String("ses_main".to_string()), - ); - object.insert(CWD_FIELD.to_string(), Value::String("/repo".to_string())); - let payload = Value::Object(object).to_string(); - - let event = parse_opencode_hook_event(&payload).unwrap(); - let identity = match event { - OpenCodeHookEvent::SessionIdle(identity) - | OpenCodeHookEvent::SessionError(identity) - | OpenCodeHookEvent::SessionDeleted(identity) => identity, - other => panic!("expected a session-identity event, got {other:?}"), - }; - assert_eq!(identity.session_id, "ses_main"); - assert_eq!(identity.cwd, "/repo"); - } - - let mut object = Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(HOOK_EVENT_TOOL_ERROR.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String("ses_main".to_string()), - ); - object.insert( - CALL_ID_FIELD.to_string(), - Value::String("call_1".to_string()), - ); - object.insert(CWD_FIELD.to_string(), Value::String("/repo".to_string())); - object.insert( - TOOL_NAME_FIELD.to_string(), - Value::String("write".to_string()), - ); - let OpenCodeHookEvent::ToolError(identity) = - parse_opencode_hook_event(&Value::Object(object).to_string()).unwrap() - else { - panic!("expected ToolError"); - }; - assert_eq!(identity.attempt_key(), key("ses_main", "call_1")); - assert_eq!( - identity.classification(), - ToolClassification::TrackedMutation - ); - - let mut object = Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(HOOK_EVENT_SERVER_DISPOSED.to_string()), - ); - object.insert(CWD_FIELD.to_string(), Value::String("/repo".to_string())); - let OpenCodeHookEvent::ServerDisposed(workspace) = - parse_opencode_hook_event(&Value::Object(object).to_string()).unwrap() - else { - panic!("expected ServerDisposed"); - }; - assert_eq!(workspace.cwd, "/repo"); - } - - #[test] - fn classification_table() { - let cases: &[(&str, ToolClassification)] = &[ - ("bash", ToolClassification::TrackedMutation), - ("write", ToolClassification::TrackedMutation), - ("edit", ToolClassification::TrackedMutation), - ("apply_patch", ToolClassification::TrackedMutation), - ("task", ToolClassification::Delegation), - ("read", ToolClassification::Untracked), - ("glob", ToolClassification::Untracked), - ("grep", ToolClassification::Untracked), - ("webfetch", ToolClassification::Untracked), - ("websearch", ToolClassification::Untracked), - ("todowrite", ToolClassification::Untracked), - ("probe_mutate", ToolClassification::Untracked), - ( - "brave-search_brave_web_search", - ToolClassification::Untracked, - ), - ("Bash", ToolClassification::Untracked), - ("some_future_opencode_tool", ToolClassification::Untracked), - ("", ToolClassification::Untracked), - ]; - for (tool_name, expected) in cases { - assert_eq!( - classify_tool(tool_name), - *expected, - "classify_tool({tool_name:?})" - ); - } - } - - #[test] - fn classification_is_total_and_single_valued() { - for tool_name in ["bash", "write", "edit", "apply_patch", "task", "read", "x"] { - let _: ToolClassification = classify_tool(tool_name); - } - } - - #[test] - fn scope_id_is_deterministic_for_the_same_key() { - let k = key("ses_main", "call_1"); - assert_eq!(format_opencode_scope_id(&k), format_opencode_scope_id(&k)); - - let scope_id = format_opencode_scope_id(&k); - assert_eq!(scope_id, "oc-tool-v1|s=8:ses_main|c=6:call_1"); - assert_eq!( - opencode_scope_start_event_id(&scope_id), - format!("{scope_id}|start") - ); - assert_eq!( - opencode_scope_close_event_id(&scope_id), - format!("{scope_id}|close") - ); - assert_ne!( - opencode_scope_start_event_id(&scope_id), - opencode_scope_close_event_id(&scope_id) - ); - } - - #[test] - fn duplicate_events_reuse_the_same_scope_id() { - let payload = tool_event_json( - HOOK_EVENT_TOOL_EXECUTE_BEFORE, - &[(TOOL_NAME_FIELD, Value::String("bash".to_string()))], - ); - let first = tool_execution(&payload).identity.attempt_key(); - let second = tool_execution(&payload).identity.attempt_key(); - assert_eq!( - format_opencode_scope_id(&first), - format_opencode_scope_id(&second) - ); - } - - #[test] - fn length_prefix_disambiguates_delimiter_collisions() { - let a = key("s|c=1:x", "y"); - let b = key("s", "1:x|y"); - assert_ne!(format_opencode_scope_id(&a), format_opencode_scope_id(&b)); - - let tricky = key("ses|c=0:x", "call:with:colons"); - assert_eq!( - format_opencode_scope_id(&tricky), - format!( - "oc-tool-v1|s={}:{}|c={}:{}", - tricky.session_id.len(), - tricky.session_id, - tricky.call_id.len(), - tricky.call_id, - ) - ); - } - - #[test] - fn parallel_call_ids_in_one_session_stay_distinguishable() { - let a = tool_execution(&tool_event_json( - HOOK_EVENT_TOOL_EXECUTE_BEFORE, - &[ - (TOOL_NAME_FIELD, Value::String("bash".to_string())), - (CALL_ID_FIELD, Value::String("call_a".to_string())), - ], - )); - let b = tool_execution(&tool_event_json( - HOOK_EVENT_TOOL_EXECUTE_BEFORE, - &[ - (TOOL_NAME_FIELD, Value::String("bash".to_string())), - (CALL_ID_FIELD, Value::String("call_b".to_string())), - ], - )); - assert_ne!(a.identity.attempt_key(), b.identity.attempt_key()); - assert_ne!( - format_opencode_scope_id(&a.identity.attempt_key()), - format_opencode_scope_id(&b.identity.attempt_key()) - ); - } - - #[test] - fn task_child_session_identity_flows_through_the_attempt_key() { - let child = tool_execution(&tool_event_json( - HOOK_EVENT_TOOL_EXECUTE_BEFORE, - &[ - (TOOL_NAME_FIELD, Value::String("bash".to_string())), - (SESSION_ID_FIELD, Value::String("ses_child".to_string())), - (CALL_ID_FIELD, Value::String("call_child".to_string())), - ], - )); - assert_eq!(child.identity.attempt_key(), key("ses_child", "call_child")); - assert_ne!( - format_opencode_scope_id(&child.identity.attempt_key()), - format_opencode_scope_id(&key("ses_main", "call_child")) - ); - } - - #[test] - fn attempt_key_projects_only_session_and_call() { - let identity_a = OpenCodeToolIdentity { - session_id: "ses_main".to_string(), - call_id: "call_1".to_string(), - cwd: "/repo".to_string(), - tool_name: "write".to_string(), - }; - let identity_b = OpenCodeToolIdentity { - tool_name: "bash".to_string(), - cwd: "/other".to_string(), - ..identity_a.clone() - }; - assert_eq!(identity_a.attempt_key(), identity_b.attempt_key()); - } - - #[test] - fn provenance_canonicalizes_the_session_and_normalizes_the_model() { - let provenance = opencode_scope_provenance("ses_main", Some("opencode/big-pickle")); - assert_eq!(provenance.session_id, "oc_ses_main"); - assert_eq!(provenance.model_id.as_deref(), Some("opencode/big-pickle")); - } - - #[test] - fn provenance_keeps_an_already_prefixed_session_id() { - let provenance = opencode_scope_provenance("oc_ses_main", None); - assert_eq!(provenance.session_id, "oc_ses_main"); - } - - #[test] - fn provenance_without_model_evidence_is_null() { - for model in [None, Some(""), Some(" ")] { - let provenance = opencode_scope_provenance("ses_main", model); - assert_eq!(provenance.model_id, None, "model {model:?}"); - assert_eq!(provenance.session_id, "oc_ses_main", "model {model:?}"); - } - } - - #[test] - fn provenance_is_built_from_a_parsed_start_event() { - let execution = tool_execution(&tool_event_json( - HOOK_EVENT_TOOL_EXECUTE_BEFORE, - &[ - (TOOL_NAME_FIELD, Value::String("write".to_string())), - ( - MODEL_FIELD, - Value::String("opencode/big-pickle".to_string()), - ), - ], - )); - let provenance = - opencode_scope_provenance(&execution.identity.session_id, execution.model.as_deref()); - assert_eq!(provenance.session_id, "oc_ses_main"); - assert_eq!(provenance.model_id.as_deref(), Some("opencode/big-pickle")); - } - - #[test] - fn run_from_payload_fails_closed_when_a_tracked_start_cannot_resolve_its_checkout() { - let payload = tool_event_json( - HOOK_EVENT_TOOL_EXECUTE_BEFORE, - &[ - (TOOL_NAME_FIELD, Value::String("write".to_string())), - ( - CWD_FIELD, - Value::String("/nonexistent/sce/opencode/checkout".to_string()), - ), - ], - ); - let error = run_opencode_mutation_scope_from_payload(&payload, None) - .expect_err("a tracked Start that cannot resolve its checkout must fail closed"); - assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE), "{error:?}"); - } - - #[test] - fn run_from_payload_is_neutral_for_untracked_and_delegation_events() { - for tool_name in ["read", "task", "probe_mutate"] { - let payload = tool_event_json( - HOOK_EVENT_TOOL_EXECUTE_BEFORE, - &[(TOOL_NAME_FIELD, Value::String(tool_name.to_string()))], - ); - assert_eq!( - run_opencode_mutation_scope_from_payload(&payload, None).unwrap(), - String::new() - ); - } - } - - #[test] - fn run_from_payload_surfaces_malformed_input() { - let error = run_opencode_mutation_scope_from_payload("{bad", None) - .unwrap_err() - .to_string(); - assert!(error.contains("expected valid JSON"), "{error:?}"); - } -} - -#[cfg(test)] -mod lifecycle_tests { - use std::path::PathBuf; - use std::sync::atomic::{AtomicU64, Ordering}; - use std::sync::Mutex; - - use serde_json::Value; - - use super::state::{read_state, AttemptPhase, RecoveryState}; - use super::*; - - static NEXT_ID: AtomicU64 = AtomicU64::new(0); - - fn temp_git_dir(label: &str) -> PathBuf { - let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); - std::env::temp_dir().join(format!( - "sce-opencode-mutation-scope-lifecycle-{label}-{}-{id}", - std::process::id() - )) - } - - const CWD: &str = "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/repo/opencode-checkout"; - - struct RecordingSeam { - calls: Mutex>, - fail_operations: Vec, - fail_once_operations: Mutex>, - fail_operation_occurrence: Option<(String, usize)>, - } - - impl RecordingSeam { - fn new() -> Self { - Self { - calls: Mutex::new(Vec::new()), - fail_operations: Vec::new(), - fail_once_operations: Mutex::new(Vec::new()), - fail_operation_occurrence: None, - } - } - - fn failing_on(operations: &[&str]) -> Self { - Self { - fail_operations: operations.iter().map(|op| (*op).to_string()).collect(), - ..Self::new() - } - } - - fn failing_once_on(operations: &[&str]) -> Self { - Self { - fail_once_operations: Mutex::new( - operations.iter().map(|op| (*op).to_string()).collect(), - ), - ..Self::new() - } - } - - fn failing_on_nth_occurrence(operation: &str, occurrence: usize) -> Self { - Self { - fail_operation_occurrence: Some((operation.to_string(), occurrence)), - ..Self::new() - } - } - - fn handle(&self, payload: &str) -> Result { - let operation = operation_of(payload); - let occurrence = { - let mut calls = self.calls.lock().expect("seam mutex"); - calls.push(operation.clone()); - calls - .iter() - .filter(|candidate| *candidate == &operation) - .count() - }; - if self.fail_operations.contains(&operation) { - bail!("seam failure injected by test for '{operation}'"); - } - if let Some((target, target_occurrence)) = &self.fail_operation_occurrence { - if target == &operation && *target_occurrence == occurrence { - bail!( - "seam failure injected by test for '{operation}' occurrence {occurrence}" - ); - } - } - { - let mut once = self.fail_once_operations.lock().expect("seam mutex"); - if let Some(position) = once.iter().position(|candidate| candidate == &operation) { - once.remove(position); - bail!("transient seam failure injected once by test for '{operation}'"); - } - } - Ok(String::new()) - } - - fn operations(&self) -> Vec { - self.calls.lock().expect("seam mutex").clone() - } - } - - fn operation_of(payload: &str) -> String { - let value: Value = serde_json::from_str(payload).expect("seam payload is JSON"); - value - .get("operation") - .and_then(Value::as_str) - .expect("seam payload has an operation") - .to_string() - } - - fn drive(git_dir: &Path, seam: &RecordingSeam, payload: &str) -> Result { - let resolver = |_cwd: &str| Ok(git_dir.to_path_buf()); - let seam_fn = - |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| seam.handle(payload); - run_opencode_mutation_scope_from_payload_with_seams(payload, None, &resolver, &seam_fn) - } - - fn tool_before(tool_name: &str, call_id: &str) -> String { - json!({ - "hook_event_name": "ToolExecuteBefore", - "session_id": "ses_main", - "call_id": call_id, - "cwd": CWD, - "tool_name": tool_name, - "model": "opencode/big-pickle", - }) - .to_string() - } - - fn shell_env(call_id: &str) -> String { - json!({ - "hook_event_name": "ShellEnv", - "session_id": "ses_main", - "call_id": call_id, - "cwd": CWD, - "model": "opencode/big-pickle", - }) - .to_string() - } - - fn tool_after(tool_name: &str, call_id: &str) -> String { - json!({ - "hook_event_name": "ToolExecuteAfter", - "session_id": "ses_main", - "call_id": call_id, - "cwd": CWD, - "tool_name": tool_name, - }) - .to_string() - } - - fn tool_error(tool_name: &str, call_id: &str) -> String { - json!({ - "hook_event_name": "ToolError", - "session_id": "ses_main", - "call_id": call_id, - "cwd": CWD, - "tool_name": tool_name, - }) - .to_string() - } - - fn session_event(hook_event_name: &str, session_id: &str) -> String { - json!({ - "hook_event_name": hook_event_name, - "session_id": session_id, - "cwd": CWD, - }) - .to_string() - } - - fn server_disposed() -> String { - json!({ "hook_event_name": "ServerDisposed", "cwd": CWD }).to_string() - } - - fn cleanup(git_dir: &Path) { - let _ = std::fs::remove_dir_all(git_dir); - } - - #[test] - fn file_tool_before_establishes_a_write_ahead_start_and_replays_idempotently() { - let git_dir = temp_git_dir("write-ahead-start"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_before("write", "call_1")).expect("first Start"); - drive(&git_dir, &seam, &tool_before("write", "call_1")) - .expect("duplicate Start is a no-op"); - - assert_eq!(seam.operations(), vec!["start"]); - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1); - assert_eq!(state.attempts[0].phase, AttemptPhase::Active); - assert_eq!(state.attempts[0].tool_name, "write"); - - cleanup(&git_dir); - } - - #[test] - fn bash_start_is_anchored_to_shell_env_not_tool_execute_before() { - let git_dir = temp_git_dir("bash-shell-env"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_before("bash", "call_bash")).expect("bash before is inert"); - assert!(seam.operations().is_empty()); - assert!(read_state(&git_dir) - .expect("state readable") - .attempts - .is_empty()); - - drive(&git_dir, &seam, &shell_env("call_bash")).expect("shell.env establishes Start"); - assert_eq!(seam.operations(), vec!["start"]); - assert_eq!( - read_state(&git_dir).expect("state readable").attempts[0].phase, - AttemptPhase::Active, - ); - - cleanup(&git_dir); - } - - #[test] - fn concurrent_bash_calls_in_one_session_stay_separate_live_scopes() { - let git_dir = temp_git_dir("concurrent-bash"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &shell_env("call_a")).expect("A Start"); - drive(&git_dir, &seam, &shell_env("call_b")).expect("B Start must not retire A"); - - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 2); - assert!(state - .attempts - .iter() - .all(|a| a.phase == AttemptPhase::Active)); - assert_eq!(seam.operations(), vec!["start", "start"]); - - cleanup(&git_dir); - } - - #[test] - fn successful_after_closes_exactly_that_attempt_and_replays_as_a_no_op() { - let git_dir = temp_git_dir("close-replay"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_before("edit", "call_1")).expect("Start"); - drive(&git_dir, &seam, &tool_after("edit", "call_1")).expect("Close"); - drive(&git_dir, &seam, &tool_after("edit", "call_1")).expect("duplicate Close is a no-op"); - - assert_eq!(seam.operations(), vec!["start", "close"]); - assert!(read_state(&git_dir) - .expect("state readable") - .attempts - .is_empty()); - - cleanup(&git_dir); - } - - #[test] - fn tool_error_retires_the_named_attempt_and_consumes_the_ambiguous_interval() { - let git_dir = temp_git_dir("tool-error-consume"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &shell_env("call_a")).expect("A Start"); - drive(&git_dir, &seam, &shell_env("call_b")).expect("B Start"); - drive(&git_dir, &seam, &tool_error("bash", "call_a")).expect("A terminal failure"); - - assert_eq!( - seam.operations(), - vec!["start", "start", "flush", "abandon", "flush"], - "the ambiguous interval is flushed before A is abandoned, then the abandon \ - rebaseline is flushed away so B keeps its future intervals", - ); - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1); - assert_eq!(state.attempts[0].call_id, "call_b"); - assert_eq!(state.attempts[0].phase, AttemptPhase::Active); - assert!( - state.recovery.is_clear(), - "a successful ambiguity flush clears the recovery barrier while B stays live", - ); - - cleanup(&git_dir); - } - - #[test] - fn exact_error_retires_only_the_named_sibling() { - let git_dir = temp_git_dir("exact-error-siblings"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &shell_env("call_a")).expect("A Start"); - drive(&git_dir, &seam, &shell_env("call_b")).expect("B Start"); - drive(&git_dir, &seam, &shell_env("call_c")).expect("C Start"); - drive(&git_dir, &seam, &tool_error("bash", "call_b")).expect("B terminal failure"); - - let state = read_state(&git_dir).expect("state readable"); - let mut remaining: Vec<&str> = state - .attempts - .iter() - .map(|attempt| attempt.call_id.as_str()) - .collect(); - remaining.sort_unstable(); - assert_eq!(remaining, vec!["call_a", "call_c"]); - assert!(state - .attempts - .iter() - .all(|a| a.phase == AttemptPhase::Active)); - assert!(state.recovery.is_clear()); - assert_eq!( - seam.operations() - .iter() - .filter(|op| *op == "abandon") - .count(), - 1, - "abandoning B must not sweep A or C", - ); - - cleanup(&git_dir); - } - - #[test] - fn a_close_before_start_confirmation_consumes_rather_than_closes() { - let git_dir = temp_git_dir("pending-start-close"); - let seam = RecordingSeam::failing_on(&["start"]); - - let error = drive(&git_dir, &seam, &tool_before("write", "call_1")) - .expect_err("a failed Start seam must fail closed"); - assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE)); - assert_eq!( - read_state(&git_dir).expect("state readable").attempts[0].phase, - AttemptPhase::PendingStart, - ); - - let ok_seam = RecordingSeam::new(); - drive(&git_dir, &ok_seam, &tool_after("write", "call_1")).expect("After on a PendingStart"); - assert_eq!(ok_seam.operations(), vec!["flush", "abandon", "flush"]); - assert!(read_state(&git_dir) - .expect("state readable") - .attempts - .is_empty()); - - cleanup(&git_dir); - } - - #[test] - fn session_idle_is_non_destructive() { - let git_dir = temp_git_dir("session-idle-noop"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &shell_env("call_a")).expect("A Start"); - drive( - &git_dir, - &seam, - &json!({ - "hook_event_name": "ShellEnv", - "session_id": "ses_other", - "call_id": "call_c", - "cwd": CWD, - }) - .to_string(), - ) - .expect("other-session Start"); - - for name in ["SessionIdle", "SessionError", "SessionDeleted"] { - drive(&git_dir, &seam, &session_event(name, "ses_main")).expect("broad event is inert"); - } - - let state = read_state(&git_dir).expect("state readable"); - assert_eq!( - state.attempts.len(), - 2, - "no attempt is retired by a broad event" - ); - assert!(state - .attempts - .iter() - .all(|a| a.phase == AttemptPhase::Active)); - assert!(state.recovery.is_clear()); - assert_eq!(seam.operations(), vec!["start", "start"]); - - cleanup(&git_dir); - } - - #[test] - fn delayed_session_idle_cannot_retire_a_newer_call() { - let git_dir = temp_git_dir("delayed-session-idle"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &shell_env("call_b")).expect("newer call B Start"); - drive(&git_dir, &seam, &session_event("SessionIdle", "ses_main")) - .expect("a delayed SessionIdle for the same session arrives after B started"); - - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1); - assert_eq!(state.attempts[0].call_id, "call_b"); - assert_eq!(state.attempts[0].phase, AttemptPhase::Active); - assert!(state.recovery.is_clear()); - assert!(!seam.operations().iter().any(|op| op == "abandon")); - - cleanup(&git_dir); - } - - #[test] - fn server_disposed_cannot_sweep_another_processes_attempt() { - let git_dir = temp_git_dir("server-disposed-noop"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &shell_env("call_a")).expect("process P1 owns call A"); - drive( - &git_dir, - &seam, - &json!({ - "hook_event_name": "ShellEnv", - "session_id": "ses_other", - "call_id": "call_b", - "cwd": CWD, - }) - .to_string(), - ) - .expect("process P2 owns call B in the same checkout"); - - drive(&git_dir, &seam, &server_disposed()).expect("P1 server disposal is inert"); - - let state = read_state(&git_dir).expect("state readable"); - assert_eq!( - state.attempts.len(), - 2, - "one process's disposal must not retire another process's live attempt", - ); - assert!(state.recovery.is_clear()); - assert_eq!(seam.operations(), vec!["start", "start"]); - - cleanup(&git_dir); - } - - #[test] - fn a_failed_sibling_does_not_retire_survivors_or_block_new_starts() { - let git_dir = temp_git_dir("failed-sibling-survivors"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &shell_env("call_a")).expect("A Start"); - drive(&git_dir, &seam, &shell_env("call_b")).expect("B Start"); - drive(&git_dir, &seam, &tool_error("bash", "call_a")).expect("A fails"); - - drive(&git_dir, &seam, &shell_env("call_c")) - .expect("a new Start is admitted normally once the ambiguity flush cleared recovery"); - - let state = read_state(&git_dir).expect("state readable"); - let mut remaining: Vec<&str> = state - .attempts - .iter() - .map(|attempt| attempt.call_id.as_str()) - .collect(); - remaining.sort_unstable(); - assert_eq!(remaining, vec!["call_b", "call_c"]); - assert!(state - .attempts - .iter() - .all(|a| a.phase == AttemptPhase::Active)); - assert!(state.recovery.is_clear()); - - cleanup(&git_dir); - } - - #[test] - fn a_terminal_failure_consumes_the_interval_and_the_next_start_proceeds() { - let git_dir = temp_git_dir("terminal-then-start"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_before("write", "call_1")).expect("Start"); - drive(&git_dir, &seam, &tool_error("write", "call_1")).expect("terminal failure"); - assert!( - read_state(&git_dir) - .expect("state readable") - .recovery - .is_clear(), - "the ambiguity flush is done at abandon time, not deferred to the next Start", - ); - - drive(&git_dir, &seam, &tool_before("write", "call_2")).expect("Start after consume"); - - assert_eq!( - seam.operations(), - vec!["start", "flush", "abandon", "flush", "start"], - ); - let state = read_state(&git_dir).expect("state readable"); - assert!(state.recovery.is_clear()); - assert_eq!(state.attempts.len(), 1); - assert_eq!(state.attempts[0].call_id, "call_2"); - - cleanup(&git_dir); - } - - #[test] - fn a_failed_ambiguity_flush_stays_recovery_pending_and_fails_closed_starts() { - let git_dir = temp_git_dir("failed-ambiguity-flush"); - - { - let ok_seam = RecordingSeam::new(); - drive(&git_dir, &ok_seam, &tool_before("write", "call_1")).expect("Start"); - } - - let failing = RecordingSeam::failing_on(&["flush"]); - drive(&git_dir, &failing, &tool_error("write", "call_1")) - .expect("a terminal failure whose flush fails still returns (best-effort)"); - assert_eq!( - read_state(&git_dir).expect("state readable").recovery, - RecoveryState::Pending { generation: 1 }, - "a failed ambiguity flush retains a recovery-required state", - ); - - let error = drive(&git_dir, &failing, &tool_before("write", "call_2")) - .expect_err("a new Start while recovery is unresolved must stay fail-closed"); - assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE)); - assert_eq!( - read_state(&git_dir).expect("state readable").recovery, - RecoveryState::Pending { generation: 1 }, - ); - - cleanup(&git_dir); - } - - #[test] - fn untracked_and_delegation_events_are_zero_footprint() { - let git_dir = temp_git_dir("zero-footprint"); - let seam = RecordingSeam::new(); - - for payload in [ - tool_before("read", "call_r"), - tool_before("task", "call_t"), - tool_before("some_future_tool", "call_f"), - tool_after("read", "call_r"), - tool_error("read", "call_r"), - tool_error("task", "call_t"), - ] { - drive(&git_dir, &seam, &payload).expect("untracked event is neutral"); - } - - assert!(seam.operations().is_empty()); - assert!( - !git_dir.join("sce").exists(), - "no state directory is created" - ); - - cleanup(&git_dir); - } - - #[test] - fn close_seam_failure_falls_back_to_consume() { - let git_dir = temp_git_dir("close-seam-failure"); - - { - let ok_seam = RecordingSeam::new(); - drive(&git_dir, &ok_seam, &tool_before("edit", "call_1")).expect("Start"); - } - - let failing = RecordingSeam::failing_on(&["close"]); - drive(&git_dir, &failing, &tool_after("edit", "call_1")).expect("Close seam failure"); - - assert_eq!( - failing.operations(), - vec!["close", "flush", "abandon", "flush"], - ); - let state = read_state(&git_dir).expect("state readable"); - assert!(state.attempts.is_empty()); - assert!(state.recovery.is_clear()); - - cleanup(&git_dir); - } - - #[test] - fn regression_a_abandon_failure_preserves_terminal_intent_then_recovers() { - let git_dir = temp_git_dir("regression-a-abandon-failure"); - - { - let ok_seam = RecordingSeam::new(); - drive(&git_dir, &ok_seam, &tool_before("write", "call_1")).expect("A Start"); - } - - let failing = RecordingSeam::failing_on(&["abandon"]); - drive(&git_dir, &failing, &tool_error("write", "call_1")) - .expect("a terminal failure whose abandon fails still returns best-effort"); - - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1, "A is not forgotten"); - assert_eq!(state.attempts[0].call_id, "call_1"); - assert_eq!( - state.attempts[0].phase, - AttemptPhase::PendingAbandon, - "the exact terminal attempt is durably marked PendingAbandon", - ); - assert!( - !state.recovery.is_clear(), - "recovery stays unresolved while Abandon has not succeeded", - ); - assert_eq!( - failing.operations(), - vec!["flush", "abandon"], - "the ambiguity flush ran, then the abandon that failed; no rebaseline flush, \ - no removal", - ); - - let healthy = RecordingSeam::new(); - drive(&git_dir, &healthy, &tool_before("write", "call_2")) - .expect("a healthy retry boundary resolves recovery and admits the new Start"); - - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1); - assert_eq!(state.attempts[0].call_id, "call_2"); - assert!(state.recovery.is_clear()); - assert_eq!( - healthy.operations(), - vec!["flush", "abandon", "flush", "start"], - "recovery replays flush + abandon + rebaseline flush before the new Start", - ); - - cleanup(&git_dir); - } - - #[test] - fn regression_b_ambiguity_flush_failure_blocks_new_starts_then_recovers() { - let git_dir = temp_git_dir("regression-b-ambiguity-flush-failure"); - let live = RecordingSeam::new(); - - drive(&git_dir, &live, &shell_env("call_a")).expect("A Start"); - drive(&git_dir, &live, &shell_env("call_b")).expect("B Start"); - - let failing = RecordingSeam::failing_on(&["flush"]); - drive(&git_dir, &failing, &tool_error("bash", "call_a")).expect("A terminal failure"); - - let state = read_state(&git_dir).expect("state readable"); - let pending_abandon: Vec<&str> = state - .attempts - .iter() - .filter(|attempt| attempt.phase == AttemptPhase::PendingAbandon) - .map(|attempt| attempt.call_id.as_str()) - .collect(); - let active: Vec<&str> = state - .attempts - .iter() - .filter(|attempt| attempt.phase == AttemptPhase::Active) - .map(|attempt| attempt.call_id.as_str()) - .collect(); - assert_eq!(pending_abandon, vec!["call_a"], "A is PendingAbandon"); - assert_eq!(active, vec!["call_b"], "B stays Active"); - assert!(!state.recovery.is_clear(), "recovery pending"); - - let error = drive(&git_dir, &failing, &shell_env("call_c")) - .expect_err("a new tracked Start must fail closed while recovery is unresolved"); - assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE)); - assert!(read_state(&git_dir) - .expect("state readable") - .attempts - .iter() - .all(|attempt| attempt.call_id != "call_c")); - - let healthy = RecordingSeam::new(); - drive(&git_dir, &healthy, &shell_env("call_c")) - .expect("once recovery succeeds a new Start is admitted normally"); - - let state = read_state(&git_dir).expect("state readable"); - let mut remaining: Vec<&str> = state - .attempts - .iter() - .map(|attempt| attempt.call_id.as_str()) - .collect(); - remaining.sort_unstable(); - assert_eq!( - remaining, - vec!["call_b", "call_c"], - "A removed, B kept, C admitted" - ); - assert!(state - .attempts - .iter() - .all(|attempt| attempt.phase == AttemptPhase::Active)); - assert!(state.recovery.is_clear()); - - cleanup(&git_dir); - } - - #[test] - fn regression_c_rebaseline_flush_failure_is_recoverable_without_poison() { - let git_dir = temp_git_dir("regression-c-rebaseline-flush-failure"); - - { - let ok_seam = RecordingSeam::new(); - drive(&git_dir, &ok_seam, &tool_before("edit", "call_1")).expect("Start"); - } - - let rebaseline_failing = RecordingSeam::failing_on_nth_occurrence("flush", 2); - drive(&git_dir, &rebaseline_failing, &tool_error("edit", "call_1")) - .expect("terminal failure whose rebaseline flush fails still returns"); - - assert_eq!( - rebaseline_failing.operations(), - vec!["flush", "abandon", "flush"], - "the ambiguity flush and the abandon succeeded; the rebaseline flush failed", - ); - let state = read_state(&git_dir).expect("state readable"); - assert!( - state.attempts.is_empty(), - "the abandon succeeded so the attempt is removed", - ); - assert!( - !state.recovery.is_clear(), - "recovery state still carries the outstanding rebaseline", - ); - - let healthy = RecordingSeam::new(); - drive(&git_dir, &healthy, &tool_before("edit", "call_2")).expect("retry admits new work"); - - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1); - assert_eq!(state.attempts[0].call_id, "call_2"); - assert!(state.recovery.is_clear()); - - cleanup(&git_dir); - } - - #[test] - fn regression_e_duplicate_tool_error_is_idempotent() { - let git_dir = temp_git_dir("regression-e-duplicate-tool-error"); - - { - let ok_seam = RecordingSeam::new(); - drive(&git_dir, &ok_seam, &tool_before("write", "call_1")).expect("Start"); - } - - let stuck = RecordingSeam::failing_on(&["abandon"]); - drive(&git_dir, &stuck, &tool_error("write", "call_1")).expect("first terminal failure"); - drive(&git_dir, &stuck, &tool_error("write", "call_1")) - .expect("a duplicate ToolError while PendingAbandon is idempotent"); - - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1, "no second attempt is created"); - assert_eq!(state.attempts[0].phase, AttemptPhase::PendingAbandon); - assert_eq!( - state.next_recovery_generation, 2, - "the recovery generation is not incremented by the duplicate", - ); - - cleanup(&git_dir); - } - - #[test] - fn regression_f_start_replay_for_a_pending_abandon_identity_never_reactivates() { - let git_dir = temp_git_dir("regression-f-start-replay-pending-abandon"); - - { - let ok_seam = RecordingSeam::new(); - drive(&git_dir, &ok_seam, &tool_before("write", "call_1")).expect("Start"); - } - - let stuck = RecordingSeam::failing_on(&["abandon"]); - drive(&git_dir, &stuck, &tool_error("write", "call_1")).expect("terminal failure"); - - let error = drive(&git_dir, &stuck, &tool_before("write", "call_1")) - .expect_err("a replayed Start for a PendingAbandon identity must fail closed"); - assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE)); - - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1); - assert_eq!( - state.attempts[0].phase, - AttemptPhase::PendingAbandon, - "the replayed Start does not return the attempt to Active", - ); - - cleanup(&git_dir); - } - - #[test] - fn regression_g_late_tool_error_after_close_is_a_harmless_no_op() { - let git_dir = temp_git_dir("regression-g-late-tool-error"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_before("write", "call_1")).expect("Start"); - drive(&git_dir, &seam, &tool_after("write", "call_1")).expect("Close"); - drive(&git_dir, &seam, &tool_error("write", "call_1")) - .expect("a late ToolError after a completed Close is inert"); - - assert_eq!(seam.operations(), vec!["start", "close"]); - let state = read_state(&git_dir).expect("state readable"); - assert!(state.attempts.is_empty()); - assert!(state.recovery.is_clear()); - - cleanup(&git_dir); - } - - #[test] - fn regression_h_siblings_stay_active_through_a_transient_cleanup_failure() { - let git_dir = temp_git_dir("regression-h-siblings-preserved"); - let live = RecordingSeam::new(); - - drive(&git_dir, &live, &shell_env("call_a")).expect("A Start"); - drive(&git_dir, &live, &shell_env("call_b")).expect("B Start"); - drive(&git_dir, &live, &shell_env("call_c")).expect("C Start"); - - let transient = RecordingSeam::failing_once_on(&["abandon"]); - drive(&git_dir, &transient, &tool_error("bash", "call_b")) - .expect("B terminal failure with a transient abandon failure"); - - let snapshot = read_state(&git_dir).expect("state readable"); - let mut siblings: Vec<&str> = snapshot - .attempts - .iter() - .filter(|attempt| attempt.phase == AttemptPhase::Active) - .map(|attempt| attempt.call_id.as_str()) - .collect(); - siblings.sort_unstable(); - assert_eq!( - siblings, - vec!["call_a", "call_c"], - "A and C stay Active mid-failure" - ); - - let healthy = RecordingSeam::new(); - drive(&git_dir, &healthy, &shell_env("call_d")) - .expect("the retry boundary resolves recovery and admits D"); - - let state = read_state(&git_dir).expect("state readable"); - let mut remaining: Vec<&str> = state - .attempts - .iter() - .map(|attempt| attempt.call_id.as_str()) - .collect(); - remaining.sort_unstable(); - assert_eq!( - remaining, - vec!["call_a", "call_c", "call_d"], - "B gone, A/C/D active" - ); - assert!(state - .attempts - .iter() - .all(|attempt| attempt.phase == AttemptPhase::Active)); - assert!(state.recovery.is_clear()); - - cleanup(&git_dir); - } -} +pub(crate) use lifecycle::run_opencode_mutation_scope_from_payload_at_state_root; +pub(crate) use lifecycle::run_opencode_mutation_scope_subcommand; +#[allow(unused_imports)] +pub(crate) use lifecycle::{repair_blocked, RepairOutcome}; #[cfg(test)] -mod runtime_seam_tests { - use std::fs; - use std::path::{Path, PathBuf}; - use std::process::Command; - - use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; - use crate::services::agent_trace_storage::{ - resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, - }; - use crate::services::mutation_trace::runtime::resolve_git_dir; - - use super::state::read_state; - use super::*; - - fn git(dir: &Path, args: &[&str]) { - let output = Command::new("git") - .args(args) - .current_dir(dir) - .output() - .expect("git should spawn"); - assert!( - output.status.success(), - "git {args:?} failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - - struct OpenCodeRepo { - temp: tempfile::TempDir, - root: PathBuf, - state_root: PathBuf, - } - - impl OpenCodeRepo { - fn new(label: &str) -> Self { - let temp = tempfile::Builder::new() - .prefix(&format!("sce-opencode-mutation-scope-seam-{label}-")) - .tempdir() - .expect("temp dir should be created"); - let root = temp.path().join("repo"); - fs::create_dir_all(&root).expect("repo dir should be created"); - git(&root, &["init", "-q"]); - git(&root, &["config", "user.email", "test@example.invalid"]); - git(&root, &["config", "user.name", "SCE Test"]); - git( - &root, - &["remote", "add", "origin", "git@github.com:acme/widgets.git"], - ); - fs::write(root.join("file.txt"), "one\n").expect("seed file should write"); - git(&root, &["add", "-A"]); - git(&root, &["commit", "-qm", "base"]); - - let state_root = temp.path().join("state"); - fs::create_dir_all(&state_root).expect("state root should be created"); - resolve_agent_trace_storage_at_state_root( - &AgentTraceStorageContext { - repository_root: &root, - explicit_repository_id: None, - repository_remote: "origin", - }, - &state_root, - ) - .expect("state-root storage should initialize the repository DB"); - - Self { - temp, - root, - state_root, - } - } - - fn cwd(&self) -> String { - self.root.to_string_lossy().into_owned() - } - - fn drive(&self, payload: &str) -> Result { - run_opencode_mutation_scope_from_payload_at_state_root(&self.state_root, payload, None) - } - - fn drive_failing_seam_operation_once( - &self, - payload: &str, - fail_operation: &str, - remaining_failures: &std::cell::Cell, - ) -> Result { - let resolver = |cwd: &str| resolve_git_dir(Path::new(cwd)); - let seam_fn = |root: &Path, seam_payload: &str, logger: Option<&dyn Logger>| { - let operation = serde_json::from_str::(seam_payload) - .ok() - .and_then(|value| { - value - .get("operation") - .and_then(|op| op.as_str()) - .map(str::to_owned) - }) - .unwrap_or_default(); - if operation == fail_operation && remaining_failures.get() > 0 { - remaining_failures.set(remaining_failures.get() - 1); - return Err(anyhow!("injected transient '{operation}' seam failure")); - } - crate::services::hooks::mutation_scope::run_mutation_scope_from_payload_at_state_root( - root, - &self.state_root, - seam_payload, - logger, - ) - }; - run_opencode_mutation_scope_from_payload_with_seams(payload, None, &resolver, &seam_fn) - } - - fn write(&self, name: &str, contents: &str) { - fs::write(self.root.join(name), contents).expect("write should succeed"); - } - - fn git_dir(&self) -> PathBuf { - resolve_git_dir(&self.root).expect("git dir should resolve") - } - - fn db(&self) -> RepositoryAgentTraceDb { - crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( - &self.root, - &self.state_root, - "opencode mutation-scope seam test assertions", - ) - .expect("assertion DB should open") - } - - fn scope_status(&self, scope_id: &str) -> Option<(String, String)> { - self.db() - .query_map( - "SELECT actor_kind, status FROM mutation_trace_scopes WHERE scope_id = ?1", - (scope_id,), - |row| { - let actor_kind = row.get::(0).map_err(anyhow::Error::from)?; - let status = row.get::(1).map_err(anyhow::Error::from)?; - Ok((actor_kind, status)) - }, - ) - .expect("scope query should succeed") - .into_iter() - .next() - } - - fn scope_count(&self) -> i64 { - self.db() - .query_map("SELECT COUNT(*) FROM mutation_trace_scopes", (), |row| { - row.get::(0).map_err(anyhow::Error::from) - }) - .expect("count query should succeed") - .into_iter() - .next() - .expect("a count row should exist") - } - - fn mutation_events(&self) -> Vec<(String, Option)> { - self.db() - .query_map( - "SELECT attribution_kind, attribution_scope_id \ - FROM mutation_trace_events ORDER BY revision", - (), - |row| { - let attribution_kind = row.get::(0).map_err(anyhow::Error::from)?; - let attribution_scope_id = - row.get::>(1).map_err(anyhow::Error::from)?; - Ok((attribution_kind, attribution_scope_id)) - }, - ) - .expect("mutation-events query should succeed") - } - } - - fn error(repo: &OpenCodeRepo, tool_name: &str, call_id: &str) -> String { - json!({ - "hook_event_name": "ToolError", - "session_id": "ses_seam", - "call_id": call_id, - "cwd": repo.cwd(), - "tool_name": tool_name, - }) - .to_string() - } - - fn before(repo: &OpenCodeRepo, tool_name: &str, call_id: &str) -> String { - json!({ - "hook_event_name": "ToolExecuteBefore", - "session_id": "ses_seam", - "call_id": call_id, - "cwd": repo.cwd(), - "tool_name": tool_name, - "model": "opencode/big-pickle", - }) - .to_string() - } - - fn after(repo: &OpenCodeRepo, tool_name: &str, call_id: &str) -> String { - json!({ - "hook_event_name": "ToolExecuteAfter", - "session_id": "ses_seam", - "call_id": call_id, - "cwd": repo.cwd(), - "tool_name": tool_name, - }) - .to_string() - } - - #[test] - fn a_write_start_then_after_closes_the_scope_through_the_real_runtime() { - let repo = OpenCodeRepo::new("write-start-close"); - - assert_eq!( - repo.drive(&before(&repo, "write", "call_1")) - .expect("Start"), - "" - ); - let scope_id = { - let state = read_state(&repo.git_dir()).expect("adapter state readable"); - assert_eq!(state.attempts.len(), 1); - state.attempts[0].scope_id.clone() - }; - - repo.write("file.txt", "one\ntwo\n"); - assert_eq!( - repo.drive(&after(&repo, "write", "call_1")).expect("Close"), - "" - ); - - assert!(read_state(&repo.git_dir()) - .expect("adapter state readable") - .attempts - .is_empty()); - assert_eq!( - repo.scope_status(&scope_id), - Some(("opencode".to_string(), "closed".to_string())), - ); - } - - #[test] - fn linked_worktree_uses_its_worktree_specific_git_dir_for_mutation_scope_state() { - let repo = OpenCodeRepo::new("linked-worktree"); - let linked_root = repo.temp.path().join("linked"); - git( - &repo.root, - &[ - "worktree", - "add", - "--quiet", - linked_root - .to_str() - .expect("linked worktree path should be UTF-8"), - ], - ); - - let main_git_dir = resolve_git_dir(&repo.root).expect("main git dir should resolve"); - let linked_git_dir = resolve_git_dir(&linked_root).expect("linked git dir should resolve"); - assert_ne!( - main_git_dir, linked_git_dir, - "linked worktrees must use distinct Git state locations", - ); - - let before = json!({ - "hook_event_name": "ToolExecuteBefore", - "session_id": "ses_linked", - "call_id": "call_linked", - "cwd": linked_root, - "tool_name": "write", - "model": "opencode/big-pickle", - }) - .to_string(); - run_opencode_mutation_scope_from_payload_at_state_root(&repo.state_root, &before, None) - .expect("linked-worktree Start"); - - let scope_id = read_state(&linked_git_dir) - .expect("linked adapter state should be readable") - .attempts[0] - .scope_id - .clone(); - fs::write(linked_root.join("file.txt"), "one\nlinked\n") - .expect("linked worktree file should be writable"); - - let after = json!({ - "hook_event_name": "ToolExecuteAfter", - "session_id": "ses_linked", - "call_id": "call_linked", - "cwd": linked_root, - "tool_name": "write", - }) - .to_string(); - run_opencode_mutation_scope_from_payload_at_state_root(&repo.state_root, &after, None) - .expect("linked-worktree Close"); - - assert!(read_state(&linked_git_dir) - .expect("linked adapter state should be readable") - .attempts - .is_empty()); - assert_eq!( - repo.scope_status(&scope_id), - Some(("opencode".to_string(), "closed".to_string())), - ); - } - - #[test] - fn a_tool_error_abandons_the_scope_through_the_real_runtime() { - let repo = OpenCodeRepo::new("tool-error-abandon"); - - repo.drive(&before(&repo, "edit", "call_1")).expect("Start"); - let scope_id = read_state(&repo.git_dir()) - .expect("adapter state readable") - .attempts[0] - .scope_id - .clone(); - - repo.write("file.txt", "one\nabandoned-a\n"); - repo.drive(&error(&repo, "edit", "call_1")) - .expect("terminal failure"); - - let state = read_state(&repo.git_dir()).expect("adapter state readable"); - assert!(state.attempts.is_empty()); - assert!( - state.recovery.is_clear(), - "a successful ambiguity flush clears the recovery barrier", - ); - assert_eq!( - repo.scope_status(&scope_id).map(|(_, status)| status), - Some("abandoned".to_string()), - ); - } - - #[test] - fn regression_a_failed_concurrent_scope_cannot_contaminate_a_survivor() { - let repo = OpenCodeRepo::new("regression-a-no-contamination"); - - repo.drive(&before(&repo, "write", "call_a")) - .expect("A Start"); - repo.drive(&before(&repo, "write", "call_b")) - .expect("B Start"); - let scope_b = read_state(&repo.git_dir()) - .expect("adapter state readable") - .attempts - .iter() - .find(|attempt| attempt.call_id == "call_b") - .expect("B is tracked") - .scope_id - .clone(); - - repo.write("file_a.txt", "a mutated\n"); - repo.write("file_b.txt", "b mutated\n"); - - repo.drive(&error(&repo, "write", "call_a")) - .expect("A terminal failure"); - - let state = read_state(&repo.git_dir()).expect("adapter state readable"); - assert_eq!(state.attempts.len(), 1, "B stays tracked after A's cleanup"); - assert_eq!(state.attempts[0].call_id, "call_b"); - assert_eq!(state.attempts[0].phase, super::state::AttemptPhase::Active); - - repo.drive(&after(&repo, "write", "call_b")) - .expect("B Close"); - - let events = repo.mutation_events(); - assert!( - events.iter().any(|(kind, _)| kind == "ineligible_unscoped"), - "the ambiguous interval containing A's mutations is consumed as \ - IneligibleUnscoped: {events:?}", - ); - assert!( - !events.iter().any(|(kind, scope)| kind == "ai_exclusive" - && scope.as_deref() == Some(scope_b.as_str())), - "B must never be attributed the interval that could contain A's changes: {events:?}", - ); - assert_eq!( - repo.scope_status(&scope_b).map(|(_, status)| status), - Some("closed".to_string()), - ); - } - - #[test] - fn regression_b_survivor_still_attributes_its_later_mutations() { - let repo = OpenCodeRepo::new("regression-b-survivor-liveness"); - - repo.drive(&before(&repo, "write", "call_a")) - .expect("A Start"); - repo.drive(&before(&repo, "write", "call_b")) - .expect("B Start"); - let scope_b = read_state(&repo.git_dir()) - .expect("adapter state readable") - .attempts - .iter() - .find(|attempt| attempt.call_id == "call_b") - .expect("B is tracked") - .scope_id - .clone(); - - repo.write("file_a.txt", "a mutated\n"); - repo.drive(&error(&repo, "write", "call_a")) - .expect("A terminal failure consumes the ambiguous interval"); - - repo.write("file_c.txt", "b's own later work\n"); - repo.drive(&after(&repo, "write", "call_b")) - .expect("B Close"); - - let events = repo.mutation_events(); - assert!( - events.iter().any(|(kind, scope)| kind == "ai_exclusive" - && scope.as_deref() == Some(scope_b.as_str())), - "after the ambiguity flush, B may legitimately attribute its own later \ - mutations: {events:?}", - ); - assert_eq!( - repo.scope_status(&scope_b).map(|(_, status)| status), - Some("closed".to_string()), - ); - } - - #[test] - fn regression_c_exact_error_does_not_sweep_siblings_through_the_real_runtime() { - let repo = OpenCodeRepo::new("regression-c-no-sibling-sweep"); - - repo.drive(&before(&repo, "write", "call_a")) - .expect("A Start"); - repo.drive(&before(&repo, "write", "call_b")) - .expect("B Start"); - repo.drive(&before(&repo, "write", "call_c")) - .expect("C Start"); - - repo.write("file.txt", "one\nmutated\n"); - repo.drive(&error(&repo, "write", "call_b")) - .expect("B terminal failure"); - - let state = read_state(&repo.git_dir()).expect("adapter state readable"); - let mut remaining: Vec<&str> = state - .attempts - .iter() - .map(|attempt| attempt.call_id.as_str()) - .collect(); - remaining.sort_unstable(); - assert_eq!(remaining, vec!["call_a", "call_c"], "A and C remain active"); - assert!(state - .attempts - .iter() - .all(|a| a.phase == super::state::AttemptPhase::Active)); - assert!(state.recovery.is_clear()); - } - - #[test] - fn regression_d_delayed_session_idle_cannot_kill_a_newer_call() { - let repo = OpenCodeRepo::new("regression-d-delayed-session-idle"); - - repo.drive(&before(&repo, "write", "call_b")) - .expect("newer call B Start"); - - repo.drive( - &json!({ - "hook_event_name": "SessionIdle", - "session_id": "ses_seam", - "cwd": repo.cwd(), - }) - .to_string(), - ) - .expect("a delayed SessionIdle for the same session is inert"); - - let state = read_state(&repo.git_dir()).expect("adapter state readable"); - assert_eq!(state.attempts.len(), 1); - assert_eq!(state.attempts[0].call_id, "call_b"); - assert_eq!(state.attempts[0].phase, super::state::AttemptPhase::Active); - assert!(state.recovery.is_clear()); - } - - #[test] - fn regression_e_server_disposed_cannot_sweep_another_process() { - let repo = OpenCodeRepo::new("regression-e-server-disposed"); - - repo.drive(&before(&repo, "write", "call_a")) - .expect("P1 owns call A"); - repo.drive( - &json!({ - "hook_event_name": "ToolExecuteBefore", - "session_id": "ses_p2", - "call_id": "call_b", - "cwd": repo.cwd(), - "tool_name": "write", - }) - .to_string(), - ) - .expect("P2 owns call B in the same checkout"); - - repo.drive(&json!({ "hook_event_name": "ServerDisposed", "cwd": repo.cwd() }).to_string()) - .expect("P1 server disposal is inert"); - - let state = read_state(&repo.git_dir()).expect("adapter state readable"); - assert_eq!( - state.attempts.len(), - 2, - "one process's disposal must not retire another process's live attempt", - ); - assert!(state.recovery.is_clear()); - } - - #[test] - fn regression_f_untracked_tool_error_is_zero_footprint() { - let repo = OpenCodeRepo::new("regression-f-untracked-tool-error"); - - for tool_name in ["read", "task", "brave-search_brave_web_search"] { - repo.drive(&error(&repo, tool_name, "call_x")) - .expect("an untracked ToolError is neutral"); - } - - assert_eq!(repo.scope_count(), 0); - assert!(!super::state::adapter_state_dir(&repo.git_dir()) - .join("opencode-mutation-scope-state.json") - .exists()); - } - - #[test] - fn untracked_events_never_reach_the_runtime_or_touch_adapter_state() { - let repo = OpenCodeRepo::new("untracked-zero-footprint"); - - assert_eq!( - repo.drive(&before(&repo, "read", "call_r")).expect("read"), - "" - ); - assert_eq!( - repo.drive(&before(&repo, "task", "call_t")).expect("task"), - "" - ); - - assert_eq!(repo.scope_count(), 0); - assert!(!super::state::adapter_state_dir(&repo.git_dir()) - .join("opencode-mutation-scope-state.json") - .exists()); - } - - #[test] - fn regression_d_concurrent_survivor_stays_usable_after_a_transient_cleanup_failure() { - let repo = OpenCodeRepo::new("regression-d-transient-cleanup-failure"); - - repo.drive(&before(&repo, "write", "call_a")) - .expect("A Start"); - repo.drive(&before(&repo, "write", "call_b")) - .expect("B Start"); - let scoped = read_state(&repo.git_dir()).expect("adapter state readable"); - let scope_a = scoped - .attempts - .iter() - .find(|attempt| attempt.call_id == "call_a") - .expect("A is tracked") - .scope_id - .clone(); - let scope_b = scoped - .attempts - .iter() - .find(|attempt| attempt.call_id == "call_b") - .expect("B is tracked") - .scope_id - .clone(); - - repo.write("file_a.txt", "a mutated\n"); - repo.write("file_b.txt", "b mutated\n"); - - let remaining_failures = std::cell::Cell::new(1_u32); - repo.drive_failing_seam_operation_once( - &error(&repo, "write", "call_a"), - "abandon", - &remaining_failures, - ) - .expect("A terminal failure with a transient abandon failure returns best-effort"); - - let state = read_state(&repo.git_dir()).expect("adapter state readable"); - assert_eq!( - state - .attempts - .iter() - .find(|attempt| attempt.call_id == "call_a") - .map(|attempt| attempt.phase), - Some(super::state::AttemptPhase::PendingAbandon), - "A's terminal intent survives the transient failure", - ); - assert_eq!( - state - .attempts - .iter() - .find(|attempt| attempt.call_id == "call_b") - .map(|attempt| attempt.phase), - Some(super::state::AttemptPhase::Active), - "B is untouched", - ); - assert!(!state.recovery.is_clear()); - - repo.drive(&error(&repo, "write", "call_a")) - .expect("a healthy duplicate ToolError retries and completes cleanup"); - - let state = read_state(&repo.git_dir()).expect("adapter state readable"); - let remaining: Vec<&str> = state - .attempts - .iter() - .map(|attempt| attempt.call_id.as_str()) - .collect(); - assert_eq!(remaining, vec!["call_b"], "A retired, B still live"); - assert_eq!(state.attempts[0].phase, super::state::AttemptPhase::Active); - assert!(state.recovery.is_clear()); - assert_eq!( - repo.scope_status(&scope_a).map(|(_, status)| status), - Some("abandoned".to_string()), - ); - - repo.write("file_c.txt", "b's own later work\n"); - repo.drive(&after(&repo, "write", "call_b")) - .expect("B Close"); - - let events = repo.mutation_events(); - assert!( - events.iter().any(|(kind, _)| kind == "ineligible_unscoped"), - "the ambiguous A/B interval is consumed as IneligibleUnscoped: {events:?}", - ); - assert!( - events.iter().any(|(kind, scope)| kind == "ai_exclusive" - && scope.as_deref() == Some(scope_b.as_str())), - "B still attributes its own later mutation once recovery completed: {events:?}", - ); - } -} +mod tests; diff --git a/cli/src/services/hooks/opencode_mutation_scope/payload.rs b/cli/src/services/hooks/opencode_mutation_scope/payload.rs new file mode 100644 index 000000000..ca2ab1051 --- /dev/null +++ b/cli/src/services/hooks/opencode_mutation_scope/payload.rs @@ -0,0 +1,43 @@ +use serde_json::json; + +use super::events::{OpenCodeScopeProvenance, ACTOR_KIND_OPENCODE}; + +pub(super) fn scope_boundary_payload(operation: &str, scope_id: &str, event_id: &str) -> String { + json!({ + "operation": operation, + "scope_id": scope_id, + "event_id": event_id, + "actor_kind": ACTOR_KIND_OPENCODE, + }) + .to_string() +} + +pub(super) fn scope_start_payload( + scope_id: &str, + event_id: &str, + provenance: &OpenCodeScopeProvenance, +) -> String { + json!({ + "operation": "start", + "scope_id": scope_id, + "event_id": event_id, + "actor_kind": ACTOR_KIND_OPENCODE, + "provenance": { + "session_id": provenance.session_id, + "model_id": provenance.model_id, + }, + }) + .to_string() +} + +pub(super) fn abandon_payload(scope_id: &str) -> String { + json!({ + "operation": "abandon", + "scope_id": scope_id, + }) + .to_string() +} + +pub(super) fn flush_payload() -> String { + json!({ "operation": "flush" }).to_string() +} diff --git a/cli/src/services/hooks/opencode_mutation_scope/state.rs b/cli/src/services/hooks/opencode_mutation_scope/state.rs index ec77f17ac..a8c0a0d85 100644 --- a/cli/src/services/hooks/opencode_mutation_scope/state.rs +++ b/cli/src/services/hooks/opencode_mutation_scope/state.rs @@ -8,6 +8,9 @@ use serde::{Deserialize, Serialize}; use super::os_lock::{AdvisoryLockError, OsAdvisoryLock}; use super::{format_opencode_scope_id, AttemptKey}; +use crate::services::hooks::mutation_scope_owner::{ + current_process_owner, is_definitely_dead, ProcessOwner, +}; const SCE_STATE_DIR: &str = "sce"; const ADAPTER_STATE_FILE: &str = "opencode-mutation-scope-state.json"; @@ -52,6 +55,8 @@ pub(crate) struct AdapterAttempt { pub call_id: String, pub tool_name: String, pub phase: AttemptPhase, + #[serde(default)] + pub owner: Option, } impl AdapterAttempt { @@ -246,6 +251,7 @@ fn allocate_pending_start( call_id: key.call_id.clone(), tool_name: tool_name.to_string(), phase: AttemptPhase::PendingStart, + owner: Some(current_process_owner()), }; state.attempts.push(attempt.clone()); attempt @@ -355,10 +361,10 @@ pub(crate) fn arm_recovery(git_dir: &Path) -> Result { Ok(generation) } -pub(crate) fn begin_terminal_cleanup(git_dir: &Path, scope_ids: &[String]) -> Result { - let _lock = acquire_lock(git_dir)?; - let mut state = read_state(git_dir)?; - +fn transition_to_pending_abandon_and_arm_flush( + state: &mut AdapterState, + scope_ids: &[String], +) -> u64 { for attempt in &mut state.attempts { if scope_ids .iter() @@ -379,10 +385,52 @@ pub(crate) fn begin_terminal_cleanup(git_dir: &Path, scope_ids: &[String]) -> Re } }; state.recovery = RecoveryState::Flushing { generation }; + generation +} + +pub(crate) fn begin_terminal_cleanup(git_dir: &Path, scope_ids: &[String]) -> Result { + let _lock = acquire_lock(git_dir)?; + let mut state = read_state(git_dir)?; + + let generation = transition_to_pending_abandon_and_arm_flush(&mut state, scope_ids); write_state_durably(git_dir, &state)?; Ok(generation) } +pub(crate) fn reprove_dead_owner_pending_start_and_begin_repair( + git_dir: &Path, +) -> Result> { + let _lock = acquire_lock(git_dir)?; + let mut state = read_state(git_dir)?; + + let pending_start: Vec<&AdapterAttempt> = state + .attempts + .iter() + .filter(|attempt| attempt.phase == AttemptPhase::PendingStart) + .collect(); + + if pending_start.is_empty() { + return Ok(None); + } + + let every_owner_is_positively_dead = pending_start + .iter() + .all(|attempt| attempt.owner.as_ref().is_some_and(is_definitely_dead)); + + if !every_owner_is_positively_dead { + return Ok(None); + } + + let dead_scope_ids: Vec = pending_start + .iter() + .map(|attempt| attempt.scope_id.clone()) + .collect(); + + let generation = transition_to_pending_abandon_and_arm_flush(&mut state, &dead_scope_ids); + write_state_durably(git_dir, &state)?; + Ok(Some(generation)) +} + pub(crate) fn arm_and_begin_recovery_flush(git_dir: &Path) -> Result { let _lock = acquire_lock(git_dir)?; let mut state = read_state(git_dir)?; @@ -449,6 +497,25 @@ pub(crate) fn seed_attempt_for_tests( attempt } +#[cfg(test)] +pub(crate) fn set_attempt_owner_for_tests( + git_dir: &Path, + scope_id: &str, + owner: Option, +) -> AdapterAttempt { + let _lock = acquire_lock(git_dir).expect("test owner-override lock"); + let mut state = read_state(git_dir).expect("test owner-override read"); + let attempt = state + .attempts + .iter_mut() + .find(|attempt| attempt.scope_id == scope_id) + .expect("attempt to override must already exist"); + attempt.owner = owner; + let updated = attempt.clone(); + write_state_durably(git_dir, &state).expect("test owner-override write"); + updated +} + #[cfg(test)] mod tests { use std::sync::atomic::{AtomicU64, Ordering}; diff --git a/cli/src/services/hooks/opencode_mutation_scope/tests.rs b/cli/src/services/hooks/opencode_mutation_scope/tests.rs new file mode 100644 index 000000000..53f40a7a5 --- /dev/null +++ b/cli/src/services/hooks/opencode_mutation_scope/tests.rs @@ -0,0 +1,1993 @@ +use super::events::{ + classify_tool, format_opencode_scope_id, opencode_scope_close_event_id, + opencode_scope_provenance, opencode_scope_start_event_id, parse_opencode_hook_event, + AttemptKey, OpenCodeHookEvent, OpenCodeToolExecution, OpenCodeToolIdentity, ToolClassification, + CALL_ID_FIELD, CWD_FIELD, HOOK_EVENT_NAME_FIELD, HOOK_EVENT_SERVER_DISPOSED, + HOOK_EVENT_SESSION_DELETED, HOOK_EVENT_SESSION_ERROR, HOOK_EVENT_SESSION_IDLE, + HOOK_EVENT_SHELL_ENV, HOOK_EVENT_TOOL_ERROR, HOOK_EVENT_TOOL_EXECUTE_AFTER, + HOOK_EVENT_TOOL_EXECUTE_BEFORE, MODEL_FIELD, SESSION_ID_FIELD, TOOL_NAME_FIELD, +}; +use super::lifecycle::{run_opencode_mutation_scope_from_payload, FAIL_CLOSED_MESSAGE}; +use serde_json::{Map, Value}; + +fn tool_event_json(hook_event_name: &str, overrides: &[(&str, Value)]) -> String { + let mut object = Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(hook_event_name.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("ses_main".to_string()), + ); + object.insert( + CALL_ID_FIELD.to_string(), + Value::String("call_1".to_string()), + ); + object.insert( + CWD_FIELD.to_string(), + Value::String("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/repo/checkout".to_string()), + ); + object.insert( + TOOL_NAME_FIELD.to_string(), + Value::String("write".to_string()), + ); + for (field, value) in overrides { + object.insert((*field).to_string(), value.clone()); + } + Value::Object(object).to_string() +} + +fn key(session_id: &str, call_id: &str) -> AttemptKey { + AttemptKey { + session_id: session_id.to_string(), + call_id: call_id.to_string(), + } +} + +fn tool_execution(payload: &str) -> OpenCodeToolExecution { + match parse_opencode_hook_event(payload).expect("valid ToolExecuteBefore parses") { + OpenCodeHookEvent::ToolExecuteBefore(execution) => execution, + other => panic!("expected ToolExecuteBefore, got {other:?}"), + } +} + +#[test] +fn empty_payload_is_rejected() { + let error = parse_opencode_hook_event(" ").unwrap_err().to_string(); + assert_eq!( + error, + "Invalid OpenCode hook event payload from STDIN: expected a JSON object, got an empty payload." + ); +} + +#[test] +fn non_object_json_is_rejected() { + for payload in ["[]", "\"ToolExecuteBefore\"", "42", "null"] { + let error = parse_opencode_hook_event(payload).unwrap_err().to_string(); + assert!( + error.contains("expected a JSON object"), + "payload {payload:?} produced {error:?}" + ); + } +} + +#[test] +fn invalid_json_is_rejected() { + let error = parse_opencode_hook_event("{not json") + .unwrap_err() + .to_string(); + assert!( + error.contains("Invalid OpenCode hook event payload from STDIN: expected valid JSON"), + "{error:?}" + ); +} + +#[test] +fn unsupported_hook_event_name_is_rejected() { + for name in ["PreToolUse", "ToolExecute", "chat.params", ""] { + let payload = tool_event_json(name, &[]); + let error = parse_opencode_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains("hook_event_name"), + "name {name:?} produced {error:?}" + ); + } +} + +#[test] +fn missing_required_fields_are_rejected_without_fabricating_identity() { + for field in [SESSION_ID_FIELD, CALL_ID_FIELD, CWD_FIELD, TOOL_NAME_FIELD] { + let mut object: Map = + serde_json::from_str(&tool_event_json(HOOK_EVENT_TOOL_EXECUTE_BEFORE, &[])).unwrap(); + object.remove(field); + let payload = Value::Object(object).to_string(); + + let error = parse_opencode_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains(&format!("'{field}'")), + "missing {field} produced {error:?}" + ); + } +} + +#[test] +fn blank_required_fields_are_rejected() { + for field in [SESSION_ID_FIELD, CALL_ID_FIELD, CWD_FIELD, TOOL_NAME_FIELD] { + let payload = tool_event_json( + HOOK_EVENT_TOOL_EXECUTE_BEFORE, + &[(field, Value::String(" ".to_string()))], + ); + let error = parse_opencode_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains(&format!("field '{field}' must be a non-blank string")), + "blank {field} produced {error:?}" + ); + } +} + +#[test] +fn wrong_typed_fields_are_rejected() { + let payload = tool_event_json( + HOOK_EVENT_TOOL_EXECUTE_BEFORE, + &[(CALL_ID_FIELD, Value::Bool(true))], + ); + let error = parse_opencode_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains("field 'call_id' must be a string"), + "{error:?}" + ); +} + +#[test] +fn wrong_typed_optional_model_is_rejected() { + let payload = tool_event_json( + HOOK_EVENT_TOOL_EXECUTE_BEFORE, + &[(MODEL_FIELD, Value::Bool(false))], + ); + let error = parse_opencode_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains("field 'model' must be null, absent, or a non-blank string"), + "{error:?}" + ); +} + +#[test] +fn tool_execute_before_parses_identity_and_model() { + let execution = tool_execution(&tool_event_json( + HOOK_EVENT_TOOL_EXECUTE_BEFORE, + &[ + (TOOL_NAME_FIELD, Value::String("edit".to_string())), + ( + MODEL_FIELD, + Value::String("opencode/big-pickle".to_string()), + ), + ], + )); + assert_eq!(execution.identity.session_id, "ses_main"); + assert_eq!(execution.identity.call_id, "call_1"); + assert_eq!(execution.identity.tool_name, "edit"); + assert_eq!(execution.model.as_deref(), Some("opencode/big-pickle")); + assert_eq!( + execution.identity.classification(), + ToolClassification::TrackedMutation + ); +} + +#[test] +fn tool_execute_before_model_is_optional() { + let execution = tool_execution(&tool_event_json(HOOK_EVENT_TOOL_EXECUTE_BEFORE, &[])); + assert_eq!(execution.model, None); +} + +#[test] +fn shell_env_parses_without_a_tool_name() { + let mut object = Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(HOOK_EVENT_SHELL_ENV.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("ses_main".to_string()), + ); + object.insert( + CALL_ID_FIELD.to_string(), + Value::String("call_bash".to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String("/repo".to_string())); + object.insert( + MODEL_FIELD.to_string(), + Value::String("opencode/big-pickle".to_string()), + ); + let payload = Value::Object(object).to_string(); + + let OpenCodeHookEvent::ShellEnv(shell) = parse_opencode_hook_event(&payload).unwrap() else { + panic!("expected ShellEnv"); + }; + assert_eq!(shell.call_id, "call_bash"); + assert_eq!(shell.model.as_deref(), Some("opencode/big-pickle")); + assert_eq!(shell.attempt_key(), key("ses_main", "call_bash")); +} + +#[test] +fn tool_execute_after_parses_tool_identity() { + let OpenCodeHookEvent::ToolExecuteAfter(identity) = + parse_opencode_hook_event(&tool_event_json(HOOK_EVENT_TOOL_EXECUTE_AFTER, &[])).unwrap() + else { + panic!("expected ToolExecuteAfter"); + }; + assert_eq!(identity.attempt_key(), key("ses_main", "call_1")); + assert_eq!(identity.tool_name, "write"); +} + +#[test] +fn terminal_events_parse_their_minimal_identity() { + for name in [ + HOOK_EVENT_SESSION_IDLE, + HOOK_EVENT_SESSION_ERROR, + HOOK_EVENT_SESSION_DELETED, + ] { + let mut object = Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(name.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("ses_main".to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String("/repo".to_string())); + let payload = Value::Object(object).to_string(); + + let event = parse_opencode_hook_event(&payload).unwrap(); + let identity = match event { + OpenCodeHookEvent::SessionIdle(identity) + | OpenCodeHookEvent::SessionError(identity) + | OpenCodeHookEvent::SessionDeleted(identity) => identity, + other => panic!("expected a session-identity event, got {other:?}"), + }; + assert_eq!(identity.session_id, "ses_main"); + assert_eq!(identity.cwd, "/repo"); + } + + let mut object = Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(HOOK_EVENT_TOOL_ERROR.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("ses_main".to_string()), + ); + object.insert( + CALL_ID_FIELD.to_string(), + Value::String("call_1".to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String("/repo".to_string())); + object.insert( + TOOL_NAME_FIELD.to_string(), + Value::String("write".to_string()), + ); + let OpenCodeHookEvent::ToolError(identity) = + parse_opencode_hook_event(&Value::Object(object).to_string()).unwrap() + else { + panic!("expected ToolError"); + }; + assert_eq!(identity.attempt_key(), key("ses_main", "call_1")); + assert_eq!( + identity.classification(), + ToolClassification::TrackedMutation + ); + + let mut object = Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(HOOK_EVENT_SERVER_DISPOSED.to_string()), + ); + object.insert(CWD_FIELD.to_string(), Value::String("/repo".to_string())); + let OpenCodeHookEvent::ServerDisposed(workspace) = + parse_opencode_hook_event(&Value::Object(object).to_string()).unwrap() + else { + panic!("expected ServerDisposed"); + }; + assert_eq!(workspace.cwd, "/repo"); +} + +#[test] +fn classification_table() { + let cases: &[(&str, ToolClassification)] = &[ + ("bash", ToolClassification::TrackedMutation), + ("write", ToolClassification::TrackedMutation), + ("edit", ToolClassification::TrackedMutation), + ("apply_patch", ToolClassification::TrackedMutation), + ("task", ToolClassification::Delegation), + ("read", ToolClassification::Untracked), + ("glob", ToolClassification::Untracked), + ("grep", ToolClassification::Untracked), + ("webfetch", ToolClassification::Untracked), + ("websearch", ToolClassification::Untracked), + ("todowrite", ToolClassification::Untracked), + ("probe_mutate", ToolClassification::Untracked), + ( + "brave-search_brave_web_search", + ToolClassification::Untracked, + ), + ("Bash", ToolClassification::Untracked), + ("some_future_opencode_tool", ToolClassification::Untracked), + ("", ToolClassification::Untracked), + ]; + for (tool_name, expected) in cases { + assert_eq!( + classify_tool(tool_name), + *expected, + "classify_tool({tool_name:?})" + ); + } +} + +#[test] +fn classification_is_total_and_single_valued() { + for tool_name in ["bash", "write", "edit", "apply_patch", "task", "read", "x"] { + let _: ToolClassification = classify_tool(tool_name); + } +} + +#[test] +fn scope_id_is_deterministic_for_the_same_key() { + let k = key("ses_main", "call_1"); + assert_eq!(format_opencode_scope_id(&k), format_opencode_scope_id(&k)); + + let scope_id = format_opencode_scope_id(&k); + assert_eq!(scope_id, "oc-tool-v1|s=8:ses_main|c=6:call_1"); + assert_eq!( + opencode_scope_start_event_id(&scope_id), + format!("{scope_id}|start") + ); + assert_eq!( + opencode_scope_close_event_id(&scope_id), + format!("{scope_id}|close") + ); + assert_ne!( + opencode_scope_start_event_id(&scope_id), + opencode_scope_close_event_id(&scope_id) + ); +} + +#[test] +fn duplicate_events_reuse_the_same_scope_id() { + let payload = tool_event_json( + HOOK_EVENT_TOOL_EXECUTE_BEFORE, + &[(TOOL_NAME_FIELD, Value::String("bash".to_string()))], + ); + let first = tool_execution(&payload).identity.attempt_key(); + let second = tool_execution(&payload).identity.attempt_key(); + assert_eq!( + format_opencode_scope_id(&first), + format_opencode_scope_id(&second) + ); +} + +#[test] +fn length_prefix_disambiguates_delimiter_collisions() { + let a = key("s|c=1:x", "y"); + let b = key("s", "1:x|y"); + assert_ne!(format_opencode_scope_id(&a), format_opencode_scope_id(&b)); + + let tricky = key("ses|c=0:x", "call:with:colons"); + assert_eq!( + format_opencode_scope_id(&tricky), + format!( + "oc-tool-v1|s={}:{}|c={}:{}", + tricky.session_id.len(), + tricky.session_id, + tricky.call_id.len(), + tricky.call_id, + ) + ); +} + +#[test] +fn parallel_call_ids_in_one_session_stay_distinguishable() { + let a = tool_execution(&tool_event_json( + HOOK_EVENT_TOOL_EXECUTE_BEFORE, + &[ + (TOOL_NAME_FIELD, Value::String("bash".to_string())), + (CALL_ID_FIELD, Value::String("call_a".to_string())), + ], + )); + let b = tool_execution(&tool_event_json( + HOOK_EVENT_TOOL_EXECUTE_BEFORE, + &[ + (TOOL_NAME_FIELD, Value::String("bash".to_string())), + (CALL_ID_FIELD, Value::String("call_b".to_string())), + ], + )); + assert_ne!(a.identity.attempt_key(), b.identity.attempt_key()); + assert_ne!( + format_opencode_scope_id(&a.identity.attempt_key()), + format_opencode_scope_id(&b.identity.attempt_key()) + ); +} + +#[test] +fn task_child_session_identity_flows_through_the_attempt_key() { + let child = tool_execution(&tool_event_json( + HOOK_EVENT_TOOL_EXECUTE_BEFORE, + &[ + (TOOL_NAME_FIELD, Value::String("bash".to_string())), + (SESSION_ID_FIELD, Value::String("ses_child".to_string())), + (CALL_ID_FIELD, Value::String("call_child".to_string())), + ], + )); + assert_eq!(child.identity.attempt_key(), key("ses_child", "call_child")); + assert_ne!( + format_opencode_scope_id(&child.identity.attempt_key()), + format_opencode_scope_id(&key("ses_main", "call_child")) + ); +} + +#[test] +fn attempt_key_projects_only_session_and_call() { + let identity_a = OpenCodeToolIdentity { + session_id: "ses_main".to_string(), + call_id: "call_1".to_string(), + cwd: "/repo".to_string(), + tool_name: "write".to_string(), + }; + let identity_b = OpenCodeToolIdentity { + tool_name: "bash".to_string(), + cwd: "/other".to_string(), + ..identity_a.clone() + }; + assert_eq!(identity_a.attempt_key(), identity_b.attempt_key()); +} + +#[test] +fn provenance_canonicalizes_the_session_and_normalizes_the_model() { + let provenance = opencode_scope_provenance("ses_main", Some("opencode/big-pickle")); + assert_eq!(provenance.session_id, "oc_ses_main"); + assert_eq!(provenance.model_id.as_deref(), Some("opencode/big-pickle")); +} + +#[test] +fn provenance_keeps_an_already_prefixed_session_id() { + let provenance = opencode_scope_provenance("oc_ses_main", None); + assert_eq!(provenance.session_id, "oc_ses_main"); +} + +#[test] +fn provenance_without_model_evidence_is_null() { + for model in [None, Some(""), Some(" ")] { + let provenance = opencode_scope_provenance("ses_main", model); + assert_eq!(provenance.model_id, None, "model {model:?}"); + assert_eq!(provenance.session_id, "oc_ses_main", "model {model:?}"); + } +} + +#[test] +fn provenance_is_built_from_a_parsed_start_event() { + let execution = tool_execution(&tool_event_json( + HOOK_EVENT_TOOL_EXECUTE_BEFORE, + &[ + (TOOL_NAME_FIELD, Value::String("write".to_string())), + ( + MODEL_FIELD, + Value::String("opencode/big-pickle".to_string()), + ), + ], + )); + let provenance = + opencode_scope_provenance(&execution.identity.session_id, execution.model.as_deref()); + assert_eq!(provenance.session_id, "oc_ses_main"); + assert_eq!(provenance.model_id.as_deref(), Some("opencode/big-pickle")); +} + +#[test] +fn run_from_payload_fails_closed_when_a_tracked_start_cannot_resolve_its_checkout() { + let payload = tool_event_json( + HOOK_EVENT_TOOL_EXECUTE_BEFORE, + &[ + (TOOL_NAME_FIELD, Value::String("write".to_string())), + ( + CWD_FIELD, + Value::String("/nonexistent/sce/opencode/checkout".to_string()), + ), + ], + ); + let error = run_opencode_mutation_scope_from_payload(&payload, None) + .expect_err("a tracked Start that cannot resolve its checkout must fail closed"); + assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE), "{error:?}"); +} + +#[test] +fn run_from_payload_is_neutral_for_untracked_and_delegation_events() { + for tool_name in ["read", "task", "probe_mutate"] { + let payload = tool_event_json( + HOOK_EVENT_TOOL_EXECUTE_BEFORE, + &[(TOOL_NAME_FIELD, Value::String(tool_name.to_string()))], + ); + assert_eq!( + run_opencode_mutation_scope_from_payload(&payload, None).unwrap(), + String::new() + ); + } +} + +#[test] +fn run_from_payload_surfaces_malformed_input() { + let error = run_opencode_mutation_scope_from_payload("{bad", None) + .unwrap_err() + .to_string(); + assert!(error.contains("expected valid JSON"), "{error:?}"); +} + +mod lifecycle_tests { + use std::path::{Path, PathBuf}; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::Mutex; + + use anyhow::{bail, Result}; + use serde_json::{json, Value}; + + use crate::services::observability::traits::Logger; + + use super::super::lifecycle::{ + run_opencode_mutation_scope_from_payload_with_seams, FAIL_CLOSED_MESSAGE, + }; + use super::super::state::{read_state, AttemptPhase, RecoveryState}; + + static NEXT_ID: AtomicU64 = AtomicU64::new(0); + + fn temp_git_dir(label: &str) -> PathBuf { + let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "sce-opencode-mutation-scope-lifecycle-{label}-{}-{id}", + std::process::id() + )) + } + + const CWD: &str = "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/repo/opencode-checkout"; + + struct RecordingSeam { + calls: Mutex>, + fail_operations: Vec, + fail_once_operations: Mutex>, + fail_operation_occurrence: Option<(String, usize)>, + } + + impl RecordingSeam { + fn new() -> Self { + Self { + calls: Mutex::new(Vec::new()), + fail_operations: Vec::new(), + fail_once_operations: Mutex::new(Vec::new()), + fail_operation_occurrence: None, + } + } + + fn failing_on(operations: &[&str]) -> Self { + Self { + fail_operations: operations.iter().map(|op| (*op).to_string()).collect(), + ..Self::new() + } + } + + fn failing_once_on(operations: &[&str]) -> Self { + Self { + fail_once_operations: Mutex::new( + operations.iter().map(|op| (*op).to_string()).collect(), + ), + ..Self::new() + } + } + + fn failing_on_nth_occurrence(operation: &str, occurrence: usize) -> Self { + Self { + fail_operation_occurrence: Some((operation.to_string(), occurrence)), + ..Self::new() + } + } + + fn handle(&self, payload: &str) -> Result { + let operation = operation_of(payload); + let occurrence = { + let mut calls = self.calls.lock().expect("seam mutex"); + calls.push(operation.clone()); + calls + .iter() + .filter(|candidate| *candidate == &operation) + .count() + }; + if self.fail_operations.contains(&operation) { + bail!("seam failure injected by test for '{operation}'"); + } + if let Some((target, target_occurrence)) = &self.fail_operation_occurrence { + if target == &operation && *target_occurrence == occurrence { + bail!( + "seam failure injected by test for '{operation}' occurrence {occurrence}" + ); + } + } + { + let mut once = self.fail_once_operations.lock().expect("seam mutex"); + if let Some(position) = once.iter().position(|candidate| candidate == &operation) { + once.remove(position); + bail!("transient seam failure injected once by test for '{operation}'"); + } + } + Ok(String::new()) + } + + fn operations(&self) -> Vec { + self.calls.lock().expect("seam mutex").clone() + } + } + + fn operation_of(payload: &str) -> String { + let value: Value = serde_json::from_str(payload).expect("seam payload is JSON"); + value + .get("operation") + .and_then(Value::as_str) + .expect("seam payload has an operation") + .to_string() + } + + fn drive(git_dir: &Path, seam: &RecordingSeam, payload: &str) -> Result { + let resolver = |_cwd: &str| Ok(git_dir.to_path_buf()); + let seam_fn = + |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| seam.handle(payload); + run_opencode_mutation_scope_from_payload_with_seams(payload, None, &resolver, &seam_fn) + } + + fn tool_before(tool_name: &str, call_id: &str) -> String { + json!({ + "hook_event_name": "ToolExecuteBefore", + "session_id": "ses_main", + "call_id": call_id, + "cwd": CWD, + "tool_name": tool_name, + "model": "opencode/big-pickle", + }) + .to_string() + } + + fn shell_env(call_id: &str) -> String { + json!({ + "hook_event_name": "ShellEnv", + "session_id": "ses_main", + "call_id": call_id, + "cwd": CWD, + "model": "opencode/big-pickle", + }) + .to_string() + } + + fn tool_after(tool_name: &str, call_id: &str) -> String { + json!({ + "hook_event_name": "ToolExecuteAfter", + "session_id": "ses_main", + "call_id": call_id, + "cwd": CWD, + "tool_name": tool_name, + }) + .to_string() + } + + fn tool_error(tool_name: &str, call_id: &str) -> String { + json!({ + "hook_event_name": "ToolError", + "session_id": "ses_main", + "call_id": call_id, + "cwd": CWD, + "tool_name": tool_name, + }) + .to_string() + } + + fn session_event(hook_event_name: &str, session_id: &str) -> String { + json!({ + "hook_event_name": hook_event_name, + "session_id": session_id, + "cwd": CWD, + }) + .to_string() + } + + fn server_disposed() -> String { + json!({ "hook_event_name": "ServerDisposed", "cwd": CWD }).to_string() + } + + fn cleanup(git_dir: &Path) { + let _ = std::fs::remove_dir_all(git_dir); + } + + #[test] + fn file_tool_before_establishes_a_write_ahead_start_and_replays_idempotently() { + let git_dir = temp_git_dir("write-ahead-start"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_before("write", "call_1")).expect("first Start"); + drive(&git_dir, &seam, &tool_before("write", "call_1")) + .expect("duplicate Start is a no-op"); + + assert_eq!(seam.operations(), vec!["start"]); + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].phase, AttemptPhase::Active); + assert_eq!(state.attempts[0].tool_name, "write"); + + cleanup(&git_dir); + } + + #[test] + fn bash_start_is_anchored_to_shell_env_not_tool_execute_before() { + let git_dir = temp_git_dir("bash-shell-env"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_before("bash", "call_bash")).expect("bash before is inert"); + assert!(seam.operations().is_empty()); + assert!(read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); + + drive(&git_dir, &seam, &shell_env("call_bash")).expect("shell.env establishes Start"); + assert_eq!(seam.operations(), vec!["start"]); + assert_eq!( + read_state(&git_dir).expect("state readable").attempts[0].phase, + AttemptPhase::Active, + ); + + cleanup(&git_dir); + } + + #[test] + fn concurrent_bash_calls_in_one_session_stay_separate_live_scopes() { + let git_dir = temp_git_dir("concurrent-bash"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &shell_env("call_a")).expect("A Start"); + drive(&git_dir, &seam, &shell_env("call_b")).expect("B Start must not retire A"); + + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 2); + assert!(state + .attempts + .iter() + .all(|a| a.phase == AttemptPhase::Active)); + assert_eq!(seam.operations(), vec!["start", "start"]); + + cleanup(&git_dir); + } + + #[test] + fn successful_after_closes_exactly_that_attempt_and_replays_as_a_no_op() { + let git_dir = temp_git_dir("close-replay"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_before("edit", "call_1")).expect("Start"); + drive(&git_dir, &seam, &tool_after("edit", "call_1")).expect("Close"); + drive(&git_dir, &seam, &tool_after("edit", "call_1")).expect("duplicate Close is a no-op"); + + assert_eq!(seam.operations(), vec!["start", "close"]); + assert!(read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); + + cleanup(&git_dir); + } + + #[test] + fn tool_error_retires_the_named_attempt_and_consumes_the_ambiguous_interval() { + let git_dir = temp_git_dir("tool-error-consume"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &shell_env("call_a")).expect("A Start"); + drive(&git_dir, &seam, &shell_env("call_b")).expect("B Start"); + drive(&git_dir, &seam, &tool_error("bash", "call_a")).expect("A terminal failure"); + + assert_eq!( + seam.operations(), + vec!["start", "start", "flush", "abandon", "flush"], + "the ambiguous interval is flushed before A is abandoned, then the abandon \ + rebaseline is flushed away so B keeps its future intervals", + ); + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].call_id, "call_b"); + assert_eq!(state.attempts[0].phase, AttemptPhase::Active); + assert!( + state.recovery.is_clear(), + "a successful ambiguity flush clears the recovery barrier while B stays live", + ); + + cleanup(&git_dir); + } + + #[test] + fn exact_error_retires_only_the_named_sibling() { + let git_dir = temp_git_dir("exact-error-siblings"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &shell_env("call_a")).expect("A Start"); + drive(&git_dir, &seam, &shell_env("call_b")).expect("B Start"); + drive(&git_dir, &seam, &shell_env("call_c")).expect("C Start"); + drive(&git_dir, &seam, &tool_error("bash", "call_b")).expect("B terminal failure"); + + let state = read_state(&git_dir).expect("state readable"); + let mut remaining: Vec<&str> = state + .attempts + .iter() + .map(|attempt| attempt.call_id.as_str()) + .collect(); + remaining.sort_unstable(); + assert_eq!(remaining, vec!["call_a", "call_c"]); + assert!(state + .attempts + .iter() + .all(|a| a.phase == AttemptPhase::Active)); + assert!(state.recovery.is_clear()); + assert_eq!( + seam.operations() + .iter() + .filter(|op| *op == "abandon") + .count(), + 1, + "abandoning B must not sweep A or C", + ); + + cleanup(&git_dir); + } + + #[test] + fn a_close_before_start_confirmation_consumes_rather_than_closes() { + let git_dir = temp_git_dir("pending-start-close"); + let seam = RecordingSeam::failing_on(&["start"]); + + let error = drive(&git_dir, &seam, &tool_before("write", "call_1")) + .expect_err("a failed Start seam must fail closed"); + assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE)); + assert_eq!( + read_state(&git_dir).expect("state readable").attempts[0].phase, + AttemptPhase::PendingStart, + ); + + let ok_seam = RecordingSeam::new(); + drive(&git_dir, &ok_seam, &tool_after("write", "call_1")).expect("After on a PendingStart"); + assert_eq!(ok_seam.operations(), vec!["flush", "abandon", "flush"]); + assert!(read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); + + cleanup(&git_dir); + } + + #[test] + fn session_idle_is_non_destructive() { + let git_dir = temp_git_dir("session-idle-noop"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &shell_env("call_a")).expect("A Start"); + drive( + &git_dir, + &seam, + &json!({ + "hook_event_name": "ShellEnv", + "session_id": "ses_other", + "call_id": "call_c", + "cwd": CWD, + }) + .to_string(), + ) + .expect("other-session Start"); + + for name in ["SessionIdle", "SessionError", "SessionDeleted"] { + drive(&git_dir, &seam, &session_event(name, "ses_main")).expect("broad event is inert"); + } + + let state = read_state(&git_dir).expect("state readable"); + assert_eq!( + state.attempts.len(), + 2, + "no attempt is retired by a broad event" + ); + assert!(state + .attempts + .iter() + .all(|a| a.phase == AttemptPhase::Active)); + assert!(state.recovery.is_clear()); + assert_eq!(seam.operations(), vec!["start", "start"]); + + cleanup(&git_dir); + } + + #[test] + fn delayed_session_idle_cannot_retire_a_newer_call() { + let git_dir = temp_git_dir("delayed-session-idle"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &shell_env("call_b")).expect("newer call B Start"); + drive(&git_dir, &seam, &session_event("SessionIdle", "ses_main")) + .expect("a delayed SessionIdle for the same session arrives after B started"); + + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].call_id, "call_b"); + assert_eq!(state.attempts[0].phase, AttemptPhase::Active); + assert!(state.recovery.is_clear()); + assert!(!seam.operations().iter().any(|op| op == "abandon")); + + cleanup(&git_dir); + } + + #[test] + fn server_disposed_cannot_sweep_another_processes_attempt() { + let git_dir = temp_git_dir("server-disposed-noop"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &shell_env("call_a")).expect("process P1 owns call A"); + drive( + &git_dir, + &seam, + &json!({ + "hook_event_name": "ShellEnv", + "session_id": "ses_other", + "call_id": "call_b", + "cwd": CWD, + }) + .to_string(), + ) + .expect("process P2 owns call B in the same checkout"); + + drive(&git_dir, &seam, &server_disposed()).expect("P1 server disposal is inert"); + + let state = read_state(&git_dir).expect("state readable"); + assert_eq!( + state.attempts.len(), + 2, + "one process's disposal must not retire another process's live attempt", + ); + assert!(state.recovery.is_clear()); + assert_eq!(seam.operations(), vec!["start", "start"]); + + cleanup(&git_dir); + } + + #[test] + fn a_failed_sibling_does_not_retire_survivors_or_block_new_starts() { + let git_dir = temp_git_dir("failed-sibling-survivors"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &shell_env("call_a")).expect("A Start"); + drive(&git_dir, &seam, &shell_env("call_b")).expect("B Start"); + drive(&git_dir, &seam, &tool_error("bash", "call_a")).expect("A fails"); + + drive(&git_dir, &seam, &shell_env("call_c")) + .expect("a new Start is admitted normally once the ambiguity flush cleared recovery"); + + let state = read_state(&git_dir).expect("state readable"); + let mut remaining: Vec<&str> = state + .attempts + .iter() + .map(|attempt| attempt.call_id.as_str()) + .collect(); + remaining.sort_unstable(); + assert_eq!(remaining, vec!["call_b", "call_c"]); + assert!(state + .attempts + .iter() + .all(|a| a.phase == AttemptPhase::Active)); + assert!(state.recovery.is_clear()); + + cleanup(&git_dir); + } + + #[test] + fn a_terminal_failure_consumes_the_interval_and_the_next_start_proceeds() { + let git_dir = temp_git_dir("terminal-then-start"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_before("write", "call_1")).expect("Start"); + drive(&git_dir, &seam, &tool_error("write", "call_1")).expect("terminal failure"); + assert!( + read_state(&git_dir) + .expect("state readable") + .recovery + .is_clear(), + "the ambiguity flush is done at abandon time, not deferred to the next Start", + ); + + drive(&git_dir, &seam, &tool_before("write", "call_2")).expect("Start after consume"); + + assert_eq!( + seam.operations(), + vec!["start", "flush", "abandon", "flush", "start"], + ); + let state = read_state(&git_dir).expect("state readable"); + assert!(state.recovery.is_clear()); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].call_id, "call_2"); + + cleanup(&git_dir); + } + + #[test] + fn a_failed_ambiguity_flush_stays_recovery_pending_and_fails_closed_starts() { + let git_dir = temp_git_dir("failed-ambiguity-flush"); + + { + let ok_seam = RecordingSeam::new(); + drive(&git_dir, &ok_seam, &tool_before("write", "call_1")).expect("Start"); + } + + let failing = RecordingSeam::failing_on(&["flush"]); + drive(&git_dir, &failing, &tool_error("write", "call_1")) + .expect("a terminal failure whose flush fails still returns (best-effort)"); + assert_eq!( + read_state(&git_dir).expect("state readable").recovery, + RecoveryState::Pending { generation: 1 }, + "a failed ambiguity flush retains a recovery-required state", + ); + + let error = drive(&git_dir, &failing, &tool_before("write", "call_2")) + .expect_err("a new Start while recovery is unresolved must stay fail-closed"); + assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE)); + assert_eq!( + read_state(&git_dir).expect("state readable").recovery, + RecoveryState::Pending { generation: 1 }, + ); + + cleanup(&git_dir); + } + + #[test] + fn untracked_and_delegation_events_are_zero_footprint() { + let git_dir = temp_git_dir("zero-footprint"); + let seam = RecordingSeam::new(); + + for payload in [ + tool_before("read", "call_r"), + tool_before("task", "call_t"), + tool_before("some_future_tool", "call_f"), + tool_after("read", "call_r"), + tool_error("read", "call_r"), + tool_error("task", "call_t"), + ] { + drive(&git_dir, &seam, &payload).expect("untracked event is neutral"); + } + + assert!(seam.operations().is_empty()); + assert!( + !git_dir.join("sce").exists(), + "no state directory is created" + ); + + cleanup(&git_dir); + } + + #[test] + fn close_seam_failure_falls_back_to_consume() { + let git_dir = temp_git_dir("close-seam-failure"); + + { + let ok_seam = RecordingSeam::new(); + drive(&git_dir, &ok_seam, &tool_before("edit", "call_1")).expect("Start"); + } + + let failing = RecordingSeam::failing_on(&["close"]); + drive(&git_dir, &failing, &tool_after("edit", "call_1")).expect("Close seam failure"); + + assert_eq!( + failing.operations(), + vec!["close", "flush", "abandon", "flush"], + ); + let state = read_state(&git_dir).expect("state readable"); + assert!(state.attempts.is_empty()); + assert!(state.recovery.is_clear()); + + cleanup(&git_dir); + } + + #[test] + fn regression_a_abandon_failure_preserves_terminal_intent_then_recovers() { + let git_dir = temp_git_dir("regression-a-abandon-failure"); + + { + let ok_seam = RecordingSeam::new(); + drive(&git_dir, &ok_seam, &tool_before("write", "call_1")).expect("A Start"); + } + + let failing = RecordingSeam::failing_on(&["abandon"]); + drive(&git_dir, &failing, &tool_error("write", "call_1")) + .expect("a terminal failure whose abandon fails still returns best-effort"); + + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1, "A is not forgotten"); + assert_eq!(state.attempts[0].call_id, "call_1"); + assert_eq!( + state.attempts[0].phase, + AttemptPhase::PendingAbandon, + "the exact terminal attempt is durably marked PendingAbandon", + ); + assert!( + !state.recovery.is_clear(), + "recovery stays unresolved while Abandon has not succeeded", + ); + assert_eq!( + failing.operations(), + vec!["flush", "abandon"], + "the ambiguity flush ran, then the abandon that failed; no rebaseline flush, \ + no removal", + ); + + let healthy = RecordingSeam::new(); + drive(&git_dir, &healthy, &tool_before("write", "call_2")) + .expect("a healthy retry boundary resolves recovery and admits the new Start"); + + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].call_id, "call_2"); + assert!(state.recovery.is_clear()); + assert_eq!( + healthy.operations(), + vec!["flush", "abandon", "flush", "start"], + "recovery replays flush + abandon + rebaseline flush before the new Start", + ); + + cleanup(&git_dir); + } + + #[test] + fn regression_b_ambiguity_flush_failure_blocks_new_starts_then_recovers() { + let git_dir = temp_git_dir("regression-b-ambiguity-flush-failure"); + let live = RecordingSeam::new(); + + drive(&git_dir, &live, &shell_env("call_a")).expect("A Start"); + drive(&git_dir, &live, &shell_env("call_b")).expect("B Start"); + + let failing = RecordingSeam::failing_on(&["flush"]); + drive(&git_dir, &failing, &tool_error("bash", "call_a")).expect("A terminal failure"); + + let state = read_state(&git_dir).expect("state readable"); + let pending_abandon: Vec<&str> = state + .attempts + .iter() + .filter(|attempt| attempt.phase == AttemptPhase::PendingAbandon) + .map(|attempt| attempt.call_id.as_str()) + .collect(); + let active: Vec<&str> = state + .attempts + .iter() + .filter(|attempt| attempt.phase == AttemptPhase::Active) + .map(|attempt| attempt.call_id.as_str()) + .collect(); + assert_eq!(pending_abandon, vec!["call_a"], "A is PendingAbandon"); + assert_eq!(active, vec!["call_b"], "B stays Active"); + assert!(!state.recovery.is_clear(), "recovery pending"); + + let error = drive(&git_dir, &failing, &shell_env("call_c")) + .expect_err("a new tracked Start must fail closed while recovery is unresolved"); + assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE)); + assert!(read_state(&git_dir) + .expect("state readable") + .attempts + .iter() + .all(|attempt| attempt.call_id != "call_c")); + + let healthy = RecordingSeam::new(); + drive(&git_dir, &healthy, &shell_env("call_c")) + .expect("once recovery succeeds a new Start is admitted normally"); + + let state = read_state(&git_dir).expect("state readable"); + let mut remaining: Vec<&str> = state + .attempts + .iter() + .map(|attempt| attempt.call_id.as_str()) + .collect(); + remaining.sort_unstable(); + assert_eq!( + remaining, + vec!["call_b", "call_c"], + "A removed, B kept, C admitted" + ); + assert!(state + .attempts + .iter() + .all(|attempt| attempt.phase == AttemptPhase::Active)); + assert!(state.recovery.is_clear()); + + cleanup(&git_dir); + } + + #[test] + fn regression_c_rebaseline_flush_failure_is_recoverable_without_poison() { + let git_dir = temp_git_dir("regression-c-rebaseline-flush-failure"); + + { + let ok_seam = RecordingSeam::new(); + drive(&git_dir, &ok_seam, &tool_before("edit", "call_1")).expect("Start"); + } + + let rebaseline_failing = RecordingSeam::failing_on_nth_occurrence("flush", 2); + drive(&git_dir, &rebaseline_failing, &tool_error("edit", "call_1")) + .expect("terminal failure whose rebaseline flush fails still returns"); + + assert_eq!( + rebaseline_failing.operations(), + vec!["flush", "abandon", "flush"], + "the ambiguity flush and the abandon succeeded; the rebaseline flush failed", + ); + let state = read_state(&git_dir).expect("state readable"); + assert!( + state.attempts.is_empty(), + "the abandon succeeded so the attempt is removed", + ); + assert!( + !state.recovery.is_clear(), + "recovery state still carries the outstanding rebaseline", + ); + + let healthy = RecordingSeam::new(); + drive(&git_dir, &healthy, &tool_before("edit", "call_2")).expect("retry admits new work"); + + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].call_id, "call_2"); + assert!(state.recovery.is_clear()); + + cleanup(&git_dir); + } + + #[test] + fn regression_e_duplicate_tool_error_is_idempotent() { + let git_dir = temp_git_dir("regression-e-duplicate-tool-error"); + + { + let ok_seam = RecordingSeam::new(); + drive(&git_dir, &ok_seam, &tool_before("write", "call_1")).expect("Start"); + } + + let stuck = RecordingSeam::failing_on(&["abandon"]); + drive(&git_dir, &stuck, &tool_error("write", "call_1")).expect("first terminal failure"); + drive(&git_dir, &stuck, &tool_error("write", "call_1")) + .expect("a duplicate ToolError while PendingAbandon is idempotent"); + + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1, "no second attempt is created"); + assert_eq!(state.attempts[0].phase, AttemptPhase::PendingAbandon); + assert_eq!( + state.next_recovery_generation, 2, + "the recovery generation is not incremented by the duplicate", + ); + + cleanup(&git_dir); + } + + #[test] + fn regression_f_start_replay_for_a_pending_abandon_identity_never_reactivates() { + let git_dir = temp_git_dir("regression-f-start-replay-pending-abandon"); + + { + let ok_seam = RecordingSeam::new(); + drive(&git_dir, &ok_seam, &tool_before("write", "call_1")).expect("Start"); + } + + let stuck = RecordingSeam::failing_on(&["abandon"]); + drive(&git_dir, &stuck, &tool_error("write", "call_1")).expect("terminal failure"); + + let error = drive(&git_dir, &stuck, &tool_before("write", "call_1")) + .expect_err("a replayed Start for a PendingAbandon identity must fail closed"); + assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE)); + + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!( + state.attempts[0].phase, + AttemptPhase::PendingAbandon, + "the replayed Start does not return the attempt to Active", + ); + + cleanup(&git_dir); + } + + #[test] + fn regression_g_late_tool_error_after_close_is_a_harmless_no_op() { + let git_dir = temp_git_dir("regression-g-late-tool-error"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_before("write", "call_1")).expect("Start"); + drive(&git_dir, &seam, &tool_after("write", "call_1")).expect("Close"); + drive(&git_dir, &seam, &tool_error("write", "call_1")) + .expect("a late ToolError after a completed Close is inert"); + + assert_eq!(seam.operations(), vec!["start", "close"]); + let state = read_state(&git_dir).expect("state readable"); + assert!(state.attempts.is_empty()); + assert!(state.recovery.is_clear()); + + cleanup(&git_dir); + } + + #[test] + fn regression_h_siblings_stay_active_through_a_transient_cleanup_failure() { + let git_dir = temp_git_dir("regression-h-siblings-preserved"); + let live = RecordingSeam::new(); + + drive(&git_dir, &live, &shell_env("call_a")).expect("A Start"); + drive(&git_dir, &live, &shell_env("call_b")).expect("B Start"); + drive(&git_dir, &live, &shell_env("call_c")).expect("C Start"); + + let transient = RecordingSeam::failing_once_on(&["abandon"]); + drive(&git_dir, &transient, &tool_error("bash", "call_b")) + .expect("B terminal failure with a transient abandon failure"); + + let snapshot = read_state(&git_dir).expect("state readable"); + let mut siblings: Vec<&str> = snapshot + .attempts + .iter() + .filter(|attempt| attempt.phase == AttemptPhase::Active) + .map(|attempt| attempt.call_id.as_str()) + .collect(); + siblings.sort_unstable(); + assert_eq!( + siblings, + vec!["call_a", "call_c"], + "A and C stay Active mid-failure" + ); + + let healthy = RecordingSeam::new(); + drive(&git_dir, &healthy, &shell_env("call_d")) + .expect("the retry boundary resolves recovery and admits D"); + + let state = read_state(&git_dir).expect("state readable"); + let mut remaining: Vec<&str> = state + .attempts + .iter() + .map(|attempt| attempt.call_id.as_str()) + .collect(); + remaining.sort_unstable(); + assert_eq!( + remaining, + vec!["call_a", "call_c", "call_d"], + "B gone, A/C/D active" + ); + assert!(state + .attempts + .iter() + .all(|attempt| attempt.phase == AttemptPhase::Active)); + assert!(state.recovery.is_clear()); + + cleanup(&git_dir); + } +} + +mod runtime_seam_tests { + use std::fs; + use std::path::{Path, PathBuf}; + use std::process::Command; + + use anyhow::{anyhow, Result}; + use serde_json::json; + + use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; + use crate::services::agent_trace_storage::{ + resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, + }; + use crate::services::hooks::opencode_mutation_scope::lifecycle::{ + run_opencode_mutation_scope_from_payload_at_state_root, + run_opencode_mutation_scope_from_payload_with_seams, + }; + use crate::services::mutation_trace::runtime::resolve_git_dir; + use crate::services::observability::traits::Logger; + + use super::super::state::{adapter_state_dir, read_state, AttemptPhase}; + + fn git(dir: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(dir) + .output() + .expect("git should spawn"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + struct OpenCodeRepo { + temp: tempfile::TempDir, + root: PathBuf, + state_root: PathBuf, + } + + impl OpenCodeRepo { + fn new(label: &str) -> Self { + let temp = tempfile::Builder::new() + .prefix(&format!("sce-opencode-mutation-scope-seam-{label}-")) + .tempdir() + .expect("temp dir should be created"); + let root = temp.path().join("repo"); + fs::create_dir_all(&root).expect("repo dir should be created"); + git(&root, &["init", "-q"]); + git(&root, &["config", "user.email", "test@example.invalid"]); + git(&root, &["config", "user.name", "SCE Test"]); + git( + &root, + &["remote", "add", "origin", "git@github.com:acme/widgets.git"], + ); + fs::write(root.join("file.txt"), "one\n").expect("seed file should write"); + git(&root, &["add", "-A"]); + git(&root, &["commit", "-qm", "base"]); + + let state_root = temp.path().join("state"); + fs::create_dir_all(&state_root).expect("state root should be created"); + resolve_agent_trace_storage_at_state_root( + &AgentTraceStorageContext { + repository_root: &root, + explicit_repository_id: None, + repository_remote: "origin", + }, + &state_root, + ) + .expect("state-root storage should initialize the repository DB"); + + Self { + temp, + root, + state_root, + } + } + + fn cwd(&self) -> String { + self.root.to_string_lossy().into_owned() + } + + fn drive(&self, payload: &str) -> Result { + run_opencode_mutation_scope_from_payload_at_state_root(&self.state_root, payload, None) + } + + fn drive_failing_seam_operation_once( + &self, + payload: &str, + fail_operation: &str, + remaining_failures: &std::cell::Cell, + ) -> Result { + let resolver = |cwd: &str| resolve_git_dir(Path::new(cwd)); + let seam_fn = |root: &Path, seam_payload: &str, logger: Option<&dyn Logger>| { + let operation = serde_json::from_str::(seam_payload) + .ok() + .and_then(|value| { + value + .get("operation") + .and_then(|op| op.as_str()) + .map(str::to_owned) + }) + .unwrap_or_default(); + if operation == fail_operation && remaining_failures.get() > 0 { + remaining_failures.set(remaining_failures.get() - 1); + return Err(anyhow!("injected transient '{operation}' seam failure")); + } + crate::services::hooks::mutation_scope::run_mutation_scope_from_payload_at_state_root( + root, + &self.state_root, + seam_payload, + logger, + ) + }; + run_opencode_mutation_scope_from_payload_with_seams(payload, None, &resolver, &seam_fn) + } + + fn write(&self, name: &str, contents: &str) { + fs::write(self.root.join(name), contents).expect("write should succeed"); + } + + fn git_dir(&self) -> PathBuf { + resolve_git_dir(&self.root).expect("git dir should resolve") + } + + fn db(&self) -> RepositoryAgentTraceDb { + crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( + &self.root, + &self.state_root, + "opencode mutation-scope seam test assertions", + ) + .expect("assertion DB should open") + } + + fn scope_status(&self, scope_id: &str) -> Option<(String, String)> { + self.db() + .query_map( + "SELECT actor_kind, status FROM mutation_trace_scopes WHERE scope_id = ?1", + (scope_id,), + |row| { + let actor_kind = row.get::(0).map_err(anyhow::Error::from)?; + let status = row.get::(1).map_err(anyhow::Error::from)?; + Ok((actor_kind, status)) + }, + ) + .expect("scope query should succeed") + .into_iter() + .next() + } + + fn scope_count(&self) -> i64 { + self.db() + .query_map("SELECT COUNT(*) FROM mutation_trace_scopes", (), |row| { + row.get::(0).map_err(anyhow::Error::from) + }) + .expect("count query should succeed") + .into_iter() + .next() + .expect("a count row should exist") + } + + fn mutation_events(&self) -> Vec<(String, Option)> { + self.db() + .query_map( + "SELECT attribution_kind, attribution_scope_id \ + FROM mutation_trace_events ORDER BY revision", + (), + |row| { + let attribution_kind = row.get::(0).map_err(anyhow::Error::from)?; + let attribution_scope_id = + row.get::>(1).map_err(anyhow::Error::from)?; + Ok((attribution_kind, attribution_scope_id)) + }, + ) + .expect("mutation-events query should succeed") + } + } + + fn error(repo: &OpenCodeRepo, tool_name: &str, call_id: &str) -> String { + json!({ + "hook_event_name": "ToolError", + "session_id": "ses_seam", + "call_id": call_id, + "cwd": repo.cwd(), + "tool_name": tool_name, + }) + .to_string() + } + + fn before(repo: &OpenCodeRepo, tool_name: &str, call_id: &str) -> String { + json!({ + "hook_event_name": "ToolExecuteBefore", + "session_id": "ses_seam", + "call_id": call_id, + "cwd": repo.cwd(), + "tool_name": tool_name, + "model": "opencode/big-pickle", + }) + .to_string() + } + + fn after(repo: &OpenCodeRepo, tool_name: &str, call_id: &str) -> String { + json!({ + "hook_event_name": "ToolExecuteAfter", + "session_id": "ses_seam", + "call_id": call_id, + "cwd": repo.cwd(), + "tool_name": tool_name, + }) + .to_string() + } + + #[test] + fn a_write_start_then_after_closes_the_scope_through_the_real_runtime() { + let repo = OpenCodeRepo::new("write-start-close"); + + assert_eq!( + repo.drive(&before(&repo, "write", "call_1")) + .expect("Start"), + "" + ); + let scope_id = { + let state = read_state(&repo.git_dir()).expect("adapter state readable"); + assert_eq!(state.attempts.len(), 1); + state.attempts[0].scope_id.clone() + }; + + repo.write("file.txt", "one\ntwo\n"); + assert_eq!( + repo.drive(&after(&repo, "write", "call_1")).expect("Close"), + "" + ); + + assert!(read_state(&repo.git_dir()) + .expect("adapter state readable") + .attempts + .is_empty()); + assert_eq!( + repo.scope_status(&scope_id), + Some(("opencode".to_string(), "closed".to_string())), + ); + } + + #[test] + fn linked_worktree_uses_its_worktree_specific_git_dir_for_mutation_scope_state() { + let repo = OpenCodeRepo::new("linked-worktree"); + let linked_root = repo.temp.path().join("linked"); + git( + &repo.root, + &[ + "worktree", + "add", + "--quiet", + linked_root + .to_str() + .expect("linked worktree path should be UTF-8"), + ], + ); + + let main_git_dir = resolve_git_dir(&repo.root).expect("main git dir should resolve"); + let linked_git_dir = resolve_git_dir(&linked_root).expect("linked git dir should resolve"); + assert_ne!( + main_git_dir, linked_git_dir, + "linked worktrees must use distinct Git state locations", + ); + + let before = json!({ + "hook_event_name": "ToolExecuteBefore", + "session_id": "ses_linked", + "call_id": "call_linked", + "cwd": linked_root, + "tool_name": "write", + "model": "opencode/big-pickle", + }) + .to_string(); + run_opencode_mutation_scope_from_payload_at_state_root(&repo.state_root, &before, None) + .expect("linked-worktree Start"); + + let scope_id = read_state(&linked_git_dir) + .expect("linked adapter state should be readable") + .attempts[0] + .scope_id + .clone(); + fs::write(linked_root.join("file.txt"), "one\nlinked\n") + .expect("linked worktree file should be writable"); + + let after = json!({ + "hook_event_name": "ToolExecuteAfter", + "session_id": "ses_linked", + "call_id": "call_linked", + "cwd": linked_root, + "tool_name": "write", + }) + .to_string(); + run_opencode_mutation_scope_from_payload_at_state_root(&repo.state_root, &after, None) + .expect("linked-worktree Close"); + + assert!(read_state(&linked_git_dir) + .expect("linked adapter state should be readable") + .attempts + .is_empty()); + assert_eq!( + repo.scope_status(&scope_id), + Some(("opencode".to_string(), "closed".to_string())), + ); + } + + #[test] + fn a_tool_error_abandons_the_scope_through_the_real_runtime() { + let repo = OpenCodeRepo::new("tool-error-abandon"); + + repo.drive(&before(&repo, "edit", "call_1")).expect("Start"); + let scope_id = read_state(&repo.git_dir()) + .expect("adapter state readable") + .attempts[0] + .scope_id + .clone(); + + repo.write("file.txt", "one\nabandoned-a\n"); + repo.drive(&error(&repo, "edit", "call_1")) + .expect("terminal failure"); + + let state = read_state(&repo.git_dir()).expect("adapter state readable"); + assert!(state.attempts.is_empty()); + assert!( + state.recovery.is_clear(), + "a successful ambiguity flush clears the recovery barrier", + ); + assert_eq!( + repo.scope_status(&scope_id).map(|(_, status)| status), + Some("abandoned".to_string()), + ); + } + + #[test] + fn regression_a_failed_concurrent_scope_cannot_contaminate_a_survivor() { + let repo = OpenCodeRepo::new("regression-a-no-contamination"); + + repo.drive(&before(&repo, "write", "call_a")) + .expect("A Start"); + repo.drive(&before(&repo, "write", "call_b")) + .expect("B Start"); + let scope_b = read_state(&repo.git_dir()) + .expect("adapter state readable") + .attempts + .iter() + .find(|attempt| attempt.call_id == "call_b") + .expect("B is tracked") + .scope_id + .clone(); + + repo.write("file_a.txt", "a mutated\n"); + repo.write("file_b.txt", "b mutated\n"); + + repo.drive(&error(&repo, "write", "call_a")) + .expect("A terminal failure"); + + let state = read_state(&repo.git_dir()).expect("adapter state readable"); + assert_eq!(state.attempts.len(), 1, "B stays tracked after A's cleanup"); + assert_eq!(state.attempts[0].call_id, "call_b"); + assert_eq!(state.attempts[0].phase, AttemptPhase::Active); + + repo.drive(&after(&repo, "write", "call_b")) + .expect("B Close"); + + let events = repo.mutation_events(); + assert!( + events.iter().any(|(kind, _)| kind == "ineligible_unscoped"), + "the ambiguous interval containing A's mutations is consumed as \ + IneligibleUnscoped: {events:?}", + ); + assert!( + !events.iter().any(|(kind, scope)| kind == "ai_exclusive" + && scope.as_deref() == Some(scope_b.as_str())), + "B must never be attributed the interval that could contain A's changes: {events:?}", + ); + assert_eq!( + repo.scope_status(&scope_b).map(|(_, status)| status), + Some("closed".to_string()), + ); + } + + #[test] + fn regression_b_survivor_still_attributes_its_later_mutations() { + let repo = OpenCodeRepo::new("regression-b-survivor-liveness"); + + repo.drive(&before(&repo, "write", "call_a")) + .expect("A Start"); + repo.drive(&before(&repo, "write", "call_b")) + .expect("B Start"); + let scope_b = read_state(&repo.git_dir()) + .expect("adapter state readable") + .attempts + .iter() + .find(|attempt| attempt.call_id == "call_b") + .expect("B is tracked") + .scope_id + .clone(); + + repo.write("file_a.txt", "a mutated\n"); + repo.drive(&error(&repo, "write", "call_a")) + .expect("A terminal failure consumes the ambiguous interval"); + + repo.write("file_c.txt", "b's own later work\n"); + repo.drive(&after(&repo, "write", "call_b")) + .expect("B Close"); + + let events = repo.mutation_events(); + assert!( + events.iter().any(|(kind, scope)| kind == "ai_exclusive" + && scope.as_deref() == Some(scope_b.as_str())), + "after the ambiguity flush, B may legitimately attribute its own later \ + mutations: {events:?}", + ); + assert_eq!( + repo.scope_status(&scope_b).map(|(_, status)| status), + Some("closed".to_string()), + ); + } + + #[test] + fn regression_c_exact_error_does_not_sweep_siblings_through_the_real_runtime() { + let repo = OpenCodeRepo::new("regression-c-no-sibling-sweep"); + + repo.drive(&before(&repo, "write", "call_a")) + .expect("A Start"); + repo.drive(&before(&repo, "write", "call_b")) + .expect("B Start"); + repo.drive(&before(&repo, "write", "call_c")) + .expect("C Start"); + + repo.write("file.txt", "one\nmutated\n"); + repo.drive(&error(&repo, "write", "call_b")) + .expect("B terminal failure"); + + let state = read_state(&repo.git_dir()).expect("adapter state readable"); + let mut remaining: Vec<&str> = state + .attempts + .iter() + .map(|attempt| attempt.call_id.as_str()) + .collect(); + remaining.sort_unstable(); + assert_eq!(remaining, vec!["call_a", "call_c"], "A and C remain active"); + assert!(state + .attempts + .iter() + .all(|a| a.phase == AttemptPhase::Active)); + assert!(state.recovery.is_clear()); + } + + #[test] + fn regression_d_delayed_session_idle_cannot_kill_a_newer_call() { + let repo = OpenCodeRepo::new("regression-d-delayed-session-idle"); + + repo.drive(&before(&repo, "write", "call_b")) + .expect("newer call B Start"); + + repo.drive( + &json!({ + "hook_event_name": "SessionIdle", + "session_id": "ses_seam", + "cwd": repo.cwd(), + }) + .to_string(), + ) + .expect("a delayed SessionIdle for the same session is inert"); + + let state = read_state(&repo.git_dir()).expect("adapter state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].call_id, "call_b"); + assert_eq!(state.attempts[0].phase, AttemptPhase::Active); + assert!(state.recovery.is_clear()); + } + + #[test] + fn regression_e_server_disposed_cannot_sweep_another_process() { + let repo = OpenCodeRepo::new("regression-e-server-disposed"); + + repo.drive(&before(&repo, "write", "call_a")) + .expect("P1 owns call A"); + repo.drive( + &json!({ + "hook_event_name": "ToolExecuteBefore", + "session_id": "ses_p2", + "call_id": "call_b", + "cwd": repo.cwd(), + "tool_name": "write", + }) + .to_string(), + ) + .expect("P2 owns call B in the same checkout"); + + repo.drive(&json!({ "hook_event_name": "ServerDisposed", "cwd": repo.cwd() }).to_string()) + .expect("P1 server disposal is inert"); + + let state = read_state(&repo.git_dir()).expect("adapter state readable"); + assert_eq!( + state.attempts.len(), + 2, + "one process's disposal must not retire another process's live attempt", + ); + assert!(state.recovery.is_clear()); + } + + #[test] + fn regression_f_untracked_tool_error_is_zero_footprint() { + let repo = OpenCodeRepo::new("regression-f-untracked-tool-error"); + + for tool_name in ["read", "task", "brave-search_brave_web_search"] { + repo.drive(&error(&repo, tool_name, "call_x")) + .expect("an untracked ToolError is neutral"); + } + + assert_eq!(repo.scope_count(), 0); + assert!(!adapter_state_dir(&repo.git_dir()) + .join("opencode-mutation-scope-state.json") + .exists()); + } + + #[test] + fn untracked_events_never_reach_the_runtime_or_touch_adapter_state() { + let repo = OpenCodeRepo::new("untracked-zero-footprint"); + + assert_eq!( + repo.drive(&before(&repo, "read", "call_r")).expect("read"), + "" + ); + assert_eq!( + repo.drive(&before(&repo, "task", "call_t")).expect("task"), + "" + ); + + assert_eq!(repo.scope_count(), 0); + assert!(!adapter_state_dir(&repo.git_dir()) + .join("opencode-mutation-scope-state.json") + .exists()); + } + + #[test] + fn regression_d_concurrent_survivor_stays_usable_after_a_transient_cleanup_failure() { + let repo = OpenCodeRepo::new("regression-d-transient-cleanup-failure"); + + repo.drive(&before(&repo, "write", "call_a")) + .expect("A Start"); + repo.drive(&before(&repo, "write", "call_b")) + .expect("B Start"); + let scoped = read_state(&repo.git_dir()).expect("adapter state readable"); + let scope_a = scoped + .attempts + .iter() + .find(|attempt| attempt.call_id == "call_a") + .expect("A is tracked") + .scope_id + .clone(); + let scope_b = scoped + .attempts + .iter() + .find(|attempt| attempt.call_id == "call_b") + .expect("B is tracked") + .scope_id + .clone(); + + repo.write("file_a.txt", "a mutated\n"); + repo.write("file_b.txt", "b mutated\n"); + + let remaining_failures = std::cell::Cell::new(1_u32); + repo.drive_failing_seam_operation_once( + &error(&repo, "write", "call_a"), + "abandon", + &remaining_failures, + ) + .expect("A terminal failure with a transient abandon failure returns best-effort"); + + let state = read_state(&repo.git_dir()).expect("adapter state readable"); + assert_eq!( + state + .attempts + .iter() + .find(|attempt| attempt.call_id == "call_a") + .map(|attempt| attempt.phase), + Some(AttemptPhase::PendingAbandon), + "A's terminal intent survives the transient failure", + ); + assert_eq!( + state + .attempts + .iter() + .find(|attempt| attempt.call_id == "call_b") + .map(|attempt| attempt.phase), + Some(AttemptPhase::Active), + "B is untouched", + ); + assert!(!state.recovery.is_clear()); + + repo.drive(&error(&repo, "write", "call_a")) + .expect("a healthy duplicate ToolError retries and completes cleanup"); + + let state = read_state(&repo.git_dir()).expect("adapter state readable"); + let remaining: Vec<&str> = state + .attempts + .iter() + .map(|attempt| attempt.call_id.as_str()) + .collect(); + assert_eq!(remaining, vec!["call_b"], "A retired, B still live"); + assert_eq!(state.attempts[0].phase, AttemptPhase::Active); + assert!(state.recovery.is_clear()); + assert_eq!( + repo.scope_status(&scope_a).map(|(_, status)| status), + Some("abandoned".to_string()), + ); + + repo.write("file_c.txt", "b's own later work\n"); + repo.drive(&after(&repo, "write", "call_b")) + .expect("B Close"); + + let events = repo.mutation_events(); + assert!( + events.iter().any(|(kind, _)| kind == "ineligible_unscoped"), + "the ambiguous A/B interval is consumed as IneligibleUnscoped: {events:?}", + ); + assert!( + events.iter().any(|(kind, scope)| kind == "ai_exclusive" + && scope.as_deref() == Some(scope_b.as_str())), + "B still attributes its own later mutation once recovery completed: {events:?}", + ); + } +} diff --git a/cli/src/services/hooks/pi_mutation_scope/events.rs b/cli/src/services/hooks/pi_mutation_scope/events.rs new file mode 100644 index 000000000..5f4adff18 --- /dev/null +++ b/cli/src/services/hooks/pi_mutation_scope/events.rs @@ -0,0 +1,211 @@ +use anyhow::{anyhow, bail, Context, Result}; +use serde_json::{Map, Value}; + +use crate::services::hooks::{normalize_pi_model_id, prefixed_diff_trace_session_id, PI_TOOL_NAME}; + +pub(super) const HOOK_EVENT_NAME_FIELD: &str = "hook_event_name"; +pub(super) const SESSION_ID_FIELD: &str = "session_id"; +pub(super) const TOOL_CALL_ID_FIELD: &str = "tool_call_id"; +pub(super) const CWD_FIELD: &str = "cwd"; +pub(super) const TOOL_NAME_FIELD: &str = "tool_name"; +pub(super) const MODEL_FIELD: &str = "model"; + +pub(super) const HOOK_EVENT_TOOL_EXECUTION_START: &str = "ToolExecutionStart"; +pub(super) const HOOK_EVENT_TOOL_CALL: &str = "ToolCall"; +pub(super) const HOOK_EVENT_TOOL_RESULT: &str = "ToolResult"; +pub(super) const HOOK_EVENT_TOOL_EXECUTION_END: &str = "ToolExecutionEnd"; +pub(super) const HOOK_EVENT_TOOL_EXECUTION_ABANDON: &str = "ToolExecutionAbandon"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum PiHookEvent { + ExecutionStart(PiToolIdentity), + Call(PiToolCall), + Executed(PiToolIdentity), + ExecutionEnd(PiToolIdentity), + ExecutionAbandon(PiToolIdentity), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PiToolIdentity { + pub session_id: String, + pub tool_call_id: String, + pub cwd: String, + pub tool_name: String, +} + +impl PiToolIdentity { + pub(crate) fn attempt_key(&self) -> AttemptKey { + AttemptKey { + session_id: self.session_id.clone(), + tool_call_id: self.tool_call_id.clone(), + } + } + + pub(crate) fn classification(&self) -> ToolClassification { + classify_tool(&self.tool_name) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PiToolCall { + pub identity: PiToolIdentity, + pub model: Option, +} + +#[allow(clippy::struct_field_names)] +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub(crate) struct AttemptKey { + pub session_id: String, + pub tool_call_id: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ToolClassification { + TrackedMutation, + Untracked, +} + +pub(super) const TRACKED_MUTATION_TOOL_NAMES: &[&str] = &["bash", "edit", "write"]; + +pub(crate) fn classify_tool(tool_name: &str) -> ToolClassification { + if TRACKED_MUTATION_TOOL_NAMES.contains(&tool_name) { + ToolClassification::TrackedMutation + } else { + ToolClassification::Untracked + } +} + +pub(super) const PI_SCOPE_ID_SCHEME: &str = "pi-tool-v1"; + +pub(crate) fn format_pi_scope_id(key: &AttemptKey, attempt_seq: u64) -> String { + format!( + "{PI_SCOPE_ID_SCHEME}|n={attempt_seq}|s={}:{}|c={}:{}", + key.session_id.len(), + key.session_id, + key.tool_call_id.len(), + key.tool_call_id, + ) +} + +pub(crate) fn pi_scope_start_event_id(scope_id: &str) -> String { + format!("{scope_id}|start") +} + +pub(crate) fn pi_scope_close_event_id(scope_id: &str) -> String { + format!("{scope_id}|close") +} + +pub(super) const ACTOR_KIND_PI: &str = "pi"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PiScopeProvenance { + pub session_id: String, + pub model_id: Option, +} + +pub(crate) fn pi_scope_provenance(session_id: &str, model: Option<&str>) -> PiScopeProvenance { + PiScopeProvenance { + session_id: prefixed_diff_trace_session_id(PI_TOOL_NAME, session_id), + model_id: model.and_then(normalize_pi_model_id), + } +} + +pub(crate) fn parse_pi_hook_event(stdin_payload: &str) -> Result { + if stdin_payload.trim().is_empty() { + bail!(validation_error( + "expected a JSON object, got an empty payload" + )); + } + + let parsed: Value = serde_json::from_str(stdin_payload) + .with_context(|| validation_error("expected valid JSON"))?; + let object = parsed + .as_object() + .ok_or_else(|| anyhow!(validation_error("expected a JSON object")))?; + + let hook_event_name = required_non_blank_str(object, HOOK_EVENT_NAME_FIELD)?; + + match hook_event_name.as_str() { + HOOK_EVENT_TOOL_EXECUTION_START => { + parse_tool_identity(object).map(PiHookEvent::ExecutionStart) + } + HOOK_EVENT_TOOL_CALL => parse_tool_call(object).map(PiHookEvent::Call), + HOOK_EVENT_TOOL_RESULT => parse_tool_identity(object).map(PiHookEvent::Executed), + HOOK_EVENT_TOOL_EXECUTION_END => parse_tool_identity(object).map(PiHookEvent::ExecutionEnd), + HOOK_EVENT_TOOL_EXECUTION_ABANDON => { + parse_tool_identity(object).map(PiHookEvent::ExecutionAbandon) + } + other => bail!(validation_error(&format!( + "unsupported hook_event_name '{other}'" + ))), + } +} + +pub(super) fn parse_tool_identity(object: &Map) -> Result { + Ok(PiToolIdentity { + session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, + tool_call_id: required_non_blank_str(object, TOOL_CALL_ID_FIELD)?, + cwd: required_non_blank_str(object, CWD_FIELD)?, + tool_name: required_non_blank_str(object, TOOL_NAME_FIELD)?, + }) +} + +pub(super) fn parse_tool_call(object: &Map) -> Result { + Ok(PiToolCall { + identity: parse_tool_identity(object)?, + model: optional_non_blank_str(object, MODEL_FIELD)?, + }) +} + +pub(super) fn required_field<'a>(object: &'a Map, field: &str) -> Result<&'a Value> { + object.get(field).ok_or_else(|| { + anyhow!(validation_error(&format!( + "missing required field '{field}'" + ))) + }) +} + +pub(super) fn required_str(object: &Map, field: &str) -> Result { + required_field(object, field)? + .as_str() + .map(str::to_owned) + .ok_or_else(|| { + anyhow!(validation_error(&format!( + "field '{field}' must be a string" + ))) + }) +} + +pub(super) fn required_non_blank_str(object: &Map, field: &str) -> Result { + let value = required_str(object, field)?; + if value.trim().is_empty() { + bail!(validation_error(&format!( + "field '{field}' must be a non-blank string" + ))); + } + Ok(value) +} + +pub(super) fn optional_non_blank_str( + object: &Map, + field: &str, +) -> Result> { + match object.get(field) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) => { + if value.trim().is_empty() { + bail!(validation_error(&format!( + "field '{field}' must be null, absent, or a non-blank string" + ))); + } + Ok(Some(value.clone())) + } + Some(_) => bail!(validation_error(&format!( + "field '{field}' must be null, absent, or a non-blank string" + ))), + } +} + +pub(super) fn validation_error(detail: &str) -> String { + format!("Invalid Pi hook event payload from STDIN: {detail}.") +} diff --git a/cli/src/services/hooks/pi_mutation_scope/guard_reconciliation_tests.rs b/cli/src/services/hooks/pi_mutation_scope/guard_reconciliation_tests.rs new file mode 100644 index 000000000..8611a81d1 --- /dev/null +++ b/cli/src/services/hooks/pi_mutation_scope/guard_reconciliation_tests.rs @@ -0,0 +1,606 @@ +use std::fs; +use std::path::PathBuf; +use std::process::Command; +use std::sync::mpsc; +use std::time::Duration; + +use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; +use crate::services::agent_trace_storage::{ + resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, +}; +use crate::services::mutation_trace::runtime::{ + coordinate, run_external_mutation_guard, GuardRequest, RuntimeBoundary, +}; +use crate::services::mutation_trace::types::{ActorKind, EventId, ScopeId}; + +use super::*; + +fn git(dir: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(dir) + .output() + .expect("git should spawn"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +struct GuardRepo { + _temp: tempfile::TempDir, + root: PathBuf, + state_root: PathBuf, +} + +impl GuardRepo { + fn new(label: &str) -> Self { + let temp = tempfile::Builder::new() + .prefix(&format!("sce-pi-guard-reconciliation-{label}-")) + .tempdir() + .expect("temp dir should be created"); + let root = temp.path().join("repo"); + fs::create_dir_all(&root).expect("repo dir should be created"); + git(&root, &["init", "-q"]); + git(&root, &["config", "user.email", "test@example.invalid"]); + git(&root, &["config", "user.name", "SCE Test"]); + git( + &root, + &["remote", "add", "origin", "git@github.com:acme/widgets.git"], + ); + fs::write(root.join("file.txt"), "one\n").expect("seed file should write"); + git(&root, &["add", "-A"]); + git(&root, &["commit", "-qm", "base"]); + + let state_root = temp.path().join("state"); + fs::create_dir_all(&state_root).expect("state root should be created"); + resolve_agent_trace_storage_at_state_root( + &AgentTraceStorageContext { + repository_root: &root, + explicit_repository_id: None, + repository_remote: "origin", + }, + &state_root, + ) + .expect("state-root storage should initialize the repository DB"); + + Self { + _temp: temp, + root, + state_root, + } + } + + fn drive(&self, payload: &str) -> Result { + run_pi_mutation_scope_from_payload_at_state_root(&self.state_root, payload, None) + } + + fn open_db(&self) -> anyhow::Result { + crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( + &self.root, + &self.state_root, + "Pi guard-reconciliation test assertions", + ) + } + + fn db(&self) -> RepositoryAgentTraceDb { + self.open_db().expect("assertion DB should open") + } + + fn scope_status(&self, scope_id: &str) -> Option<(String, String)> { + self.db() + .query_map( + "SELECT actor_kind, status FROM mutation_trace_scopes WHERE scope_id = ?1", + (scope_id,), + |row| { + let actor_kind = row.get::(0).map_err(anyhow::Error::from)?; + let status = row.get::(1).map_err(anyhow::Error::from)?; + Ok((actor_kind, status)) + }, + ) + .expect("scope query should succeed") + .into_iter() + .next() + } + + fn write(&self, name: &str, contents: &str) { + fs::write(self.root.join(name), contents).expect("write should succeed"); + } + + fn mutation_events(&self) -> Vec<(String, Option)> { + self.db() + .query_map( + "SELECT attribution_kind, attribution_scope_id \ + FROM mutation_trace_events ORDER BY revision", + (), + |row| { + let attribution_kind = row.get::(0).map_err(anyhow::Error::from)?; + let attribution_scope_id = + row.get::>(1).map_err(anyhow::Error::from)?; + Ok((attribution_kind, attribution_scope_id)) + }, + ) + .expect("mutation-events query should succeed") + } +} + +fn tool_call(repo: &GuardRepo, tool_call_id: &str, session_id: &str) -> String { + json!({ + "hook_event_name": "ToolCall", + "session_id": session_id, + "tool_call_id": tool_call_id, + "cwd": repo.root.to_string_lossy(), + "tool_name": "bash", + "model": "openai-codex/gpt-5.5", + }) + .to_string() +} + +fn tool_execution_end(repo: &GuardRepo, tool_call_id: &str, session_id: &str) -> String { + json!({ + "hook_event_name": "ToolExecutionEnd", + "session_id": session_id, + "tool_call_id": tool_call_id, + "cwd": repo.root.to_string_lossy(), + "tool_name": "bash", + }) + .to_string() +} + +fn tool_result(repo: &GuardRepo, tool_call_id: &str, session_id: &str) -> String { + json!({ + "hook_event_name": "ToolResult", + "session_id": session_id, + "tool_call_id": tool_call_id, + "cwd": repo.root.to_string_lossy(), + "tool_name": "bash", + }) + .to_string() +} + +#[test] +fn a_guard_triggered_worktree_abandonment_reconciles_with_the_pi_adapters_own_state() { + let repo = GuardRepo::new("reconcile"); + let session = "01a091f4-guard-session"; + let key_a = AttemptKey { + session_id: session.to_string(), + tool_call_id: "call_a".to_string(), + }; + let key_b = AttemptKey { + session_id: session.to_string(), + tool_call_id: "call_b".to_string(), + }; + let scope_a = format_pi_scope_id(&key_a, 1); + let scope_b = format_pi_scope_id(&key_b, 2); + + repo.drive(&tool_call(&repo, "call_a", session)) + .expect("A's Start should reach the real runtime"); + repo.drive(&tool_call(&repo, "call_b", session)) + .expect("B's Start should reach the real runtime"); + assert_eq!( + repo.scope_status(&scope_a), + Some(("pi".to_string(), "active".to_string())) + ); + assert_eq!( + repo.scope_status(&scope_b), + Some(("pi".to_string(), "active".to_string())) + ); + + let (_cancel_tx, cancel_rx) = mpsc::channel(); + let root = repo.root.clone(); + let outcome = run_external_mutation_guard( + &root, + &GuardRequest { + command: "printf changed >> file.txt".to_string(), + cwd: None, + env: Vec::new(), + }, + || repo.open_db(), + |_event| {}, + cancel_rx, + ) + .expect("the guard should finish successfully"); + assert_eq!(outcome.exit_code, Some(0)); + assert!(!outcome.marker_clear_failed); + + assert_eq!( + repo.scope_status(&scope_a), + Some(("pi".to_string(), "abandoned".to_string())), + "the guard's finish-time forced recovery must abandon every scope live during the \ + guarded interval, regardless of which harness's boundary happened to observe \ + user_bash" + ); + assert_eq!( + repo.scope_status(&scope_b), + Some(("pi".to_string(), "abandoned".to_string())) + ); + + repo.drive(&tool_execution_end(&repo, "call_a", session)) + .expect( + "the adapter's next interaction for an already-abandoned scope must reconcile \ + safely (falling back through the existing Close-failure-to-abandon path) rather \ + than erroring or resurrecting the scope", + ); + repo.drive(&tool_execution_end(&repo, "call_b", session)) + .expect("the same reconciliation must hold for every sibling abandoned by the guard"); + + assert!( + state::read_state(&resolve_git_dir(&repo.root).expect("git dir resolves")) + .expect("state readable") + .attempts + .is_empty(), + "the Pi adapter's own durable local attempt state must converge to empty once it \ + observes the terminal event for a scope the generic runtime already abandoned out \ + from under it" + ); +} + +#[test] +fn a_guard_abandons_a_live_pi_scope_alongside_a_live_scope_from_another_harness() { + let repo = GuardRepo::new("cross-harness"); + let key = AttemptKey { + session_id: "01a091f4-guard-cross-session".to_string(), + tool_call_id: "call_pi".to_string(), + }; + let pi_scope_id = format_pi_scope_id(&key, 1); + let claude_scope = ScopeId("claude-scope-under-guard".to_string()); + + repo.drive(&tool_call(&repo, "call_pi", "01a091f4-guard-cross-session")) + .expect("Pi's Start should reach the real runtime"); + coordinate( + &repo.root, + &RuntimeBoundary::Start { + scope: claude_scope.clone(), + event: EventId("claude-evt-start".to_string()), + actor_kind: ActorKind::ClaudeCode, + provenance: None, + }, + || repo.open_db(), + ) + .expect("Claude's Start should reach the real runtime"); + + assert_eq!( + repo.scope_status(&pi_scope_id), + Some(("pi".to_string(), "active".to_string())) + ); + assert_eq!( + repo.scope_status(&claude_scope.0), + Some(("claude_code".to_string(), "active".to_string())) + ); + + let (_cancel_tx, cancel_rx) = mpsc::channel(); + let root = repo.root.clone(); + run_external_mutation_guard( + &root, + &GuardRequest { + command: "printf changed >> file.txt".to_string(), + cwd: None, + env: Vec::new(), + }, + || repo.open_db(), + |_event| {}, + cancel_rx, + ) + .expect("the guard should finish successfully"); + + assert_eq!( + repo.scope_status(&pi_scope_id), + Some(("pi".to_string(), "abandoned".to_string())), + "the guard's forced recovery must abandon the Pi scope even though a different \ + harness's boundary is the one that happened to observe user_bash" + ); + assert_eq!( + repo.scope_status(&claude_scope.0), + Some(("claude_code".to_string(), "abandoned".to_string())), + "the guard's forced recovery must abandon every live scope on the worktree \ + regardless of which harness owns it" + ); + + repo.drive(&tool_execution_end( + &repo, + "call_pi", + "01a091f4-guard-cross-session", + )) + .expect( + "the Pi adapter must still reconcile cleanly with its own scope even when a \ + sibling scope belonging to a different harness was abandoned by the same guard", + ); + assert!( + state::read_state(&resolve_git_dir(&repo.root).expect("git dir resolves")) + .expect("state readable") + .attempts + .is_empty() + ); +} + +#[test] +fn a_foreign_pi_start_racing_an_active_guard_fails_closed_touching_no_state_then_succeeds_on_retry() +{ + let repo = GuardRepo::new("race"); + let ready = repo.root.join("ready"); + let release = repo.root.join("release"); + let command = format!( + "touch '{}'; while [ ! -f '{}' ]; do sleep 0.02; done", + ready.display(), + release.display(), + ); + + let root = repo.root.clone(); + let state_root = repo.state_root.clone(); + let guard_thread = std::thread::spawn(move || { + let (_cancel_tx, cancel_rx) = mpsc::channel(); + run_external_mutation_guard( + &root, + &GuardRequest { + command, + cwd: None, + env: Vec::new(), + }, + || { + crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( + &root, + &state_root, + "guard race test", + ) + }, + |_event| {}, + cancel_rx, + ) + }); + + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while !ready.exists() { + assert!( + std::time::Instant::now() < deadline, + "the guarded shell never reported ready" + ); + std::thread::sleep(Duration::from_millis(20)); + } + + let key = AttemptKey { + session_id: "01a091f4-guard-race-session".to_string(), + tool_call_id: "call_race".to_string(), + }; + let scope_id = format_pi_scope_id(&key, 1); + + let error = repo + .drive(&tool_call( + &repo, + "call_race", + "01a091f4-guard-race-session", + )) + .expect_err( + "a Pi Start racing an active external-mutation guard must block then fail \ + closed with CoordinateError::LockAcquisition, never proceed", + ); + assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE)); + assert!( + repo.scope_status(&scope_id).is_none(), + "a boundary that fails closed on lock acquisition must touch no protocol state" + ); + + fs::write(&release, "go").expect("release file should write"); + let outcome = guard_thread + .join() + .expect("guard thread should not panic") + .expect("the guard should finish successfully once released"); + assert_eq!(outcome.exit_code, Some(0)); + + repo.drive(&tool_call( + &repo, + "call_race", + "01a091f4-guard-race-session", + )) + .expect("retrying the same Start after the guard finishes must succeed normally"); + assert_eq!( + repo.scope_status(&scope_id), + Some(("pi".to_string(), "active".to_string())) + ); +} + +#[test] +#[allow(clippy::too_many_lines)] +fn a_foreign_harnesss_boundary_racing_the_guard_fails_closed_then_succeeds_normally_on_retry() { + let repo = GuardRepo::new("cross-harness-race"); + let claude_scope = ScopeId("claude-scope-racing-guard".to_string()); + let key = AttemptKey { + session_id: "01a091f4-guard-cross-race-session".to_string(), + tool_call_id: "call_pi_race".to_string(), + }; + let pi_scope_id = format_pi_scope_id(&key, 1); + + repo.drive(&tool_call( + &repo, + "call_pi_race", + "01a091f4-guard-cross-race-session", + )) + .expect("Pi's Start should reach the real runtime"); + coordinate( + &repo.root, + &RuntimeBoundary::Start { + scope: claude_scope.clone(), + event: EventId("claude-evt-race-start".to_string()), + actor_kind: ActorKind::ClaudeCode, + provenance: None, + }, + || repo.open_db(), + ) + .expect("Claude's Start should reach the real runtime"); + + let ready = repo.root.join("ready"); + let release = repo.root.join("release"); + let command = format!( + "touch '{}'; printf w1 >> file.txt; while [ ! -f '{}' ]; do sleep 0.02; done; printf w2 >> file.txt", + ready.display(), + release.display(), + ); + + let root = repo.root.clone(); + let state_root = repo.state_root.clone(); + let guard_thread = std::thread::spawn(move || { + let (_cancel_tx, cancel_rx) = mpsc::channel(); + run_external_mutation_guard( + &root, + &GuardRequest { + command, + cwd: None, + env: Vec::new(), + }, + || { + crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( + &root, + &state_root, + "guard cross-harness race test", + ) + }, + |_event| {}, + cancel_rx, + ) + }); + + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while !ready.exists() { + assert!( + std::time::Instant::now() < deadline, + "the guarded shell never reported ready" + ); + std::thread::sleep(Duration::from_millis(20)); + } + + let close_while_active = coordinate( + &repo.root, + &RuntimeBoundary::Close { + scope: claude_scope.clone(), + event: EventId("claude-evt-race-close".to_string()), + actor_kind: ActorKind::ClaudeCode, + }, + || repo.open_db(), + ); + assert!( + matches!( + close_while_active, + Err(crate::services::mutation_trace::runtime::CoordinateError::LockAcquisition(_)) + ), + "a foreign harness's boundary racing an active external-mutation guard must fail \ + closed with LockAcquisition, never proceed while the guard still holds the \ + worktree: {close_while_active:?}" + ); + assert!( + repo.mutation_events().is_empty(), + "a boundary that fails closed on lock acquisition must touch no protocol state" + ); + + fs::write(&release, "go").expect("release file should write"); + let outcome = guard_thread + .join() + .expect("guard thread should not panic") + .expect("the guard should finish successfully once released"); + assert_eq!(outcome.exit_code, Some(0)); + + assert_eq!( + repo.scope_status(&pi_scope_id), + Some(("pi".to_string(), "abandoned".to_string())) + ); + assert_eq!( + repo.scope_status(&claude_scope.0), + Some(("claude_code".to_string(), "abandoned".to_string())) + ); + + coordinate( + &repo.root, + &RuntimeBoundary::Close { + scope: claude_scope.clone(), + event: EventId("claude-evt-race-close-retry".to_string()), + actor_kind: ActorKind::ClaudeCode, + }, + || repo.open_db(), + ) + .expect( + "Claude's deferred boundary must succeed normally once retried against the \ + recovered worktree, rather than continuing to fail closed", + ); + repo.drive(&tool_execution_end( + &repo, + "call_pi_race", + "01a091f4-guard-cross-race-session", + )) + .expect("the Pi adapter must reconcile its own already-abandoned scope cleanly too"); + + assert!( + repo.mutation_events().is_empty(), + "both human writes made under the guard must remain excluded from positive AI \ + attribution for the Pi scope and for the racing foreign-harness scope alike" + ); +} + +#[test] +fn a_guard_triggered_abandonment_does_not_poison_the_checkout_for_a_fresh_pi_scope() { + let repo = GuardRepo::new("post-recovery-fresh-start"); + let session = "01a091f4-guard-fresh-session"; + let key_a = AttemptKey { + session_id: session.to_string(), + tool_call_id: "call_doomed".to_string(), + }; + let scope_a = format_pi_scope_id(&key_a, 1); + + repo.drive(&tool_call(&repo, "call_doomed", session)) + .expect("A's Start should reach the real runtime"); + assert_eq!( + repo.scope_status(&scope_a), + Some(("pi".to_string(), "active".to_string())) + ); + + let (_cancel_tx, cancel_rx) = mpsc::channel(); + let root = repo.root.clone(); + let outcome = run_external_mutation_guard( + &root, + &GuardRequest { + command: "printf changed >> file.txt".to_string(), + cwd: None, + env: Vec::new(), + }, + || repo.open_db(), + |_event| {}, + cancel_rx, + ) + .expect("the guard should finish successfully"); + assert_eq!(outcome.exit_code, Some(0)); + + assert_eq!( + repo.scope_status(&scope_a), + Some(("pi".to_string(), "abandoned".to_string())) + ); + repo.drive(&tool_execution_end(&repo, "call_doomed", session)) + .expect("the adapter must reconcile the guard-abandoned scope cleanly"); + + let key_c = AttemptKey { + session_id: session.to_string(), + tool_call_id: "call_clean".to_string(), + }; + let scope_c = format_pi_scope_id(&key_c, 2); + + repo.drive(&tool_call(&repo, "call_clean", session)) + .expect("a fresh Start on the same worktree after recovery must succeed normally"); + assert_eq!( + repo.scope_status(&scope_c), + Some(("pi".to_string(), "active".to_string())) + ); + + repo.write("file.txt", "one\nchanged\nclean\n"); + repo.drive(&tool_result(&repo, "call_clean", session)) + .expect("tool_result should mark Executed"); + repo.drive(&tool_execution_end(&repo, "call_clean", session)) + .expect("Close should reach the real runtime"); + + assert_eq!( + repo.scope_status(&scope_c), + Some(("pi".to_string(), "closed".to_string())) + ); + assert_eq!( + repo.mutation_events(), + vec![("ai_exclusive".to_string(), Some(scope_c))], + "a clean Pi mutation after guard-triggered recovery must still reach AiExclusive; \ + recovery must not permanently poison the checkout for later, uninterfered-with work" + ); +} diff --git a/cli/src/services/hooks/pi_mutation_scope/health.rs b/cli/src/services/hooks/pi_mutation_scope/health.rs index d7051bed2..587eeaec3 100644 --- a/cli/src/services/hooks/pi_mutation_scope/health.rs +++ b/cli/src/services/hooks/pi_mutation_scope/health.rs @@ -5,8 +5,8 @@ use crate::services::hooks::mutation_scope_health::{ }; use crate::services::mutation_trace::types::ActorKind; -use super::process_owner::is_definitely_dead; use super::state::{self, AttemptPhase, RecoveryState}; +use crate::services::hooks::mutation_scope_owner::is_definitely_dead; pub(crate) fn classify_health(git_dir: &Path) -> MutationScopeAdapterHealth { let state = match state::read_state(git_dir) { @@ -69,12 +69,12 @@ mod tests { use serde_json::{json, Value}; - use super::super::process_owner::ProcessOwner; use super::super::{ force_attempt_owner_dead_for_tests, run_pi_mutation_scope_from_payload_with_seams, AttemptKey, }; use super::*; + use crate::services::hooks::mutation_scope_owner::ProcessOwner; use crate::services::observability::traits::Logger; const FAIL_CLOSED_MESSAGE: &str = @@ -639,7 +639,7 @@ mod tests { pid: dead_pid, instance_token: None, }; - let live_owner = super::super::process_owner::current_process_owner(); + let live_owner = crate::services::hooks::mutation_scope_owner::current_process_owner(); let clear = RecoveryState::Clear; let pending = RecoveryState::Pending { generation: 1 }; diff --git a/cli/src/services/hooks/pi_mutation_scope/lifecycle.rs b/cli/src/services/hooks/pi_mutation_scope/lifecycle.rs new file mode 100644 index 000000000..4d6202ebf --- /dev/null +++ b/cli/src/services/hooks/pi_mutation_scope/lifecycle.rs @@ -0,0 +1,460 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, bail, Result}; + +use crate::services::hooks; +use crate::services::mutation_trace::runtime::resolve_git_dir; +use crate::services::observability::traits::Logger; + +use super::boundary_lock::{AdapterBoundaryLock, DEFAULT_BOUNDARY_LOCK_TIMEOUT}; +use super::state::{self, AdmitDecision, RecoveryFlushCompletion}; +use super::{ + abandon_payload, flush_payload, parse_pi_hook_event, pi_scope_close_event_id, + pi_scope_provenance, pi_scope_start_event_id, scope_boundary_payload, scope_start_payload, + AttemptKey, PiHookEvent, PiScopeProvenance, ToolClassification, +}; + +pub(crate) fn run_pi_mutation_scope_subcommand(logger: Option<&dyn Logger>) -> Result { + let stdin_payload = hooks::read_hook_stdin()?; + run_pi_mutation_scope_from_payload(&stdin_payload, logger) +} + +pub(crate) fn run_pi_mutation_scope_from_payload( + stdin_payload: &str, + logger: Option<&dyn Logger>, +) -> Result { + let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); + let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { + hooks::mutation_scope::run_mutation_scope_from_payload(repository_root, payload, logger) + }; + + run_pi_mutation_scope_from_payload_with_seams( + stdin_payload, + logger, + &resolve_git_dir_fn, + &seam_fn, + ) +} + +#[cfg(test)] +pub(crate) fn run_pi_mutation_scope_from_payload_at_state_root( + state_root: &Path, + stdin_payload: &str, + logger: Option<&dyn Logger>, +) -> Result { + let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); + let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { + hooks::mutation_scope::run_mutation_scope_from_payload_at_state_root( + repository_root, + state_root, + payload, + logger, + ) + }; + + run_pi_mutation_scope_from_payload_with_seams( + stdin_payload, + logger, + &resolve_git_dir_fn, + &seam_fn, + ) +} + +pub(super) type GitDirResolver<'a> = &'a dyn Fn(&str) -> Result; + +pub(super) type IngressSeam<'a> = &'a dyn Fn(&Path, &str, Option<&dyn Logger>) -> Result; + +pub(super) const FAIL_CLOSED_MESSAGE: &str = + "SCE could not establish Pi mutation attribution for this tool execution."; + +pub(super) const FAIL_CLOSED_EVENT: &str = "sce.hooks.pi_mutation_scope.start_fail_closed"; + +pub(super) fn log_fail_closed(logger: Option<&dyn Logger>, context: &str, error: &anyhow::Error) { + if let Some(log) = logger { + log.warn( + FAIL_CLOSED_EVENT, + &error.to_string(), + &[("context", context)], + None, + ); + } +} + +pub(super) fn run_pi_mutation_scope_from_payload_with_seams( + stdin_payload: &str, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, +) -> Result { + let event = parse_pi_hook_event(stdin_payload)?; + dispatch_pi_hook_event(event, logger, resolve_git_dir, seam) +} + +pub(super) fn dispatch_pi_hook_event( + event: PiHookEvent, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, +) -> Result { + match event { + PiHookEvent::ExecutionStart(_identity) => Ok(String::new()), + PiHookEvent::Call(call) => match call.identity.classification() { + ToolClassification::TrackedMutation => { + let provenance = + pi_scope_provenance(&call.identity.session_id, call.model.as_deref()); + establish_tracked_start( + &call.identity.cwd, + &call.identity.attempt_key(), + &call.identity.tool_name, + &provenance, + logger, + resolve_git_dir, + seam, + ) + } + ToolClassification::Untracked => Ok(String::new()), + }, + PiHookEvent::Executed(identity) => { + if !matches!( + identity.classification(), + ToolClassification::TrackedMutation + ) { + return Ok(String::new()); + } + let git_dir = resolve_git_dir(&identity.cwd)?; + state::mark_executed(&git_dir, &identity.attempt_key())?; + Ok(String::new()) + } + PiHookEvent::ExecutionEnd(identity) => { + if !matches!( + identity.classification(), + ToolClassification::TrackedMutation + ) { + return Ok(String::new()); + } + let git_dir = resolve_git_dir(&identity.cwd)?; + let repository_root = Path::new(&identity.cwd); + let key = identity.attempt_key(); + with_boundary_lock(&git_dir, || { + state::normalize_recovery_after_boundary_lock_acquired(&git_dir)?; + handle_tool_execution_end(&git_dir, repository_root, &key, logger, seam) + }) + } + PiHookEvent::ExecutionAbandon(identity) => { + if !matches!( + identity.classification(), + ToolClassification::TrackedMutation + ) { + return Ok(String::new()); + } + let git_dir = resolve_git_dir(&identity.cwd)?; + let repository_root = Path::new(&identity.cwd); + let key = identity.attempt_key(); + with_boundary_lock(&git_dir, || { + state::normalize_recovery_after_boundary_lock_acquired(&git_dir)?; + force_abandon_attempt(&git_dir, repository_root, &key, logger, seam) + }) + } + } +} + +pub(super) fn with_boundary_lock( + git_dir: &Path, + operation: impl FnOnce() -> Result, +) -> Result { + let _boundary = AdapterBoundaryLock::acquire(git_dir, DEFAULT_BOUNDARY_LOCK_TIMEOUT) + .map_err(|error| anyhow!("Failed to acquire adapter boundary lock: {error}"))?; + operation() +} + +pub(super) enum Admission { + Admitted(state::AllocatedAttempt), + Denied, +} + +pub(super) enum StartOutcome { + Established, + Denied, +} + +pub(super) fn establish_tracked_start( + cwd: &str, + key: &AttemptKey, + tool_name: &str, + provenance: &PiScopeProvenance, + logger: Option<&dyn Logger>, + resolve_git_dir: GitDirResolver, + seam: IngressSeam, +) -> Result { + let git_dir = match resolve_git_dir(cwd) { + Ok(git_dir) => git_dir, + Err(error) => { + log_fail_closed(logger, "resolve_git_dir", &error); + return Err(error.context(FAIL_CLOSED_MESSAGE)); + } + }; + let repository_root = Path::new(cwd); + + let outcome = with_boundary_lock(&git_dir, || { + state::normalize_recovery_after_boundary_lock_acquired(&git_dir)?; + + match admit_or_recover(&git_dir, repository_root, key, tool_name, logger, seam)? { + Admission::Admitted(allocated) => { + establish_start( + &git_dir, + repository_root, + &allocated, + provenance, + logger, + seam, + )?; + Ok(StartOutcome::Established) + } + Admission::Denied => Ok(StartOutcome::Denied), + } + }); + + match outcome { + Ok(StartOutcome::Established) => Ok(String::new()), + Ok(StartOutcome::Denied) => bail!(FAIL_CLOSED_MESSAGE), + Err(error) => { + log_fail_closed(logger, "establish_tracked_start", &error); + Err(error.context(FAIL_CLOSED_MESSAGE)) + } + } +} + +pub(super) fn admit_or_recover( + git_dir: &Path, + repository_root: &Path, + key: &AttemptKey, + tool_name: &str, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result { + match reconcile_stale_owners(git_dir, repository_root, logger, seam)? { + RecoveryResolution::Cleared => {} + RecoveryResolution::Unresolved => return Ok(Admission::Denied), + } + + match state::admit_tracked_attempt(git_dir, key, tool_name)? { + AdmitDecision::Admitted(allocated) => Ok(Admission::Admitted(allocated)), + AdmitDecision::RecoveryBlocked + | AdmitDecision::UncertainAttemptBlocked + | AdmitDecision::TerminalAttemptBlocked => Ok(Admission::Denied), + AdmitDecision::FlushClaimed { generation } => { + match resolve_recovery(git_dir, repository_root, generation, logger, seam)? { + RecoveryResolution::Cleared => readmit_after_flush(git_dir, key, tool_name), + RecoveryResolution::Unresolved => Ok(Admission::Denied), + } + } + } +} + +pub(super) fn reconcile_stale_owners( + git_dir: &Path, + repository_root: &Path, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result { + loop { + let dead_scope_ids = state::find_definitely_dead_attempts(git_dir)?; + if dead_scope_ids.is_empty() { + return Ok(RecoveryResolution::Cleared); + } + + let generation = state::begin_terminal_cleanup(git_dir, &dead_scope_ids)?; + if matches!( + resolve_recovery(git_dir, repository_root, generation, logger, seam)?, + RecoveryResolution::Unresolved + ) { + return Ok(RecoveryResolution::Unresolved); + } + } +} + +pub(super) fn readmit_after_flush( + git_dir: &Path, + key: &AttemptKey, + tool_name: &str, +) -> Result { + match state::admit_tracked_attempt(git_dir, key, tool_name)? { + AdmitDecision::Admitted(allocated) => Ok(Admission::Admitted(allocated)), + AdmitDecision::FlushClaimed { generation } => { + state::relinquish_recovery_flush(git_dir, generation)?; + Ok(Admission::Denied) + } + AdmitDecision::RecoveryBlocked + | AdmitDecision::UncertainAttemptBlocked + | AdmitDecision::TerminalAttemptBlocked => Ok(Admission::Denied), + } +} + +pub(super) fn establish_start( + _git_dir: &Path, + repository_root: &Path, + allocated: &state::AllocatedAttempt, + provenance: &PiScopeProvenance, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result<()> { + let scope_id = &allocated.attempt.scope_id; + + let start_payload = + scope_start_payload(scope_id, &pi_scope_start_event_id(scope_id), provenance); + + seam(repository_root, &start_payload, logger)?; + Ok(()) +} + +pub(super) fn handle_tool_execution_end( + git_dir: &Path, + repository_root: &Path, + key: &AttemptKey, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result { + let current = state::read_state(git_dir)?; + let Some(attempt) = current + .attempts + .iter() + .find(|attempt| { + attempt.session_id == key.session_id && attempt.tool_call_id == key.tool_call_id + }) + .cloned() + else { + return Ok(String::new()); + }; + + let doomed_scope_id = attempt.scope_id.clone(); + + if !matches!(attempt.phase, state::AttemptPhase::Executed) { + return abandon_and_consume(git_dir, repository_root, logger, seam, move |candidate| { + candidate.scope_id == doomed_scope_id + }); + } + + let close_payload = scope_boundary_payload( + "close", + &attempt.scope_id, + &pi_scope_close_event_id(&attempt.scope_id), + ); + + if seam(repository_root, &close_payload, logger).is_ok() { + state::remove_attempt(git_dir, &attempt.scope_id)?; + Ok(String::new()) + } else { + abandon_and_consume(git_dir, repository_root, logger, seam, move |candidate| { + candidate.scope_id == doomed_scope_id + }) + } +} + +pub(super) fn force_abandon_attempt( + git_dir: &Path, + repository_root: &Path, + key: &AttemptKey, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result { + let current = state::read_state(git_dir)?; + let Some(attempt) = current + .attempts + .iter() + .find(|attempt| { + attempt.session_id == key.session_id && attempt.tool_call_id == key.tool_call_id + }) + .cloned() + else { + return Ok(String::new()); + }; + + let doomed_scope_id = attempt.scope_id.clone(); + abandon_and_consume(git_dir, repository_root, logger, seam, move |candidate| { + candidate.scope_id == doomed_scope_id + }) +} + +pub(super) enum RecoveryResolution { + Cleared, + Unresolved, +} + +pub(super) fn abandon_and_consume( + git_dir: &Path, + repository_root: &Path, + logger: Option<&dyn Logger>, + seam: IngressSeam, + doomed: impl Fn(&state::AdapterAttempt) -> bool, +) -> Result { + let doomed_scope_ids: Vec = state::read_state(git_dir)? + .attempts + .into_iter() + .filter(|attempt| doomed(attempt)) + .map(|attempt| attempt.scope_id) + .collect(); + if doomed_scope_ids.is_empty() { + return Ok(String::new()); + } + + let generation = state::begin_terminal_cleanup(git_dir, &doomed_scope_ids)?; + resolve_recovery(git_dir, repository_root, generation, logger, seam)?; + Ok(String::new()) +} + +pub(super) fn resolve_recovery( + git_dir: &Path, + repository_root: &Path, + generation: u64, + logger: Option<&dyn Logger>, + seam: IngressSeam, +) -> Result { + let pending_abandon: Vec = state::read_state(git_dir)? + .attempts + .into_iter() + .filter(|attempt| attempt.phase == state::AttemptPhase::PendingAbandon) + .collect(); + + if let Err(error) = seam(repository_root, &flush_payload(), logger) { + log_fail_closed(logger, "recovery_ambiguity_flush", &error); + state::relinquish_recovery_flush(git_dir, generation)?; + return Ok(RecoveryResolution::Unresolved); + } + + for attempt in &pending_abandon { + if let Err(error) = seam(repository_root, &abandon_payload(&attempt.scope_id), logger) { + log_fail_closed(logger, "recovery_abandon", &error); + state::relinquish_recovery_flush(git_dir, generation)?; + return Ok(RecoveryResolution::Unresolved); + } + state::remove_attempt(git_dir, &attempt.scope_id)?; + } + + if let Err(error) = seam(repository_root, &flush_payload(), logger) { + log_fail_closed(logger, "recovery_rebaseline_flush", &error); + state::relinquish_recovery_flush(git_dir, generation)?; + return Ok(RecoveryResolution::Unresolved); + } + + match state::complete_recovery_flush(git_dir, generation)? { + RecoveryFlushCompletion::Cleared => Ok(RecoveryResolution::Cleared), + RecoveryFlushCompletion::Superseded => Ok(RecoveryResolution::Unresolved), + } +} + +#[cfg(test)] +pub(crate) fn force_attempt_owner_dead_for_tests(git_dir: &Path, scope_id: &str) { + let mut dead_child = std::process::Command::new("true") + .spawn() + .expect("spawning 'true' should succeed"); + let dead_pid = i32::try_from(dead_child.id()).expect("pid fits in i32"); + dead_child.wait().expect("child should exit and be reaped"); + state::set_attempt_owner_for_tests( + git_dir, + scope_id, + crate::services::hooks::mutation_scope_owner::ProcessOwner { + pid: dead_pid, + instance_token: None, + }, + ); +} diff --git a/cli/src/services/hooks/pi_mutation_scope/lifecycle_tests.rs b/cli/src/services/hooks/pi_mutation_scope/lifecycle_tests.rs new file mode 100644 index 000000000..6d3be6601 --- /dev/null +++ b/cli/src/services/hooks/pi_mutation_scope/lifecycle_tests.rs @@ -0,0 +1,1155 @@ +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; + +use serde_json::Value; + +use super::state::{read_state, AdapterAttempt, AdapterState, AttemptPhase, RecoveryState}; +use super::*; + +static NEXT_ID: AtomicU64 = AtomicU64::new(0); + +fn temp_git_dir(label: &str) -> PathBuf { + let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "sce-pi-mutation-scope-lifecycle-{label}-{}-{id}", + std::process::id() + )) +} + +const CWD: &str = "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/repo/pi-checkout"; + +struct RecordingSeam { + calls: Mutex>, + fail_operations: Vec, + fail_once_operations: Mutex>, +} + +impl RecordingSeam { + fn new() -> Self { + Self { + calls: Mutex::new(Vec::new()), + fail_operations: Vec::new(), + fail_once_operations: Mutex::new(Vec::new()), + } + } + + fn failing_on(operations: &[&str]) -> Self { + Self { + fail_operations: operations.iter().map(|op| (*op).to_string()).collect(), + ..Self::new() + } + } + + fn failing_once_on(operations: &[&str]) -> Self { + Self { + fail_once_operations: Mutex::new( + operations.iter().map(|op| (*op).to_string()).collect(), + ), + ..Self::new() + } + } + + fn handle(&self, payload: &str) -> Result { + let operation = operation_of(payload); + { + let mut calls = self.calls.lock().expect("seam mutex"); + calls.push(operation.clone()); + } + if self.fail_operations.contains(&operation) { + bail!("seam failure injected by test for '{operation}'"); + } + { + let mut once = self.fail_once_operations.lock().expect("seam mutex"); + if let Some(position) = once.iter().position(|candidate| candidate == &operation) { + once.remove(position); + bail!("transient seam failure injected once by test for '{operation}'"); + } + } + Ok(String::new()) + } + + fn operations(&self) -> Vec { + self.calls.lock().expect("seam mutex").clone() + } +} + +fn operation_of(payload: &str) -> String { + let value: Value = serde_json::from_str(payload).expect("seam payload is JSON"); + value + .get("operation") + .and_then(Value::as_str) + .expect("seam payload has an operation") + .to_string() +} + +fn drive(git_dir: &Path, seam: &RecordingSeam, payload: &str) -> Result { + let resolver = |_cwd: &str| Ok(git_dir.to_path_buf()); + let seam_fn = |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| seam.handle(payload); + run_pi_mutation_scope_from_payload_with_seams(payload, None, &resolver, &seam_fn) +} + +fn tool_call_event(tool_name: &str, tool_call_id: &str) -> String { + json!({ + "hook_event_name": "ToolCall", + "session_id": "ses-main", + "tool_call_id": tool_call_id, + "cwd": CWD, + "tool_name": tool_name, + "model": "openai-codex/gpt-5.5", + }) + .to_string() +} + +fn tool_result_event(tool_name: &str, tool_call_id: &str) -> String { + json!({ + "hook_event_name": "ToolResult", + "session_id": "ses-main", + "tool_call_id": tool_call_id, + "cwd": CWD, + "tool_name": tool_name, + }) + .to_string() +} + +fn tool_execution_end_event(tool_name: &str, tool_call_id: &str) -> String { + json!({ + "hook_event_name": "ToolExecutionEnd", + "session_id": "ses-main", + "tool_call_id": tool_call_id, + "cwd": CWD, + "tool_name": tool_name, + }) + .to_string() +} + +fn tool_execution_abandon_event(tool_name: &str, tool_call_id: &str) -> String { + json!({ + "hook_event_name": "ToolExecutionAbandon", + "session_id": "ses-main", + "tool_call_id": tool_call_id, + "cwd": CWD, + "tool_name": tool_name, + }) + .to_string() +} + +fn tool_execution_abandon_event_for_session( + tool_name: &str, + session_id: &str, + tool_call_id: &str, +) -> String { + json!({ + "hook_event_name": "ToolExecutionAbandon", + "session_id": session_id, + "tool_call_id": tool_call_id, + "cwd": CWD, + "tool_name": tool_name, + }) + .to_string() +} + +fn cleanup(git_dir: &Path) { + let _ = std::fs::remove_dir_all(git_dir); +} + +fn tool_call_event_for_session(tool_name: &str, session_id: &str, tool_call_id: &str) -> String { + json!({ + "hook_event_name": "ToolCall", + "session_id": session_id, + "tool_call_id": tool_call_id, + "cwd": CWD, + "tool_name": tool_name, + "model": "openai-codex/gpt-5.5", + }) + .to_string() +} + +fn tool_result_event_for_session(tool_name: &str, session_id: &str, tool_call_id: &str) -> String { + json!({ + "hook_event_name": "ToolResult", + "session_id": session_id, + "tool_call_id": tool_call_id, + "cwd": CWD, + "tool_name": tool_name, + }) + .to_string() +} + +fn dead_process_owner() -> crate::services::hooks::mutation_scope_owner::ProcessOwner { + let mut dead_child = std::process::Command::new("true") + .spawn() + .expect("spawning 'true' should succeed"); + let dead_pid = i32::try_from(dead_child.id()).expect("pid fits in i32"); + dead_child.wait().expect("child should exit and be reaped"); + crate::services::hooks::mutation_scope_owner::ProcessOwner { + pid: dead_pid, + instance_token: None, + } +} + +fn attempt_owned_by(state: &AdapterState, session_id: &str) -> AdapterAttempt { + state + .attempts + .iter() + .find(|attempt| attempt.session_id == session_id) + .expect("attempt for session must exist") + .clone() +} + +#[test] +fn tool_call_establishes_a_write_ahead_start_and_replays_idempotently() { + let git_dir = temp_git_dir("write-ahead-start"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_call_event("write", "call_1")).expect("first Start"); + drive(&git_dir, &seam, &tool_call_event("write", "call_1")) + .expect("duplicate Start is idempotent"); + + assert_eq!(seam.operations(), vec!["start", "start"]); + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].phase, AttemptPhase::PendingStart); + assert_eq!(state.attempts[0].tool_name, "write"); + + cleanup(&git_dir); +} + +#[test] +fn concurrent_bash_calls_in_one_session_stay_separate_live_scopes() { + let git_dir = temp_git_dir("concurrent-bash"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_call_event("bash", "call_a")).expect("A Start"); + drive(&git_dir, &seam, &tool_call_event("bash", "call_b")).expect("B Start must not retire A"); + + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 2); + assert!(state + .attempts + .iter() + .all(|attempt| attempt.phase == AttemptPhase::PendingStart)); + + cleanup(&git_dir); +} + +#[test] +fn full_success_lifecycle_start_result_close() { + let git_dir = temp_git_dir("success-lifecycle"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); + assert_eq!( + read_state(&git_dir).expect("state readable").attempts[0].phase, + AttemptPhase::PendingStart + ); + + drive(&git_dir, &seam, &tool_result_event("bash", "call_1")).expect("tool_result"); + assert_eq!( + read_state(&git_dir).expect("state readable").attempts[0].phase, + AttemptPhase::Executed, + "D5/D6: tool_result is the sole Executed-transition evidence" + ); + + drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")) + .expect("tool_execution_end closes an Executed attempt"); + assert!(read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); + assert_eq!(seam.operations(), vec!["start", "close"]); + + cleanup(&git_dir); +} + +#[test] +fn tool_execution_end_without_a_preceding_tool_result_abandons_never_closes() { + let git_dir = temp_git_dir("d7-abandon"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); + + drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")) + .expect("D7: a terminal event with no preceding tool_result must abandon"); + + assert!(read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); + assert!( + !seam.operations().contains(&"close".to_string()), + "D7: an unexecuted attempt must never be closed" + ); + assert!(seam.operations().contains(&"abandon".to_string())); + assert!(read_state(&git_dir) + .expect("state readable") + .recovery + .is_clear()); + + cleanup(&git_dir); +} + +#[test] +fn a_failed_close_falls_back_to_abandon_recovery() { + let git_dir = temp_git_dir("close-failure-falls-back"); + let seam = RecordingSeam::failing_on(&["close"]); + + drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); + drive(&git_dir, &seam, &tool_result_event("bash", "call_1")).expect("tool_result"); + + drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")) + .expect("a Close failure must recover via abandon, not surface an error"); + + assert!(read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); + assert!(seam.operations().contains(&"abandon".to_string())); + + cleanup(&git_dir); +} + +#[test] +fn execution_abandon_forces_abandon_even_when_the_attempt_is_already_executed() { + let git_dir = temp_git_dir("d9-execution-abandon-executed"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); + drive(&git_dir, &seam, &tool_result_event("bash", "call_1")).expect("tool_result"); + + drive( + &git_dir, + &seam, + &tool_execution_abandon_event("bash", "call_1"), + ) + .expect("ExecutionAbandon must recover via abandon, not surface an error"); + + assert!(read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); + assert!( + !seam.operations().contains(&"close".to_string()), + "D9: an explicit abandon request must never be treated as a Close, \ + even for an attempt already marked Executed" + ); + assert!(seam.operations().contains(&"abandon".to_string())); + + cleanup(&git_dir); +} + +#[test] +fn execution_abandon_on_a_pending_start_attempt_abandons() { + let git_dir = temp_git_dir("d9-execution-abandon-pending-start"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); + + drive( + &git_dir, + &seam, + &tool_execution_abandon_event("bash", "call_1"), + ) + .expect("ExecutionAbandon must recover a PendingStart attempt via abandon"); + + assert!(read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); + assert!(seam.operations().contains(&"abandon".to_string())); + + cleanup(&git_dir); +} + +#[test] +fn execution_abandon_for_an_unknown_attempt_is_a_safe_no_op() { + let git_dir = temp_git_dir("d9-execution-abandon-unknown"); + let seam = RecordingSeam::new(); + + let result = drive( + &git_dir, + &seam, + &tool_execution_abandon_event("bash", "call_1"), + ) + .expect("an unknown attempt must be a safe no-op, never an error"); + + assert_eq!(result, ""); + assert!(seam.operations().is_empty()); + + cleanup(&git_dir); +} + +#[test] +fn execution_abandon_for_an_untracked_tool_is_a_no_op() { + let git_dir = temp_git_dir("d9-execution-abandon-untracked"); + let seam = RecordingSeam::new(); + + let result = drive( + &git_dir, + &seam, + &tool_execution_abandon_event("read", "call_1"), + ) + .expect("untracked tools are never adapter-relevant"); + + assert_eq!(result, ""); + assert!(seam.operations().is_empty()); + + cleanup(&git_dir); +} + +#[test] +fn duplicate_execution_abandon_on_an_already_abandoned_attempt_is_a_safe_no_op() { + let git_dir = temp_git_dir("d9-execution-abandon-duplicate"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); + + drive( + &git_dir, + &seam, + &tool_execution_abandon_event("bash", "call_1"), + ) + .expect("first ExecutionAbandon retires the attempt"); + assert!(read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); + + let result = drive( + &git_dir, + &seam, + &tool_execution_abandon_event("bash", "call_1"), + ) + .expect("a duplicate ExecutionAbandon for an already-retired attempt must be a safe no-op"); + + assert_eq!(result, ""); + assert_eq!( + seam.operations(), + vec!["start", "flush", "abandon", "flush"], + "a duplicate ExecutionAbandon must never issue a second abandon or a close" + ); + + cleanup(&git_dir); +} + +#[test] +fn execution_abandon_for_one_session_never_touches_another_sessions_attempt_with_the_same_tool_call_id( +) { + let git_dir = temp_git_dir("d9-execution-abandon-cross-session"); + let seam = RecordingSeam::new(); + + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "ses-a", "call_1"), + ) + .expect("session A Start"); + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "ses-b", "call_1"), + ) + .expect("session B Start with the same tool_call_id"); + + assert_eq!( + read_state(&git_dir).expect("state readable").attempts.len(), + 2 + ); + + drive( + &git_dir, + &seam, + &tool_execution_abandon_event_for_session("bash", "ses-a", "call_1"), + ) + .expect("ExecutionAbandon for session A must not error"); + + let remaining = read_state(&git_dir).expect("state readable").attempts; + assert_eq!( + remaining.len(), + 1, + "abandoning session A's attempt must leave session B's untouched" + ); + assert_eq!(remaining[0].session_id, "ses-b"); + assert_eq!(remaining[0].tool_call_id, "call_1"); + assert_eq!(remaining[0].phase, AttemptPhase::PendingStart); + + drive( + &git_dir, + &seam, + &tool_result_event_for_session("bash", "ses-b", "call_1"), + ) + .expect("session B must still be able to progress normally after A's abandon"); + assert_eq!( + read_state(&git_dir).expect("state readable").attempts[0].phase, + AttemptPhase::Executed + ); + + cleanup(&git_dir); +} + +#[test] +fn a_terminal_recovery_flush_failure_leaves_a_pending_recovery_and_denies_new_admission() { + let git_dir = temp_git_dir("recovery-flush-failure"); + let persistently_failing = RecordingSeam::failing_on(&["flush"]); + + drive( + &git_dir, + &persistently_failing, + &tool_call_event("bash", "call_1"), + ) + .expect("Start"); + drive( + &git_dir, + &persistently_failing, + &tool_execution_end_event("bash", "call_1"), + ) + .expect("abandon path swallows the flush failure rather than surfacing an error"); + + assert_eq!( + read_state(&git_dir).expect("state readable").recovery, + RecoveryState::Pending { generation: 1 }, + "a failed ambiguity flush must leave recovery Pending, not Clear" + ); + + let error = drive( + &git_dir, + &persistently_failing, + &tool_call_event("bash", "call_2"), + ) + .expect_err("a new admission must fail closed while recovery remains unresolved"); + assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE)); + + let recovered = RecordingSeam::new(); + drive(&git_dir, &recovered, &tool_call_event("bash", "call_3")) + .expect("a new admission must self-heal once recovery can complete"); + assert!(read_state(&git_dir) + .expect("state readable") + .recovery + .is_clear()); + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].tool_call_id, "call_3"); + + cleanup(&git_dir); +} + +#[test] +fn start_provenance_carries_the_prefixed_session_and_normalized_model_to_the_seam() { + let git_dir = temp_git_dir("provenance-present"); + let captured: Mutex> = Mutex::new(Vec::new()); + let resolver = |_cwd: &str| Ok(git_dir.clone()); + let seam_fn = |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| { + captured + .lock() + .expect("capture mutex") + .push(payload.to_string()); + Ok(String::new()) + }; + + run_pi_mutation_scope_from_payload_with_seams( + &tool_call_event("bash", "call_model"), + None, + &resolver, + &seam_fn, + ) + .expect("Start should succeed"); + + let payloads = captured.into_inner().expect("capture mutex"); + assert_eq!(payloads.len(), 1); + let sent: Value = serde_json::from_str(&payloads[0]).expect("seam payload is JSON"); + assert_eq!( + sent["provenance"]["session_id"].as_str(), + Some("pi_ses-main") + ); + assert_eq!( + sent["provenance"]["model_id"].as_str(), + Some("openai-codex/gpt-5.5") + ); + + cleanup(&git_dir); +} + +#[test] +fn start_provenance_is_null_model_when_the_event_carries_no_model() { + let git_dir = temp_git_dir("provenance-absent"); + let captured: Mutex> = Mutex::new(Vec::new()); + let resolver = |_cwd: &str| Ok(git_dir.clone()); + let seam_fn = |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| { + captured + .lock() + .expect("capture mutex") + .push(payload.to_string()); + Ok(String::new()) + }; + + let payload = json!({ + "hook_event_name": "ToolCall", + "session_id": "ses-main", + "tool_call_id": "call_no_model", + "cwd": CWD, + "tool_name": "bash", + }) + .to_string(); + + run_pi_mutation_scope_from_payload_with_seams(&payload, None, &resolver, &seam_fn) + .expect("Start should succeed"); + + let payloads = captured.into_inner().expect("capture mutex"); + let sent: Value = serde_json::from_str(&payloads[0]).expect("seam payload is JSON"); + assert!(sent["provenance"]["model_id"].is_null()); + + cleanup(&git_dir); +} + +#[test] +fn untracked_tool_call_never_admits_an_attempt() { + let git_dir = temp_git_dir("untracked-no-admit"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_call_event("read", "call_ro")).expect("untracked is inert"); + assert!(seam.operations().is_empty()); + assert!(read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); + + drive(&git_dir, &seam, &tool_result_event("read", "call_ro")).expect("untracked result inert"); + drive( + &git_dir, + &seam, + &tool_execution_end_event("read", "call_ro"), + ) + .expect("untracked terminal inert"); + assert!(seam.operations().is_empty()); + + cleanup(&git_dir); +} + +#[test] +fn a_pending_start_attempt_owned_by_a_dead_process_is_abandoned_not_replayed() { + let git_dir = temp_git_dir("d10-dead-owner-abandon"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("first Start"); + let scope_id = read_state(&git_dir).expect("state readable").attempts[0] + .scope_id + .clone(); + + let mut dead_child = std::process::Command::new("true") + .spawn() + .expect("spawning 'true' should succeed"); + let dead_pid = i32::try_from(dead_child.id()).expect("pid fits in i32"); + dead_child.wait().expect("child should exit and be reaped"); + state::set_attempt_owner_for_tests( + &git_dir, + &scope_id, + crate::services::hooks::mutation_scope_owner::ProcessOwner { + pid: dead_pid, + instance_token: None, + }, + ); + + drive(&git_dir, &seam, &tool_call_event("bash", "call_1")) + .expect("a replay whose recorded owner is positively dead must abandon, not reuse"); + + assert_eq!( + seam.operations(), + vec!["start", "flush", "abandon", "flush", "start"], + "D10: a dead-owner PendingStart must be abandoned via the existing D8 flush/abandon/\ + flush pattern, then the triggering event admitted as a fresh attempt" + ); + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_ne!( + state.attempts[0].scope_id, scope_id, + "the fresh attempt must never reuse the abandoned attempt's ScopeId" + ); + assert_eq!(state.attempts[0].attempt_seq, 2); + assert!(state.recovery.is_clear()); + + cleanup(&git_dir); +} + +#[test] +fn a_pending_start_attempt_owned_by_a_live_process_is_never_abandoned_by_a_replay() { + let git_dir = temp_git_dir("d10-live-owner-no-abandon"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("first Start"); + drive(&git_dir, &seam, &tool_call_event("bash", "call_1")) + .expect("a replay owned by a still-live process must be treated as a normal replay"); + + assert_eq!( + seam.operations(), + vec!["start", "start"], + "no TTL and no elapsed time may ever cause an abandon here: the owner is this test \ + process's own live parent for the whole test" + ); + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].phase, AttemptPhase::PendingStart); + + cleanup(&git_dir); +} + +#[test] +fn a_dead_pending_start_attempt_is_recovered_by_an_unrelated_fresh_session_start() { + let git_dir = temp_git_dir("d10-fresh-session-dead-pending-start"); + let seam = RecordingSeam::new(); + + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "sess-a", "call-a"), + ) + .expect("A's Start"); + let scope_a = + attempt_owned_by(&read_state(&git_dir).expect("state readable"), "sess-a").scope_id; + state::set_attempt_owner_for_tests(&git_dir, &scope_a, dead_process_owner()); + + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "sess-b", "call-b"), + ) + .expect( + "B's Start must recover A's stale owner without ever replaying A's \ + (session_id, tool_call_id) key", + ); + + assert_eq!( + seam.operations(), + vec!["start", "flush", "abandon", "flush", "start"], + "D10: a dead PendingStart owner discovered by an unrelated fresh-session Start must \ + be retired through the existing D8 flush/abandon/flush sequence before the \ + triggering Start is admitted" + ); + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].session_id, "sess-b"); + assert_ne!(state.attempts[0].scope_id, scope_a); + assert!(state.recovery.is_clear()); + + cleanup(&git_dir); +} + +#[test] +fn a_dead_executed_attempt_is_recovered_by_a_fresh_session_start_without_a_synthetic_close() { + let git_dir = temp_git_dir("d10-fresh-session-dead-executed"); + let seam = RecordingSeam::new(); + + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "sess-a", "call-a"), + ) + .expect("A's Start"); + drive( + &git_dir, + &seam, + &tool_result_event_for_session("bash", "sess-a", "call-a"), + ) + .expect("A's tool_result marks Executed"); + let state = read_state(&git_dir).expect("state readable"); + let scope_a = attempt_owned_by(&state, "sess-a").scope_id; + assert_eq!( + attempt_owned_by(&state, "sess-a").phase, + AttemptPhase::Executed + ); + state::set_attempt_owner_for_tests(&git_dir, &scope_a, dead_process_owner()); + + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "sess-b", "call-b"), + ) + .expect("B's Start must recover A's dead Executed attempt"); + + assert_eq!( + seam.operations(), + vec!["start", "flush", "abandon", "flush", "start"], + "a dead Executed attempt must be abandoned/rebaselined via D8, never given a \ + synthetic delayed Close" + ); + assert!( + !seam.operations().contains(&"close".to_string()), + "D9: the current Git tree no longer represents the original terminal observation \ + time, so a dead Executed attempt must never be closed" + ); + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].session_id, "sess-b"); + assert!(state.recovery.is_clear()); + + cleanup(&git_dir); +} + +#[test] +fn a_dead_owner_scope_is_recovered_while_a_live_owner_sibling_survives_untouched() { + let git_dir = temp_git_dir("d10-dead-live-sibling-isolation"); + let seam = RecordingSeam::new(); + + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "sess-a", "call-a"), + ) + .expect("A's Start (owner will die)"); + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "sess-b", "call-b"), + ) + .expect("B's Start (owner stays live)"); + + let state = read_state(&git_dir).expect("state readable"); + let scope_a = attempt_owned_by(&state, "sess-a").scope_id; + let scope_b = attempt_owned_by(&state, "sess-b").scope_id; + state::set_attempt_owner_for_tests(&git_dir, &scope_a, dead_process_owner()); + + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "sess-c", "call-c"), + ) + .expect("C's Start must recover only A"); + + assert_eq!( + seam.operations(), + vec!["start", "start", "flush", "abandon", "flush", "start"], + "exactly one abandon must occur, and only for A's own positively dead owner" + ); + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 2); + assert!(!state.attempts.iter().any(|a| a.scope_id == scope_a)); + let b = attempt_owned_by(&state, "sess-b"); + assert_eq!(b.scope_id, scope_b); + assert_eq!( + b.phase, + AttemptPhase::PendingStart, + "B must survive reconciliation exactly as it was, untouched" + ); + assert_eq!( + attempt_owned_by(&state, "sess-c").phase, + AttemptPhase::PendingStart + ); + + cleanup(&git_dir); +} + +#[test] +fn multiple_dead_owner_scopes_are_retired_in_one_recovery_generation_while_a_live_sibling_survives() +{ + let git_dir = temp_git_dir("d10-multiple-dead-owner-scopes"); + let seam = RecordingSeam::new(); + + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "sess-a", "call-a"), + ) + .expect("A's Start"); + drive( + &git_dir, + &seam, + &tool_result_event_for_session("bash", "sess-a", "call-a"), + ) + .expect("A's tool_result marks Executed"); + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "sess-b", "call-b"), + ) + .expect("B's Start"); + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "sess-q", "call-q"), + ) + .expect("Q's Start (owner stays live)"); + + let state = read_state(&git_dir).expect("state readable"); + let scope_a = attempt_owned_by(&state, "sess-a").scope_id; + let scope_b = attempt_owned_by(&state, "sess-b").scope_id; + let scope_q = attempt_owned_by(&state, "sess-q").scope_id; + let dead_owner = dead_process_owner(); + state::set_attempt_owner_for_tests(&git_dir, &scope_a, dead_owner); + state::set_attempt_owner_for_tests(&git_dir, &scope_b, dead_owner); + + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "sess-c", "call-c"), + ) + .expect("C's Start must recover both A and B, grouped into one recovery generation"); + + assert_eq!( + seam.operations(), + vec!["start", "start", "start", "flush", "abandon", "abandon", "flush", "start"], + "a single flush/abandon.../flush recovery generation must retire every \ + independently-proven-dead scope owned by the same dead process together" + ); + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 2); + assert!(!state.attempts.iter().any(|a| a.scope_id == scope_a)); + assert!(!state.attempts.iter().any(|a| a.scope_id == scope_b)); + assert_eq!(attempt_owned_by(&state, "sess-q").scope_id, scope_q); + assert!(state.recovery.is_clear()); + + cleanup(&git_dir); +} + +#[test] +fn an_owner_that_cannot_be_positively_proven_dead_is_never_abandoned_by_an_unrelated_start() { + let git_dir = temp_git_dir("d10-uncertain-owner-preserved"); + let seam = RecordingSeam::new(); + + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "sess-a", "call-a"), + ) + .expect("A's Start"); + let scope_a = + attempt_owned_by(&read_state(&git_dir).expect("state readable"), "sess-a").scope_id; + state::set_attempt_owner_for_tests( + &git_dir, + &scope_a, + crate::services::hooks::mutation_scope_owner::ProcessOwner { + pid: std::process::id().cast_signed(), + instance_token: None, + }, + ); + + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "sess-c", "call-c"), + ) + .expect( + "C's Start must proceed without touching A, whose owner cannot be positively \ + proven dead", + ); + + assert_eq!( + seam.operations(), + vec!["start", "start"], + "a live pid with no instance-token evidence must never be converted into proof of \ + death: uncertain identity is conservatively treated as alive" + ); + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 2); + assert!(state + .attempts + .iter() + .all(|attempt| attempt.phase == AttemptPhase::PendingStart)); + assert!(state.attempts.iter().any(|a| a.scope_id == scope_a)); + + cleanup(&git_dir); +} + +#[test] +fn an_interrupted_stale_owner_recovery_remains_pending_and_denies_the_triggering_start_until_resumed( +) { + let git_dir = temp_git_dir("d10-interrupted-stale-recovery"); + let seam = RecordingSeam::new(); + + drive( + &git_dir, + &seam, + &tool_call_event_for_session("bash", "sess-a", "call-a"), + ) + .expect("A's Start"); + let scope_a = + attempt_owned_by(&read_state(&git_dir).expect("state readable"), "sess-a").scope_id; + state::set_attempt_owner_for_tests(&git_dir, &scope_a, dead_process_owner()); + + let crashing = RecordingSeam::failing_once_on(&["abandon"]); + let error = drive( + &git_dir, + &crashing, + &tool_call_event_for_session("bash", "sess-b", "call-b"), + ) + .expect_err("a Start that triggers a stale-owner recovery which fails mid-way must not commit"); + assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE)); + + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.recovery, RecoveryState::Pending { generation: 1 }); + assert!( + !state.attempts.iter().any(|a| a.session_id == "sess-b"), + "B must never be admitted while A's stale-owner recovery is still pending" + ); + assert_eq!( + attempt_owned_by(&state, "sess-a").phase, + AttemptPhase::PendingAbandon + ); + + drive( + &git_dir, + &crashing, + &tool_call_event_for_session("bash", "sess-b", "call-b"), + ) + .expect( + "the next boundary-lock acquisition must resume and complete the pending recovery, \ + and only then admit B", + ); + + let state = read_state(&git_dir).expect("state readable"); + assert!(state.recovery.is_clear()); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].session_id, "sess-b"); + assert!(!state.attempts.iter().any(|a| a.scope_id == scope_a)); + + cleanup(&git_dir); +} + +#[test] +fn duplicate_tool_result_after_close_is_a_safe_no_op() { + let git_dir = temp_git_dir("duplicate-tool-result-after-close"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); + drive(&git_dir, &seam, &tool_result_event("bash", "call_1")).expect("tool_result"); + drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")).expect("Close"); + + drive(&git_dir, &seam, &tool_result_event("bash", "call_1")) + .expect("a late duplicate tool_result after Close must be a safe no-op"); + drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")) + .expect("a late duplicate tool_execution_end after Close must be a safe no-op"); + + assert_eq!( + seam.operations(), + vec!["start", "close"], + "a resurrected attempt must never re-enter the runtime seam after its own Close" + ); + assert!(read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); + + cleanup(&git_dir); +} + +#[test] +fn duplicate_tool_execution_end_after_abandon_is_a_safe_no_op() { + let git_dir = temp_git_dir("duplicate-terminal-after-abandon"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); + drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")).expect("D7 abandon"); + + drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")) + .expect("a late duplicate terminal event after abandon must be a safe no-op"); + + assert_eq!( + seam.operations(), + vec!["start", "flush", "abandon", "flush"], + "a duplicate terminal delivery for an already-abandoned attempt must never issue a \ + second abandon" + ); + + cleanup(&git_dir); +} + +#[test] +fn abandoning_one_sibling_never_touches_a_concurrent_sibling_in_the_same_session() { + let git_dir = temp_git_dir("sibling-abandon-isolation"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_call_event("bash", "call_a")).expect("A Start"); + drive(&git_dir, &seam, &tool_call_event("bash", "call_b")).expect("B Start"); + drive(&git_dir, &seam, &tool_result_event("bash", "call_b")).expect("B tool_result"); + + drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_a")) + .expect("A's terminal event with no tool_result must abandon only A"); + + let state = read_state(&git_dir).expect("state readable"); + assert_eq!( + state.attempts.len(), + 1, + "abandoning A must never remove or block sibling B" + ); + assert_eq!(state.attempts[0].tool_call_id, "call_b"); + assert_eq!(state.attempts[0].phase, AttemptPhase::Executed); + + drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_b")) + .expect("B must still close normally after A's abandonment and recovery"); + assert!(read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); + assert_eq!( + seam.operations(), + vec!["start", "start", "flush", "abandon", "flush", "close"] + ); + + cleanup(&git_dir); +} + +#[test] +fn a_crash_mid_abandon_loop_is_resumed_and_completed_on_the_next_boundary_lock_acquisition() { + let git_dir = temp_git_dir("crash-mid-abandon-loop"); + let crashing = RecordingSeam::failing_once_on(&["abandon"]); + + drive(&git_dir, &crashing, &tool_call_event("bash", "call_1")).expect("Start"); + drive( + &git_dir, + &crashing, + &tool_execution_end_event("bash", "call_1"), + ) + .expect( + "a transient abandon failure mid-recovery must leave recovery Pending, not surface \ + an error, simulating a crash between marking PendingAbandon and completing the \ + flush/abandon/flush sequence", + ); + + assert_eq!( + read_state(&git_dir).expect("state readable").recovery, + RecoveryState::Pending { generation: 1 }, + "the interrupted abandon loop must leave recovery durably Pending for the next \ + boundary-lock acquisition to resume, never Clear and never lost" + ); + assert_eq!( + read_state(&git_dir) + .expect("state readable") + .attempts + .first() + .expect("the doomed attempt must still be recorded") + .phase, + AttemptPhase::PendingAbandon + ); + + drive(&git_dir, &crashing, &tool_call_event("bash", "call_2")) + .expect("recovery must self-heal and complete on the very next invocation"); + + assert!(read_state(&git_dir) + .expect("state readable") + .recovery + .is_clear()); + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].tool_call_id, "call_2"); + + cleanup(&git_dir); +} + +#[test] +fn a_reused_tool_call_id_after_terminal_cleanup_gets_a_distinct_scope_id() { + let git_dir = temp_git_dir("terminal-scope-id-non-reuse"); + let seam = RecordingSeam::new(); + + drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("first Start"); + drive(&git_dir, &seam, &tool_result_event("bash", "call_1")).expect("first tool_result"); + drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")).expect("first Close"); + assert!(read_state(&git_dir) + .expect("state readable") + .attempts + .is_empty()); + + drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("reused toolCallId Start"); + let state = read_state(&git_dir).expect("state readable"); + assert_eq!(state.attempts.len(), 1); + assert_eq!(state.attempts[0].attempt_seq, 2); + + cleanup(&git_dir); +} diff --git a/cli/src/services/hooks/pi_mutation_scope/mod.rs b/cli/src/services/hooks/pi_mutation_scope/mod.rs index a75a3e749..017e6bcf4 100644 --- a/cli/src/services/hooks/pi_mutation_scope/mod.rs +++ b/cli/src/services/hooks/pi_mutation_scope/mod.rs @@ -1,3065 +1,60 @@ #![allow(dead_code)] mod boundary_lock; +mod events; pub(crate) mod health; +mod lifecycle; mod os_lock; -pub(crate) mod process_owner; +mod payload; pub(crate) mod state; -use std::path::{Path, PathBuf}; - -use anyhow::{anyhow, bail, Context, Result}; -use serde_json::{json, Map, Value}; - -use crate::services::hooks::{normalize_pi_model_id, prefixed_diff_trace_session_id, PI_TOOL_NAME}; +#[allow(unused_imports)] use crate::services::mutation_trace::runtime::resolve_git_dir; +#[allow(unused_imports)] use crate::services::observability::traits::Logger; +#[allow(unused_imports)] +use anyhow::{anyhow, bail, Context, Result}; +#[allow(unused_imports)] +use std::path::{Path, PathBuf}; -use boundary_lock::{AdapterBoundaryLock, DEFAULT_BOUNDARY_LOCK_TIMEOUT}; -use state::{AdmitDecision, RecoveryFlushCompletion}; - -const HOOK_EVENT_NAME_FIELD: &str = "hook_event_name"; -const SESSION_ID_FIELD: &str = "session_id"; -const TOOL_CALL_ID_FIELD: &str = "tool_call_id"; -const CWD_FIELD: &str = "cwd"; -const TOOL_NAME_FIELD: &str = "tool_name"; -const MODEL_FIELD: &str = "model"; - -const HOOK_EVENT_TOOL_EXECUTION_START: &str = "ToolExecutionStart"; -const HOOK_EVENT_TOOL_CALL: &str = "ToolCall"; -const HOOK_EVENT_TOOL_RESULT: &str = "ToolResult"; -const HOOK_EVENT_TOOL_EXECUTION_END: &str = "ToolExecutionEnd"; -const HOOK_EVENT_TOOL_EXECUTION_ABANDON: &str = "ToolExecutionAbandon"; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) enum PiHookEvent { - ExecutionStart(PiToolIdentity), - Call(PiToolCall), - Executed(PiToolIdentity), - ExecutionEnd(PiToolIdentity), - ExecutionAbandon(PiToolIdentity), -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct PiToolIdentity { - pub session_id: String, - pub tool_call_id: String, - pub cwd: String, - pub tool_name: String, -} - -impl PiToolIdentity { - pub(crate) fn attempt_key(&self) -> AttemptKey { - AttemptKey { - session_id: self.session_id.clone(), - tool_call_id: self.tool_call_id.clone(), - } - } - - pub(crate) fn classification(&self) -> ToolClassification { - classify_tool(&self.tool_name) - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct PiToolCall { - pub identity: PiToolIdentity, - pub model: Option, -} - -#[allow(clippy::struct_field_names)] -#[derive(Clone, Debug, Eq, PartialEq, Hash)] -pub(crate) struct AttemptKey { - pub session_id: String, - pub tool_call_id: String, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum ToolClassification { - TrackedMutation, - Untracked, -} - -const TRACKED_MUTATION_TOOL_NAMES: &[&str] = &["bash", "edit", "write"]; - -pub(crate) fn classify_tool(tool_name: &str) -> ToolClassification { - if TRACKED_MUTATION_TOOL_NAMES.contains(&tool_name) { - ToolClassification::TrackedMutation - } else { - ToolClassification::Untracked - } -} - -const PI_SCOPE_ID_SCHEME: &str = "pi-tool-v1"; - -pub(crate) fn format_pi_scope_id(key: &AttemptKey, attempt_seq: u64) -> String { - format!( - "{PI_SCOPE_ID_SCHEME}|n={attempt_seq}|s={}:{}|c={}:{}", - key.session_id.len(), - key.session_id, - key.tool_call_id.len(), - key.tool_call_id, - ) -} - -pub(crate) fn pi_scope_start_event_id(scope_id: &str) -> String { - format!("{scope_id}|start") -} - -pub(crate) fn pi_scope_close_event_id(scope_id: &str) -> String { - format!("{scope_id}|close") -} - -const ACTOR_KIND_PI: &str = "pi"; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct PiScopeProvenance { - pub session_id: String, - pub model_id: Option, -} - -pub(crate) fn pi_scope_provenance(session_id: &str, model: Option<&str>) -> PiScopeProvenance { - PiScopeProvenance { - session_id: prefixed_diff_trace_session_id(PI_TOOL_NAME, session_id), - model_id: model.and_then(normalize_pi_model_id), - } -} - -pub(crate) fn parse_pi_hook_event(stdin_payload: &str) -> Result { - if stdin_payload.trim().is_empty() { - bail!(validation_error( - "expected a JSON object, got an empty payload" - )); - } - - let parsed: Value = serde_json::from_str(stdin_payload) - .with_context(|| validation_error("expected valid JSON"))?; - let object = parsed - .as_object() - .ok_or_else(|| anyhow!(validation_error("expected a JSON object")))?; - - let hook_event_name = required_non_blank_str(object, HOOK_EVENT_NAME_FIELD)?; - - match hook_event_name.as_str() { - HOOK_EVENT_TOOL_EXECUTION_START => { - parse_tool_identity(object).map(PiHookEvent::ExecutionStart) - } - HOOK_EVENT_TOOL_CALL => parse_tool_call(object).map(PiHookEvent::Call), - HOOK_EVENT_TOOL_RESULT => parse_tool_identity(object).map(PiHookEvent::Executed), - HOOK_EVENT_TOOL_EXECUTION_END => parse_tool_identity(object).map(PiHookEvent::ExecutionEnd), - HOOK_EVENT_TOOL_EXECUTION_ABANDON => { - parse_tool_identity(object).map(PiHookEvent::ExecutionAbandon) - } - other => bail!(validation_error(&format!( - "unsupported hook_event_name '{other}'" - ))), - } -} - -fn parse_tool_identity(object: &Map) -> Result { - Ok(PiToolIdentity { - session_id: required_non_blank_str(object, SESSION_ID_FIELD)?, - tool_call_id: required_non_blank_str(object, TOOL_CALL_ID_FIELD)?, - cwd: required_non_blank_str(object, CWD_FIELD)?, - tool_name: required_non_blank_str(object, TOOL_NAME_FIELD)?, - }) -} - -fn parse_tool_call(object: &Map) -> Result { - Ok(PiToolCall { - identity: parse_tool_identity(object)?, - model: optional_non_blank_str(object, MODEL_FIELD)?, - }) -} - -fn required_field<'a>(object: &'a Map, field: &str) -> Result<&'a Value> { - object.get(field).ok_or_else(|| { - anyhow!(validation_error(&format!( - "missing required field '{field}'" - ))) - }) -} - -fn required_str(object: &Map, field: &str) -> Result { - required_field(object, field)? - .as_str() - .map(str::to_owned) - .ok_or_else(|| { - anyhow!(validation_error(&format!( - "field '{field}' must be a string" - ))) - }) -} - -fn required_non_blank_str(object: &Map, field: &str) -> Result { - let value = required_str(object, field)?; - if value.trim().is_empty() { - bail!(validation_error(&format!( - "field '{field}' must be a non-blank string" - ))); - } - Ok(value) -} - -fn optional_non_blank_str(object: &Map, field: &str) -> Result> { - match object.get(field) { - None | Some(Value::Null) => Ok(None), - Some(Value::String(value)) => { - if value.trim().is_empty() { - bail!(validation_error(&format!( - "field '{field}' must be null, absent, or a non-blank string" - ))); - } - Ok(Some(value.clone())) - } - Some(_) => bail!(validation_error(&format!( - "field '{field}' must be null, absent, or a non-blank string" - ))), - } -} - -fn validation_error(detail: &str) -> String { - format!("Invalid Pi hook event payload from STDIN: {detail}.") -} - -pub(crate) fn run_pi_mutation_scope_subcommand(logger: Option<&dyn Logger>) -> Result { - let stdin_payload = super::read_hook_stdin()?; - run_pi_mutation_scope_from_payload(&stdin_payload, logger) -} - -pub(crate) fn run_pi_mutation_scope_from_payload( - stdin_payload: &str, - logger: Option<&dyn Logger>, -) -> Result { - let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); - let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { - super::mutation_scope::run_mutation_scope_from_payload(repository_root, payload, logger) - }; - - run_pi_mutation_scope_from_payload_with_seams( - stdin_payload, - logger, - &resolve_git_dir_fn, - &seam_fn, - ) -} +#[allow(unused_imports)] +use serde_json::{json, Map, Value}; +#[allow(unused_imports)] +pub(crate) use events::{ + classify_tool, format_pi_scope_id, parse_pi_hook_event, pi_scope_close_event_id, + pi_scope_provenance, pi_scope_start_event_id, AttemptKey, PiHookEvent, PiScopeProvenance, + PiToolCall, PiToolIdentity, ToolClassification, +}; +#[allow(unused_imports)] +use events::{ + ACTOR_KIND_PI, CWD_FIELD, HOOK_EVENT_NAME_FIELD, HOOK_EVENT_TOOL_CALL, + HOOK_EVENT_TOOL_EXECUTION_ABANDON, HOOK_EVENT_TOOL_EXECUTION_END, + HOOK_EVENT_TOOL_EXECUTION_START, HOOK_EVENT_TOOL_RESULT, MODEL_FIELD, SESSION_ID_FIELD, + TOOL_CALL_ID_FIELD, TOOL_NAME_FIELD, +}; #[cfg(test)] -pub(crate) fn run_pi_mutation_scope_from_payload_at_state_root( - state_root: &Path, - stdin_payload: &str, - logger: Option<&dyn Logger>, -) -> Result { - let resolve_git_dir_fn = |cwd: &str| resolve_git_dir(Path::new(cwd)); - let seam_fn = |repository_root: &Path, payload: &str, logger: Option<&dyn Logger>| { - super::mutation_scope::run_mutation_scope_from_payload_at_state_root( - repository_root, - state_root, - payload, - logger, - ) - }; - - run_pi_mutation_scope_from_payload_with_seams( - stdin_payload, - logger, - &resolve_git_dir_fn, - &seam_fn, - ) -} - -type GitDirResolver<'a> = &'a dyn Fn(&str) -> Result; - -type IngressSeam<'a> = &'a dyn Fn(&Path, &str, Option<&dyn Logger>) -> Result; - -const FAIL_CLOSED_MESSAGE: &str = - "SCE could not establish Pi mutation attribution for this tool execution."; - -const FAIL_CLOSED_EVENT: &str = "sce.hooks.pi_mutation_scope.start_fail_closed"; - -fn log_fail_closed(logger: Option<&dyn Logger>, context: &str, error: &anyhow::Error) { - if let Some(log) = logger { - log.warn( - FAIL_CLOSED_EVENT, - &error.to_string(), - &[("context", context)], - None, - ); - } -} - -fn run_pi_mutation_scope_from_payload_with_seams( - stdin_payload: &str, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - seam: IngressSeam, -) -> Result { - let event = parse_pi_hook_event(stdin_payload)?; - dispatch_pi_hook_event(event, logger, resolve_git_dir, seam) -} - -fn dispatch_pi_hook_event( - event: PiHookEvent, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - seam: IngressSeam, -) -> Result { - match event { - PiHookEvent::ExecutionStart(_identity) => Ok(String::new()), - PiHookEvent::Call(call) => match call.identity.classification() { - ToolClassification::TrackedMutation => { - let provenance = - pi_scope_provenance(&call.identity.session_id, call.model.as_deref()); - establish_tracked_start( - &call.identity.cwd, - &call.identity.attempt_key(), - &call.identity.tool_name, - &provenance, - logger, - resolve_git_dir, - seam, - ) - } - ToolClassification::Untracked => Ok(String::new()), - }, - PiHookEvent::Executed(identity) => { - if !matches!( - identity.classification(), - ToolClassification::TrackedMutation - ) { - return Ok(String::new()); - } - let git_dir = resolve_git_dir(&identity.cwd)?; - state::mark_executed(&git_dir, &identity.attempt_key())?; - Ok(String::new()) - } - PiHookEvent::ExecutionEnd(identity) => { - if !matches!( - identity.classification(), - ToolClassification::TrackedMutation - ) { - return Ok(String::new()); - } - let git_dir = resolve_git_dir(&identity.cwd)?; - let repository_root = Path::new(&identity.cwd); - let key = identity.attempt_key(); - with_boundary_lock(&git_dir, || { - state::normalize_recovery_after_boundary_lock_acquired(&git_dir)?; - handle_tool_execution_end(&git_dir, repository_root, &key, logger, seam) - }) - } - PiHookEvent::ExecutionAbandon(identity) => { - if !matches!( - identity.classification(), - ToolClassification::TrackedMutation - ) { - return Ok(String::new()); - } - let git_dir = resolve_git_dir(&identity.cwd)?; - let repository_root = Path::new(&identity.cwd); - let key = identity.attempt_key(); - with_boundary_lock(&git_dir, || { - state::normalize_recovery_after_boundary_lock_acquired(&git_dir)?; - force_abandon_attempt(&git_dir, repository_root, &key, logger, seam) - }) - } - } -} - -fn with_boundary_lock(git_dir: &Path, operation: impl FnOnce() -> Result) -> Result { - let _boundary = AdapterBoundaryLock::acquire(git_dir, DEFAULT_BOUNDARY_LOCK_TIMEOUT) - .map_err(|error| anyhow!("Failed to acquire adapter boundary lock: {error}"))?; - operation() -} - -enum Admission { - Admitted(state::AllocatedAttempt), - Denied, -} - -enum StartOutcome { - Established, - Denied, -} - -fn establish_tracked_start( - cwd: &str, - key: &AttemptKey, - tool_name: &str, - provenance: &PiScopeProvenance, - logger: Option<&dyn Logger>, - resolve_git_dir: GitDirResolver, - seam: IngressSeam, -) -> Result { - let git_dir = match resolve_git_dir(cwd) { - Ok(git_dir) => git_dir, - Err(error) => { - log_fail_closed(logger, "resolve_git_dir", &error); - return Err(error.context(FAIL_CLOSED_MESSAGE)); - } - }; - let repository_root = Path::new(cwd); - - let outcome = with_boundary_lock(&git_dir, || { - state::normalize_recovery_after_boundary_lock_acquired(&git_dir)?; - - match admit_or_recover(&git_dir, repository_root, key, tool_name, logger, seam)? { - Admission::Admitted(allocated) => { - establish_start( - &git_dir, - repository_root, - &allocated, - provenance, - logger, - seam, - )?; - Ok(StartOutcome::Established) - } - Admission::Denied => Ok(StartOutcome::Denied), - } - }); - - match outcome { - Ok(StartOutcome::Established) => Ok(String::new()), - Ok(StartOutcome::Denied) => bail!(FAIL_CLOSED_MESSAGE), - Err(error) => { - log_fail_closed(logger, "establish_tracked_start", &error); - Err(error.context(FAIL_CLOSED_MESSAGE)) - } - } -} - -fn admit_or_recover( - git_dir: &Path, - repository_root: &Path, - key: &AttemptKey, - tool_name: &str, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result { - match reconcile_stale_owners(git_dir, repository_root, logger, seam)? { - RecoveryResolution::Cleared => {} - RecoveryResolution::Unresolved => return Ok(Admission::Denied), - } - - match state::admit_tracked_attempt(git_dir, key, tool_name)? { - AdmitDecision::Admitted(allocated) => Ok(Admission::Admitted(allocated)), - AdmitDecision::RecoveryBlocked - | AdmitDecision::UncertainAttemptBlocked - | AdmitDecision::TerminalAttemptBlocked => Ok(Admission::Denied), - AdmitDecision::FlushClaimed { generation } => { - match resolve_recovery(git_dir, repository_root, generation, logger, seam)? { - RecoveryResolution::Cleared => readmit_after_flush(git_dir, key, tool_name), - RecoveryResolution::Unresolved => Ok(Admission::Denied), - } - } - } -} - -/// D10: every tracked Start admission is a reconciliation opportunity, independent of the -/// incoming key. Repeatedly collects `PendingStart`/`Executed` attempts with a positively dead -/// owner (any session, any prior process) and retires them through the existing D8 -/// flush/abandon/flush sequence, grouping every independently-proven-dead scope into one -/// generation per pass. Live and uncertain-owner attempts are left untouched. -fn reconcile_stale_owners( - git_dir: &Path, - repository_root: &Path, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result { - loop { - let dead_scope_ids = state::find_definitely_dead_attempts(git_dir)?; - if dead_scope_ids.is_empty() { - return Ok(RecoveryResolution::Cleared); - } - - let generation = state::begin_terminal_cleanup(git_dir, &dead_scope_ids)?; - if matches!( - resolve_recovery(git_dir, repository_root, generation, logger, seam)?, - RecoveryResolution::Unresolved - ) { - return Ok(RecoveryResolution::Unresolved); - } - } -} - -fn readmit_after_flush(git_dir: &Path, key: &AttemptKey, tool_name: &str) -> Result { - match state::admit_tracked_attempt(git_dir, key, tool_name)? { - AdmitDecision::Admitted(allocated) => Ok(Admission::Admitted(allocated)), - AdmitDecision::FlushClaimed { generation } => { - state::relinquish_recovery_flush(git_dir, generation)?; - Ok(Admission::Denied) - } - AdmitDecision::RecoveryBlocked - | AdmitDecision::UncertainAttemptBlocked - | AdmitDecision::TerminalAttemptBlocked => Ok(Admission::Denied), - } -} - -fn establish_start( - _git_dir: &Path, - repository_root: &Path, - allocated: &state::AllocatedAttempt, - provenance: &PiScopeProvenance, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result<()> { - let scope_id = &allocated.attempt.scope_id; - - let start_payload = - scope_start_payload(scope_id, &pi_scope_start_event_id(scope_id), provenance); - - seam(repository_root, &start_payload, logger)?; - Ok(()) -} - -fn handle_tool_execution_end( - git_dir: &Path, - repository_root: &Path, - key: &AttemptKey, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result { - let current = state::read_state(git_dir)?; - let Some(attempt) = current - .attempts - .iter() - .find(|attempt| { - attempt.session_id == key.session_id && attempt.tool_call_id == key.tool_call_id - }) - .cloned() - else { - return Ok(String::new()); - }; - - let doomed_scope_id = attempt.scope_id.clone(); - - if !matches!(attempt.phase, state::AttemptPhase::Executed) { - return abandon_and_consume(git_dir, repository_root, logger, seam, move |candidate| { - candidate.scope_id == doomed_scope_id - }); - } - - let close_payload = scope_boundary_payload( - "close", - &attempt.scope_id, - &pi_scope_close_event_id(&attempt.scope_id), - ); - - if seam(repository_root, &close_payload, logger).is_ok() { - state::remove_attempt(git_dir, &attempt.scope_id)?; - Ok(String::new()) - } else { - abandon_and_consume(git_dir, repository_root, logger, seam, move |candidate| { - candidate.scope_id == doomed_scope_id - }) - } -} - -fn force_abandon_attempt( - git_dir: &Path, - repository_root: &Path, - key: &AttemptKey, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result { - let current = state::read_state(git_dir)?; - let Some(attempt) = current - .attempts - .iter() - .find(|attempt| { - attempt.session_id == key.session_id && attempt.tool_call_id == key.tool_call_id - }) - .cloned() - else { - return Ok(String::new()); - }; - - let doomed_scope_id = attempt.scope_id.clone(); - abandon_and_consume(git_dir, repository_root, logger, seam, move |candidate| { - candidate.scope_id == doomed_scope_id - }) -} - -enum RecoveryResolution { - Cleared, - Unresolved, -} - -fn abandon_and_consume( - git_dir: &Path, - repository_root: &Path, - logger: Option<&dyn Logger>, - seam: IngressSeam, - doomed: impl Fn(&state::AdapterAttempt) -> bool, -) -> Result { - let doomed_scope_ids: Vec = state::read_state(git_dir)? - .attempts - .into_iter() - .filter(|attempt| doomed(attempt)) - .map(|attempt| attempt.scope_id) - .collect(); - if doomed_scope_ids.is_empty() { - return Ok(String::new()); - } - - let generation = state::begin_terminal_cleanup(git_dir, &doomed_scope_ids)?; - resolve_recovery(git_dir, repository_root, generation, logger, seam)?; - Ok(String::new()) -} - -fn resolve_recovery( - git_dir: &Path, - repository_root: &Path, - generation: u64, - logger: Option<&dyn Logger>, - seam: IngressSeam, -) -> Result { - let pending_abandon: Vec = state::read_state(git_dir)? - .attempts - .into_iter() - .filter(|attempt| attempt.phase == state::AttemptPhase::PendingAbandon) - .collect(); - - if let Err(error) = seam(repository_root, &flush_payload(), logger) { - log_fail_closed(logger, "recovery_ambiguity_flush", &error); - state::relinquish_recovery_flush(git_dir, generation)?; - return Ok(RecoveryResolution::Unresolved); - } - - for attempt in &pending_abandon { - if let Err(error) = seam(repository_root, &abandon_payload(&attempt.scope_id), logger) { - log_fail_closed(logger, "recovery_abandon", &error); - state::relinquish_recovery_flush(git_dir, generation)?; - return Ok(RecoveryResolution::Unresolved); - } - state::remove_attempt(git_dir, &attempt.scope_id)?; - } - - if let Err(error) = seam(repository_root, &flush_payload(), logger) { - log_fail_closed(logger, "recovery_rebaseline_flush", &error); - state::relinquish_recovery_flush(git_dir, generation)?; - return Ok(RecoveryResolution::Unresolved); - } - - match state::complete_recovery_flush(git_dir, generation)? { - RecoveryFlushCompletion::Cleared => Ok(RecoveryResolution::Cleared), - RecoveryFlushCompletion::Superseded => Ok(RecoveryResolution::Unresolved), - } -} - -fn scope_boundary_payload(operation: &str, scope_id: &str, event_id: &str) -> String { - json!({ - "operation": operation, - "scope_id": scope_id, - "event_id": event_id, - "actor_kind": ACTOR_KIND_PI, - }) - .to_string() -} - -fn scope_start_payload(scope_id: &str, event_id: &str, provenance: &PiScopeProvenance) -> String { - json!({ - "operation": "start", - "scope_id": scope_id, - "event_id": event_id, - "actor_kind": ACTOR_KIND_PI, - "provenance": { - "session_id": provenance.session_id, - "model_id": provenance.model_id, - }, - }) - .to_string() -} - -fn abandon_payload(scope_id: &str) -> String { - json!({ - "operation": "abandon", - "scope_id": scope_id, - }) - .to_string() -} - -fn flush_payload() -> String { - json!({ "operation": "flush" }).to_string() -} - +#[allow(unused_imports)] +pub(crate) use lifecycle::force_attempt_owner_dead_for_tests; #[cfg(test)] -pub(crate) fn force_attempt_owner_dead_for_tests(git_dir: &Path, scope_id: &str) { - let mut dead_child = std::process::Command::new("true") - .spawn() - .expect("spawning 'true' should succeed"); - let dead_pid = i32::try_from(dead_child.id()).expect("pid fits in i32"); - dead_child.wait().expect("child should exit and be reaped"); - state::set_attempt_owner_for_tests( - git_dir, - scope_id, - process_owner::ProcessOwner { - pid: dead_pid, - instance_token: None, - }, - ); -} +pub(crate) use lifecycle::run_pi_mutation_scope_from_payload_at_state_root; +#[allow(unused_imports)] +pub(crate) use lifecycle::{run_pi_mutation_scope_from_payload, run_pi_mutation_scope_subcommand}; +#[allow(unused_imports)] +use lifecycle::{ + run_pi_mutation_scope_from_payload_with_seams, FAIL_CLOSED_EVENT, FAIL_CLOSED_MESSAGE, +}; +#[allow(unused_imports)] +use payload::{abandon_payload, flush_payload, scope_boundary_payload, scope_start_payload}; #[cfg(test)] -mod tests { - use super::*; - - fn tool_event_json(hook_event_name: &str, overrides: &[(&str, Value)]) -> String { - let mut object = Map::new(); - object.insert( - HOOK_EVENT_NAME_FIELD.to_string(), - Value::String(hook_event_name.to_string()), - ); - object.insert( - SESSION_ID_FIELD.to_string(), - Value::String("01a091f4-session".to_string()), - ); - object.insert( - TOOL_CALL_ID_FIELD.to_string(), - Value::String("call_1|fc_1".to_string()), - ); - object.insert( - CWD_FIELD.to_string(), - Value::String("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/repo/checkout".to_string()), - ); - object.insert( - TOOL_NAME_FIELD.to_string(), - Value::String("write".to_string()), - ); - for (field, value) in overrides { - object.insert((*field).to_string(), value.clone()); - } - Value::Object(object).to_string() - } - - fn key(session_id: &str, tool_call_id: &str) -> AttemptKey { - AttemptKey { - session_id: session_id.to_string(), - tool_call_id: tool_call_id.to_string(), - } - } - - fn tool_call(payload: &str) -> PiToolCall { - match parse_pi_hook_event(payload).expect("valid ToolCall parses") { - PiHookEvent::Call(call) => call, - other => panic!("expected ToolCall, got {other:?}"), - } - } - - #[test] - fn empty_payload_is_rejected() { - let error = parse_pi_hook_event(" ").unwrap_err().to_string(); - assert_eq!( - error, - "Invalid Pi hook event payload from STDIN: expected a JSON object, got an empty payload." - ); - } - - #[test] - fn non_object_json_is_rejected() { - for payload in ["[]", "\"ToolCall\"", "42", "null"] { - let error = parse_pi_hook_event(payload).unwrap_err().to_string(); - assert!( - error.contains("expected a JSON object"), - "payload {payload:?} produced {error:?}" - ); - } - } - - #[test] - fn invalid_json_is_rejected() { - let error = parse_pi_hook_event("{not json").unwrap_err().to_string(); - assert!( - error.contains("Invalid Pi hook event payload from STDIN: expected valid JSON"), - "{error:?}" - ); - } - - #[test] - fn unsupported_hook_event_name_is_rejected() { - for name in ["PreToolUse", "tool_call", "chat.params", ""] { - let payload = tool_event_json(name, &[]); - let error = parse_pi_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains("hook_event_name"), - "name {name:?} produced {error:?}" - ); - } - } - - #[test] - fn missing_required_fields_are_rejected_without_fabricating_identity() { - for field in [ - SESSION_ID_FIELD, - TOOL_CALL_ID_FIELD, - CWD_FIELD, - TOOL_NAME_FIELD, - ] { - let mut object: Map = - serde_json::from_str(&tool_event_json(HOOK_EVENT_TOOL_CALL, &[])).unwrap(); - object.remove(field); - let payload = Value::Object(object).to_string(); - - let error = parse_pi_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains(&format!("'{field}'")), - "missing {field} produced {error:?}" - ); - } - } - - #[test] - fn blank_required_fields_are_rejected() { - for field in [ - SESSION_ID_FIELD, - TOOL_CALL_ID_FIELD, - CWD_FIELD, - TOOL_NAME_FIELD, - ] { - let payload = tool_event_json( - HOOK_EVENT_TOOL_CALL, - &[(field, Value::String(" ".to_string()))], - ); - let error = parse_pi_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains(&format!("field '{field}' must be a non-blank string")), - "blank {field} produced {error:?}" - ); - } - } - - #[test] - fn wrong_typed_fields_are_rejected() { - let payload = tool_event_json( - HOOK_EVENT_TOOL_CALL, - &[(TOOL_CALL_ID_FIELD, Value::Bool(true))], - ); - let error = parse_pi_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains("field 'tool_call_id' must be a string"), - "{error:?}" - ); - } - - #[test] - fn wrong_typed_optional_model_is_rejected() { - let payload = tool_event_json(HOOK_EVENT_TOOL_CALL, &[(MODEL_FIELD, Value::Bool(false))]); - let error = parse_pi_hook_event(&payload).unwrap_err().to_string(); - assert!( - error.contains("field 'model' must be null, absent, or a non-blank string"), - "{error:?}" - ); - } - - #[test] - fn tool_call_parses_identity_and_model() { - let call = tool_call(&tool_event_json( - HOOK_EVENT_TOOL_CALL, - &[ - (TOOL_NAME_FIELD, Value::String("edit".to_string())), - ( - MODEL_FIELD, - Value::String("openai-codex/gpt-5.5".to_string()), - ), - ], - )); - assert_eq!(call.identity.session_id, "01a091f4-session"); - assert_eq!(call.identity.tool_call_id, "call_1|fc_1"); - assert_eq!(call.identity.tool_name, "edit"); - assert_eq!(call.model.as_deref(), Some("openai-codex/gpt-5.5")); - assert_eq!( - call.identity.classification(), - ToolClassification::TrackedMutation - ); - } - - #[test] - fn tool_call_model_is_optional() { - let call = tool_call(&tool_event_json(HOOK_EVENT_TOOL_CALL, &[])); - assert_eq!(call.model, None); - } - - #[test] - fn tool_result_and_tool_execution_end_parse_minimal_identity() { - for name in [ - HOOK_EVENT_TOOL_RESULT, - HOOK_EVENT_TOOL_EXECUTION_END, - HOOK_EVENT_TOOL_EXECUTION_ABANDON, - ] { - let event = parse_pi_hook_event(&tool_event_json(name, &[])).unwrap(); - let identity = match event { - PiHookEvent::Executed(identity) - | PiHookEvent::ExecutionEnd(identity) - | PiHookEvent::ExecutionAbandon(identity) => identity, - other => panic!("expected a minimal-identity event, got {other:?}"), - }; - assert_eq!( - identity.attempt_key(), - key("01a091f4-session", "call_1|fc_1") - ); - } - } - - #[test] - fn tool_execution_start_parses_and_is_never_evidence() { - let event = - parse_pi_hook_event(&tool_event_json(HOOK_EVENT_TOOL_EXECUTION_START, &[])).unwrap(); - let PiHookEvent::ExecutionStart(identity) = event else { - panic!("expected ToolExecutionStart"); - }; - assert_eq!(identity.tool_call_id, "call_1|fc_1"); - } - - #[test] - fn classification_table() { - let cases: &[(&str, ToolClassification)] = &[ - ("bash", ToolClassification::TrackedMutation), - ("edit", ToolClassification::TrackedMutation), - ("write", ToolClassification::TrackedMutation), - ("read", ToolClassification::Untracked), - ("grep", ToolClassification::Untracked), - ("find", ToolClassification::Untracked), - ("ls", ToolClassification::Untracked), - ("probe_mutate", ToolClassification::Untracked), - ("Bash", ToolClassification::Untracked), - ("some_future_pi_builtin", ToolClassification::Untracked), - ("", ToolClassification::Untracked), - ]; - for (tool_name, expected) in cases { - assert_eq!( - classify_tool(tool_name), - *expected, - "classify_tool({tool_name:?})" - ); - } - } - - #[test] - fn scope_id_embeds_attempt_seq_and_is_length_prefixed() { - let k = key("01a091f4-session", "call_1|fc_1"); - let scope_id = format_pi_scope_id(&k, 1); - assert_eq!( - scope_id, - "pi-tool-v1|n=1|s=16:01a091f4-session|c=11:call_1|fc_1" - ); - assert_ne!(format_pi_scope_id(&k, 1), format_pi_scope_id(&k, 2)); - assert_eq!( - pi_scope_start_event_id(&scope_id), - format!("{scope_id}|start") - ); - assert_eq!( - pi_scope_close_event_id(&scope_id), - format!("{scope_id}|close") - ); - assert_ne!( - pi_scope_start_event_id(&scope_id), - pi_scope_close_event_id(&scope_id) - ); - } - - #[test] - fn length_prefix_disambiguates_delimiter_collisions() { - let a = key("s|c=1:x", "y"); - let b = key("s", "1:x|y"); - assert_ne!(format_pi_scope_id(&a, 1), format_pi_scope_id(&b, 1)); - } - - #[test] - fn provenance_canonicalizes_the_session_and_normalizes_the_model() { - let provenance = pi_scope_provenance("01a091f4-session", Some("openai-codex/gpt-5.5")); - assert_eq!(provenance.session_id, "pi_01a091f4-session"); - assert_eq!(provenance.model_id.as_deref(), Some("openai-codex/gpt-5.5")); - } - - #[test] - fn provenance_keeps_an_already_prefixed_session_id() { - let provenance = pi_scope_provenance("pi_01a091f4-session", None); - assert_eq!(provenance.session_id, "pi_01a091f4-session"); - } - - #[test] - fn provenance_without_model_evidence_is_null() { - for model in [None, Some(""), Some(" ")] { - let provenance = pi_scope_provenance("01a091f4-session", model); - assert_eq!(provenance.model_id, None, "model {model:?}"); - } - } - - #[test] - fn run_from_payload_fails_closed_when_a_tracked_start_cannot_resolve_its_checkout() { - let payload = tool_event_json( - HOOK_EVENT_TOOL_CALL, - &[( - CWD_FIELD, - Value::String("/nonexistent/sce/pi/checkout".to_string()), - )], - ); - let error = run_pi_mutation_scope_from_payload(&payload, None) - .expect_err("a tracked Start that cannot resolve its checkout must fail closed"); - assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE), "{error:?}"); - } - - #[test] - fn run_from_payload_is_neutral_for_untracked_events() { - for tool_name in ["read", "grep", "find", "ls", "probe_mutate"] { - let payload = tool_event_json( - HOOK_EVENT_TOOL_CALL, - &[(TOOL_NAME_FIELD, Value::String(tool_name.to_string()))], - ); - assert_eq!( - run_pi_mutation_scope_from_payload(&payload, None).unwrap(), - String::new() - ); - } - } - - #[test] - fn run_from_payload_surfaces_malformed_input() { - let error = run_pi_mutation_scope_from_payload("{bad", None) - .unwrap_err() - .to_string(); - assert!(error.contains("expected valid JSON"), "{error:?}"); - } - - #[test] - fn tool_execution_start_is_always_a_no_op_regardless_of_classification() { - for tool_name in ["bash", "read"] { - let payload = tool_event_json( - HOOK_EVENT_TOOL_EXECUTION_START, - &[(TOOL_NAME_FIELD, Value::String(tool_name.to_string()))], - ); - assert_eq!( - run_pi_mutation_scope_from_payload(&payload, None).unwrap(), - String::new() - ); - } - } -} +mod tests; #[cfg(test)] -mod lifecycle_tests { - use std::path::PathBuf; - use std::sync::atomic::{AtomicU64, Ordering}; - use std::sync::Mutex; - - use serde_json::Value; - - use super::state::{read_state, AdapterAttempt, AdapterState, AttemptPhase, RecoveryState}; - use super::*; - - static NEXT_ID: AtomicU64 = AtomicU64::new(0); - - fn temp_git_dir(label: &str) -> PathBuf { - let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); - std::env::temp_dir().join(format!( - "sce-pi-mutation-scope-lifecycle-{label}-{}-{id}", - std::process::id() - )) - } - - const CWD: &str = "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/repo/pi-checkout"; - - struct RecordingSeam { - calls: Mutex>, - fail_operations: Vec, - fail_once_operations: Mutex>, - } - - impl RecordingSeam { - fn new() -> Self { - Self { - calls: Mutex::new(Vec::new()), - fail_operations: Vec::new(), - fail_once_operations: Mutex::new(Vec::new()), - } - } - - fn failing_on(operations: &[&str]) -> Self { - Self { - fail_operations: operations.iter().map(|op| (*op).to_string()).collect(), - ..Self::new() - } - } - - fn failing_once_on(operations: &[&str]) -> Self { - Self { - fail_once_operations: Mutex::new( - operations.iter().map(|op| (*op).to_string()).collect(), - ), - ..Self::new() - } - } - - fn handle(&self, payload: &str) -> Result { - let operation = operation_of(payload); - { - let mut calls = self.calls.lock().expect("seam mutex"); - calls.push(operation.clone()); - } - if self.fail_operations.contains(&operation) { - bail!("seam failure injected by test for '{operation}'"); - } - { - let mut once = self.fail_once_operations.lock().expect("seam mutex"); - if let Some(position) = once.iter().position(|candidate| candidate == &operation) { - once.remove(position); - bail!("transient seam failure injected once by test for '{operation}'"); - } - } - Ok(String::new()) - } - - fn operations(&self) -> Vec { - self.calls.lock().expect("seam mutex").clone() - } - } - - fn operation_of(payload: &str) -> String { - let value: Value = serde_json::from_str(payload).expect("seam payload is JSON"); - value - .get("operation") - .and_then(Value::as_str) - .expect("seam payload has an operation") - .to_string() - } - - fn drive(git_dir: &Path, seam: &RecordingSeam, payload: &str) -> Result { - let resolver = |_cwd: &str| Ok(git_dir.to_path_buf()); - let seam_fn = - |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| seam.handle(payload); - run_pi_mutation_scope_from_payload_with_seams(payload, None, &resolver, &seam_fn) - } - - fn tool_call_event(tool_name: &str, tool_call_id: &str) -> String { - json!({ - "hook_event_name": "ToolCall", - "session_id": "ses-main", - "tool_call_id": tool_call_id, - "cwd": CWD, - "tool_name": tool_name, - "model": "openai-codex/gpt-5.5", - }) - .to_string() - } - - fn tool_result_event(tool_name: &str, tool_call_id: &str) -> String { - json!({ - "hook_event_name": "ToolResult", - "session_id": "ses-main", - "tool_call_id": tool_call_id, - "cwd": CWD, - "tool_name": tool_name, - }) - .to_string() - } - - fn tool_execution_end_event(tool_name: &str, tool_call_id: &str) -> String { - json!({ - "hook_event_name": "ToolExecutionEnd", - "session_id": "ses-main", - "tool_call_id": tool_call_id, - "cwd": CWD, - "tool_name": tool_name, - }) - .to_string() - } - - fn tool_execution_abandon_event(tool_name: &str, tool_call_id: &str) -> String { - json!({ - "hook_event_name": "ToolExecutionAbandon", - "session_id": "ses-main", - "tool_call_id": tool_call_id, - "cwd": CWD, - "tool_name": tool_name, - }) - .to_string() - } - - fn tool_execution_abandon_event_for_session( - tool_name: &str, - session_id: &str, - tool_call_id: &str, - ) -> String { - json!({ - "hook_event_name": "ToolExecutionAbandon", - "session_id": session_id, - "tool_call_id": tool_call_id, - "cwd": CWD, - "tool_name": tool_name, - }) - .to_string() - } - - fn cleanup(git_dir: &Path) { - let _ = std::fs::remove_dir_all(git_dir); - } - - fn tool_call_event_for_session( - tool_name: &str, - session_id: &str, - tool_call_id: &str, - ) -> String { - json!({ - "hook_event_name": "ToolCall", - "session_id": session_id, - "tool_call_id": tool_call_id, - "cwd": CWD, - "tool_name": tool_name, - "model": "openai-codex/gpt-5.5", - }) - .to_string() - } - - fn tool_result_event_for_session( - tool_name: &str, - session_id: &str, - tool_call_id: &str, - ) -> String { - json!({ - "hook_event_name": "ToolResult", - "session_id": session_id, - "tool_call_id": tool_call_id, - "cwd": CWD, - "tool_name": tool_name, - }) - .to_string() - } - - fn dead_process_owner() -> super::process_owner::ProcessOwner { - let mut dead_child = std::process::Command::new("true") - .spawn() - .expect("spawning 'true' should succeed"); - let dead_pid = i32::try_from(dead_child.id()).expect("pid fits in i32"); - dead_child.wait().expect("child should exit and be reaped"); - super::process_owner::ProcessOwner { - pid: dead_pid, - instance_token: None, - } - } - - fn attempt_owned_by(state: &AdapterState, session_id: &str) -> AdapterAttempt { - state - .attempts - .iter() - .find(|attempt| attempt.session_id == session_id) - .expect("attempt for session must exist") - .clone() - } - - #[test] - fn tool_call_establishes_a_write_ahead_start_and_replays_idempotently() { - let git_dir = temp_git_dir("write-ahead-start"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_call_event("write", "call_1")).expect("first Start"); - drive(&git_dir, &seam, &tool_call_event("write", "call_1")) - .expect("duplicate Start is idempotent"); - - assert_eq!(seam.operations(), vec!["start", "start"]); - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1); - assert_eq!(state.attempts[0].phase, AttemptPhase::PendingStart); - assert_eq!(state.attempts[0].tool_name, "write"); - - cleanup(&git_dir); - } - - #[test] - fn concurrent_bash_calls_in_one_session_stay_separate_live_scopes() { - let git_dir = temp_git_dir("concurrent-bash"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_call_event("bash", "call_a")).expect("A Start"); - drive(&git_dir, &seam, &tool_call_event("bash", "call_b")) - .expect("B Start must not retire A"); - - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 2); - assert!(state - .attempts - .iter() - .all(|attempt| attempt.phase == AttemptPhase::PendingStart)); - - cleanup(&git_dir); - } - - #[test] - fn full_success_lifecycle_start_result_close() { - let git_dir = temp_git_dir("success-lifecycle"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); - assert_eq!( - read_state(&git_dir).expect("state readable").attempts[0].phase, - AttemptPhase::PendingStart - ); - - drive(&git_dir, &seam, &tool_result_event("bash", "call_1")).expect("tool_result"); - assert_eq!( - read_state(&git_dir).expect("state readable").attempts[0].phase, - AttemptPhase::Executed, - "D5/D6: tool_result is the sole Executed-transition evidence" - ); - - drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")) - .expect("tool_execution_end closes an Executed attempt"); - assert!(read_state(&git_dir) - .expect("state readable") - .attempts - .is_empty()); - assert_eq!(seam.operations(), vec!["start", "close"]); - - cleanup(&git_dir); - } - - #[test] - fn tool_execution_end_without_a_preceding_tool_result_abandons_never_closes() { - let git_dir = temp_git_dir("d7-abandon"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); - - drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")) - .expect("D7: a terminal event with no preceding tool_result must abandon"); - - assert!(read_state(&git_dir) - .expect("state readable") - .attempts - .is_empty()); - assert!( - !seam.operations().contains(&"close".to_string()), - "D7: an unexecuted attempt must never be closed" - ); - assert!(seam.operations().contains(&"abandon".to_string())); - assert!(read_state(&git_dir) - .expect("state readable") - .recovery - .is_clear()); - - cleanup(&git_dir); - } - - #[test] - fn a_failed_close_falls_back_to_abandon_recovery() { - let git_dir = temp_git_dir("close-failure-falls-back"); - let seam = RecordingSeam::failing_on(&["close"]); - - drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); - drive(&git_dir, &seam, &tool_result_event("bash", "call_1")).expect("tool_result"); - - drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")) - .expect("a Close failure must recover via abandon, not surface an error"); - - assert!(read_state(&git_dir) - .expect("state readable") - .attempts - .is_empty()); - assert!(seam.operations().contains(&"abandon".to_string())); - - cleanup(&git_dir); - } - - #[test] - fn execution_abandon_forces_abandon_even_when_the_attempt_is_already_executed() { - let git_dir = temp_git_dir("d9-execution-abandon-executed"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); - drive(&git_dir, &seam, &tool_result_event("bash", "call_1")).expect("tool_result"); - - drive( - &git_dir, - &seam, - &tool_execution_abandon_event("bash", "call_1"), - ) - .expect("ExecutionAbandon must recover via abandon, not surface an error"); - - assert!(read_state(&git_dir) - .expect("state readable") - .attempts - .is_empty()); - assert!( - !seam.operations().contains(&"close".to_string()), - "D9: an explicit abandon request must never be treated as a Close, \ - even for an attempt already marked Executed" - ); - assert!(seam.operations().contains(&"abandon".to_string())); - - cleanup(&git_dir); - } - - #[test] - fn execution_abandon_on_a_pending_start_attempt_abandons() { - let git_dir = temp_git_dir("d9-execution-abandon-pending-start"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); - - drive( - &git_dir, - &seam, - &tool_execution_abandon_event("bash", "call_1"), - ) - .expect("ExecutionAbandon must recover a PendingStart attempt via abandon"); - - assert!(read_state(&git_dir) - .expect("state readable") - .attempts - .is_empty()); - assert!(seam.operations().contains(&"abandon".to_string())); - - cleanup(&git_dir); - } - - #[test] - fn execution_abandon_for_an_unknown_attempt_is_a_safe_no_op() { - let git_dir = temp_git_dir("d9-execution-abandon-unknown"); - let seam = RecordingSeam::new(); - - let result = drive( - &git_dir, - &seam, - &tool_execution_abandon_event("bash", "call_1"), - ) - .expect("an unknown attempt must be a safe no-op, never an error"); - - assert_eq!(result, ""); - assert!(seam.operations().is_empty()); - - cleanup(&git_dir); - } - - #[test] - fn execution_abandon_for_an_untracked_tool_is_a_no_op() { - let git_dir = temp_git_dir("d9-execution-abandon-untracked"); - let seam = RecordingSeam::new(); - - let result = drive( - &git_dir, - &seam, - &tool_execution_abandon_event("read", "call_1"), - ) - .expect("untracked tools are never adapter-relevant"); - - assert_eq!(result, ""); - assert!(seam.operations().is_empty()); - - cleanup(&git_dir); - } - - #[test] - fn duplicate_execution_abandon_on_an_already_abandoned_attempt_is_a_safe_no_op() { - let git_dir = temp_git_dir("d9-execution-abandon-duplicate"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); - - drive( - &git_dir, - &seam, - &tool_execution_abandon_event("bash", "call_1"), - ) - .expect("first ExecutionAbandon retires the attempt"); - assert!(read_state(&git_dir) - .expect("state readable") - .attempts - .is_empty()); - - let result = drive( - &git_dir, - &seam, - &tool_execution_abandon_event("bash", "call_1"), - ) - .expect("a duplicate ExecutionAbandon for an already-retired attempt must be a safe no-op"); - - assert_eq!(result, ""); - assert_eq!( - seam.operations(), - vec!["start", "flush", "abandon", "flush"], - "a duplicate ExecutionAbandon must never issue a second abandon or a close" - ); - - cleanup(&git_dir); - } - - #[test] - fn execution_abandon_for_one_session_never_touches_another_sessions_attempt_with_the_same_tool_call_id( - ) { - let git_dir = temp_git_dir("d9-execution-abandon-cross-session"); - let seam = RecordingSeam::new(); - - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "ses-a", "call_1"), - ) - .expect("session A Start"); - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "ses-b", "call_1"), - ) - .expect("session B Start with the same tool_call_id"); - - assert_eq!( - read_state(&git_dir).expect("state readable").attempts.len(), - 2 - ); - - drive( - &git_dir, - &seam, - &tool_execution_abandon_event_for_session("bash", "ses-a", "call_1"), - ) - .expect("ExecutionAbandon for session A must not error"); - - let remaining = read_state(&git_dir).expect("state readable").attempts; - assert_eq!( - remaining.len(), - 1, - "abandoning session A's attempt must leave session B's untouched" - ); - assert_eq!(remaining[0].session_id, "ses-b"); - assert_eq!(remaining[0].tool_call_id, "call_1"); - assert_eq!(remaining[0].phase, AttemptPhase::PendingStart); - - drive( - &git_dir, - &seam, - &tool_result_event_for_session("bash", "ses-b", "call_1"), - ) - .expect("session B must still be able to progress normally after A's abandon"); - assert_eq!( - read_state(&git_dir).expect("state readable").attempts[0].phase, - AttemptPhase::Executed - ); - - cleanup(&git_dir); - } - - #[test] - fn a_terminal_recovery_flush_failure_leaves_a_pending_recovery_and_denies_new_admission() { - let git_dir = temp_git_dir("recovery-flush-failure"); - let persistently_failing = RecordingSeam::failing_on(&["flush"]); - - drive( - &git_dir, - &persistently_failing, - &tool_call_event("bash", "call_1"), - ) - .expect("Start"); - drive( - &git_dir, - &persistently_failing, - &tool_execution_end_event("bash", "call_1"), - ) - .expect("abandon path swallows the flush failure rather than surfacing an error"); - - assert_eq!( - read_state(&git_dir).expect("state readable").recovery, - RecoveryState::Pending { generation: 1 }, - "a failed ambiguity flush must leave recovery Pending, not Clear" - ); - - let error = drive( - &git_dir, - &persistently_failing, - &tool_call_event("bash", "call_2"), - ) - .expect_err("a new admission must fail closed while recovery remains unresolved"); - assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE)); - - let recovered = RecordingSeam::new(); - drive(&git_dir, &recovered, &tool_call_event("bash", "call_3")) - .expect("a new admission must self-heal once recovery can complete"); - assert!(read_state(&git_dir) - .expect("state readable") - .recovery - .is_clear()); - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1); - assert_eq!(state.attempts[0].tool_call_id, "call_3"); - - cleanup(&git_dir); - } - - #[test] - fn start_provenance_carries_the_prefixed_session_and_normalized_model_to_the_seam() { - let git_dir = temp_git_dir("provenance-present"); - let captured: Mutex> = Mutex::new(Vec::new()); - let resolver = |_cwd: &str| Ok(git_dir.clone()); - let seam_fn = |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| { - captured - .lock() - .expect("capture mutex") - .push(payload.to_string()); - Ok(String::new()) - }; - - run_pi_mutation_scope_from_payload_with_seams( - &tool_call_event("bash", "call_model"), - None, - &resolver, - &seam_fn, - ) - .expect("Start should succeed"); - - let payloads = captured.into_inner().expect("capture mutex"); - assert_eq!(payloads.len(), 1); - let sent: Value = serde_json::from_str(&payloads[0]).expect("seam payload is JSON"); - assert_eq!( - sent["provenance"]["session_id"].as_str(), - Some("pi_ses-main") - ); - assert_eq!( - sent["provenance"]["model_id"].as_str(), - Some("openai-codex/gpt-5.5") - ); - - cleanup(&git_dir); - } - - #[test] - fn start_provenance_is_null_model_when_the_event_carries_no_model() { - let git_dir = temp_git_dir("provenance-absent"); - let captured: Mutex> = Mutex::new(Vec::new()); - let resolver = |_cwd: &str| Ok(git_dir.clone()); - let seam_fn = |_root: &Path, payload: &str, _logger: Option<&dyn Logger>| { - captured - .lock() - .expect("capture mutex") - .push(payload.to_string()); - Ok(String::new()) - }; - - let payload = json!({ - "hook_event_name": "ToolCall", - "session_id": "ses-main", - "tool_call_id": "call_no_model", - "cwd": CWD, - "tool_name": "bash", - }) - .to_string(); - - run_pi_mutation_scope_from_payload_with_seams(&payload, None, &resolver, &seam_fn) - .expect("Start should succeed"); - - let payloads = captured.into_inner().expect("capture mutex"); - let sent: Value = serde_json::from_str(&payloads[0]).expect("seam payload is JSON"); - assert!(sent["provenance"]["model_id"].is_null()); - - cleanup(&git_dir); - } - - #[test] - fn untracked_tool_call_never_admits_an_attempt() { - let git_dir = temp_git_dir("untracked-no-admit"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_call_event("read", "call_ro")).expect("untracked is inert"); - assert!(seam.operations().is_empty()); - assert!(read_state(&git_dir) - .expect("state readable") - .attempts - .is_empty()); - - drive(&git_dir, &seam, &tool_result_event("read", "call_ro")) - .expect("untracked result inert"); - drive( - &git_dir, - &seam, - &tool_execution_end_event("read", "call_ro"), - ) - .expect("untracked terminal inert"); - assert!(seam.operations().is_empty()); - - cleanup(&git_dir); - } - - #[test] - fn a_pending_start_attempt_owned_by_a_dead_process_is_abandoned_not_replayed() { - let git_dir = temp_git_dir("d10-dead-owner-abandon"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("first Start"); - let scope_id = read_state(&git_dir).expect("state readable").attempts[0] - .scope_id - .clone(); - - let mut dead_child = std::process::Command::new("true") - .spawn() - .expect("spawning 'true' should succeed"); - let dead_pid = i32::try_from(dead_child.id()).expect("pid fits in i32"); - dead_child.wait().expect("child should exit and be reaped"); - state::set_attempt_owner_for_tests( - &git_dir, - &scope_id, - super::process_owner::ProcessOwner { - pid: dead_pid, - instance_token: None, - }, - ); - - drive(&git_dir, &seam, &tool_call_event("bash", "call_1")) - .expect("a replay whose recorded owner is positively dead must abandon, not reuse"); - - assert_eq!( - seam.operations(), - vec!["start", "flush", "abandon", "flush", "start"], - "D10: a dead-owner PendingStart must be abandoned via the existing D8 flush/abandon/\ - flush pattern, then the triggering event admitted as a fresh attempt" - ); - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1); - assert_ne!( - state.attempts[0].scope_id, scope_id, - "the fresh attempt must never reuse the abandoned attempt's ScopeId" - ); - assert_eq!(state.attempts[0].attempt_seq, 2); - assert!(state.recovery.is_clear()); - - cleanup(&git_dir); - } - - #[test] - fn a_pending_start_attempt_owned_by_a_live_process_is_never_abandoned_by_a_replay() { - let git_dir = temp_git_dir("d10-live-owner-no-abandon"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("first Start"); - drive(&git_dir, &seam, &tool_call_event("bash", "call_1")) - .expect("a replay owned by a still-live process must be treated as a normal replay"); - - assert_eq!( - seam.operations(), - vec!["start", "start"], - "no TTL and no elapsed time may ever cause an abandon here: the owner is this test \ - process's own live parent for the whole test" - ); - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1); - assert_eq!(state.attempts[0].phase, AttemptPhase::PendingStart); - - cleanup(&git_dir); - } - - #[test] - fn a_dead_pending_start_attempt_is_recovered_by_an_unrelated_fresh_session_start() { - let git_dir = temp_git_dir("d10-fresh-session-dead-pending-start"); - let seam = RecordingSeam::new(); - - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "sess-a", "call-a"), - ) - .expect("A's Start"); - let scope_a = - attempt_owned_by(&read_state(&git_dir).expect("state readable"), "sess-a").scope_id; - state::set_attempt_owner_for_tests(&git_dir, &scope_a, dead_process_owner()); - - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "sess-b", "call-b"), - ) - .expect( - "B's Start must recover A's stale owner without ever replaying A's \ - (session_id, tool_call_id) key", - ); - - assert_eq!( - seam.operations(), - vec!["start", "flush", "abandon", "flush", "start"], - "D10: a dead PendingStart owner discovered by an unrelated fresh-session Start must \ - be retired through the existing D8 flush/abandon/flush sequence before the \ - triggering Start is admitted" - ); - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1); - assert_eq!(state.attempts[0].session_id, "sess-b"); - assert_ne!(state.attempts[0].scope_id, scope_a); - assert!(state.recovery.is_clear()); - - cleanup(&git_dir); - } - - #[test] - fn a_dead_executed_attempt_is_recovered_by_a_fresh_session_start_without_a_synthetic_close() { - let git_dir = temp_git_dir("d10-fresh-session-dead-executed"); - let seam = RecordingSeam::new(); - - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "sess-a", "call-a"), - ) - .expect("A's Start"); - drive( - &git_dir, - &seam, - &tool_result_event_for_session("bash", "sess-a", "call-a"), - ) - .expect("A's tool_result marks Executed"); - let state = read_state(&git_dir).expect("state readable"); - let scope_a = attempt_owned_by(&state, "sess-a").scope_id; - assert_eq!( - attempt_owned_by(&state, "sess-a").phase, - AttemptPhase::Executed - ); - state::set_attempt_owner_for_tests(&git_dir, &scope_a, dead_process_owner()); - - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "sess-b", "call-b"), - ) - .expect("B's Start must recover A's dead Executed attempt"); - - assert_eq!( - seam.operations(), - vec!["start", "flush", "abandon", "flush", "start"], - "a dead Executed attempt must be abandoned/rebaselined via D8, never given a \ - synthetic delayed Close" - ); - assert!( - !seam.operations().contains(&"close".to_string()), - "D9: the current Git tree no longer represents the original terminal observation \ - time, so a dead Executed attempt must never be closed" - ); - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1); - assert_eq!(state.attempts[0].session_id, "sess-b"); - assert!(state.recovery.is_clear()); - - cleanup(&git_dir); - } - - #[test] - fn a_dead_owner_scope_is_recovered_while_a_live_owner_sibling_survives_untouched() { - let git_dir = temp_git_dir("d10-dead-live-sibling-isolation"); - let seam = RecordingSeam::new(); - - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "sess-a", "call-a"), - ) - .expect("A's Start (owner will die)"); - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "sess-b", "call-b"), - ) - .expect("B's Start (owner stays live)"); - - let state = read_state(&git_dir).expect("state readable"); - let scope_a = attempt_owned_by(&state, "sess-a").scope_id; - let scope_b = attempt_owned_by(&state, "sess-b").scope_id; - state::set_attempt_owner_for_tests(&git_dir, &scope_a, dead_process_owner()); - - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "sess-c", "call-c"), - ) - .expect("C's Start must recover only A"); - - assert_eq!( - seam.operations(), - vec!["start", "start", "flush", "abandon", "flush", "start"], - "exactly one abandon must occur, and only for A's own positively dead owner" - ); - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 2); - assert!(!state.attempts.iter().any(|a| a.scope_id == scope_a)); - let b = attempt_owned_by(&state, "sess-b"); - assert_eq!(b.scope_id, scope_b); - assert_eq!( - b.phase, - AttemptPhase::PendingStart, - "B must survive reconciliation exactly as it was, untouched" - ); - assert_eq!( - attempt_owned_by(&state, "sess-c").phase, - AttemptPhase::PendingStart - ); - - cleanup(&git_dir); - } - - #[test] - fn multiple_dead_owner_scopes_are_retired_in_one_recovery_generation_while_a_live_sibling_survives( - ) { - let git_dir = temp_git_dir("d10-multiple-dead-owner-scopes"); - let seam = RecordingSeam::new(); - - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "sess-a", "call-a"), - ) - .expect("A's Start"); - drive( - &git_dir, - &seam, - &tool_result_event_for_session("bash", "sess-a", "call-a"), - ) - .expect("A's tool_result marks Executed"); - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "sess-b", "call-b"), - ) - .expect("B's Start"); - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "sess-q", "call-q"), - ) - .expect("Q's Start (owner stays live)"); - - let state = read_state(&git_dir).expect("state readable"); - let scope_a = attempt_owned_by(&state, "sess-a").scope_id; - let scope_b = attempt_owned_by(&state, "sess-b").scope_id; - let scope_q = attempt_owned_by(&state, "sess-q").scope_id; - let dead_owner = dead_process_owner(); - state::set_attempt_owner_for_tests(&git_dir, &scope_a, dead_owner); - state::set_attempt_owner_for_tests(&git_dir, &scope_b, dead_owner); - - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "sess-c", "call-c"), - ) - .expect("C's Start must recover both A and B, grouped into one recovery generation"); - - assert_eq!( - seam.operations(), - vec!["start", "start", "start", "flush", "abandon", "abandon", "flush", "start"], - "a single flush/abandon.../flush recovery generation must retire every \ - independently-proven-dead scope owned by the same dead process together" - ); - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 2); - assert!(!state.attempts.iter().any(|a| a.scope_id == scope_a)); - assert!(!state.attempts.iter().any(|a| a.scope_id == scope_b)); - assert_eq!(attempt_owned_by(&state, "sess-q").scope_id, scope_q); - assert!(state.recovery.is_clear()); - - cleanup(&git_dir); - } - - #[test] - fn an_owner_that_cannot_be_positively_proven_dead_is_never_abandoned_by_an_unrelated_start() { - let git_dir = temp_git_dir("d10-uncertain-owner-preserved"); - let seam = RecordingSeam::new(); - - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "sess-a", "call-a"), - ) - .expect("A's Start"); - let scope_a = - attempt_owned_by(&read_state(&git_dir).expect("state readable"), "sess-a").scope_id; - state::set_attempt_owner_for_tests( - &git_dir, - &scope_a, - super::process_owner::ProcessOwner { - pid: std::process::id().cast_signed(), - instance_token: None, - }, - ); - - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "sess-c", "call-c"), - ) - .expect( - "C's Start must proceed without touching A, whose owner cannot be positively \ - proven dead", - ); - - assert_eq!( - seam.operations(), - vec!["start", "start"], - "a live pid with no instance-token evidence must never be converted into proof of \ - death: uncertain identity is conservatively treated as alive" - ); - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 2); - assert!(state - .attempts - .iter() - .all(|attempt| attempt.phase == AttemptPhase::PendingStart)); - assert!(state.attempts.iter().any(|a| a.scope_id == scope_a)); - - cleanup(&git_dir); - } - - #[test] - fn an_interrupted_stale_owner_recovery_remains_pending_and_denies_the_triggering_start_until_resumed( - ) { - let git_dir = temp_git_dir("d10-interrupted-stale-recovery"); - let seam = RecordingSeam::new(); - - drive( - &git_dir, - &seam, - &tool_call_event_for_session("bash", "sess-a", "call-a"), - ) - .expect("A's Start"); - let scope_a = - attempt_owned_by(&read_state(&git_dir).expect("state readable"), "sess-a").scope_id; - state::set_attempt_owner_for_tests(&git_dir, &scope_a, dead_process_owner()); - - let crashing = RecordingSeam::failing_once_on(&["abandon"]); - let error = drive( - &git_dir, - &crashing, - &tool_call_event_for_session("bash", "sess-b", "call-b"), - ) - .expect_err( - "a Start that triggers a stale-owner recovery which fails mid-way must not commit", - ); - assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE)); - - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.recovery, RecoveryState::Pending { generation: 1 }); - assert!( - !state.attempts.iter().any(|a| a.session_id == "sess-b"), - "B must never be admitted while A's stale-owner recovery is still pending" - ); - assert_eq!( - attempt_owned_by(&state, "sess-a").phase, - AttemptPhase::PendingAbandon - ); - - drive( - &git_dir, - &crashing, - &tool_call_event_for_session("bash", "sess-b", "call-b"), - ) - .expect( - "the next boundary-lock acquisition must resume and complete the pending recovery, \ - and only then admit B", - ); - - let state = read_state(&git_dir).expect("state readable"); - assert!(state.recovery.is_clear()); - assert_eq!(state.attempts.len(), 1); - assert_eq!(state.attempts[0].session_id, "sess-b"); - assert!(!state.attempts.iter().any(|a| a.scope_id == scope_a)); - - cleanup(&git_dir); - } - - #[test] - fn duplicate_tool_result_after_close_is_a_safe_no_op() { - let git_dir = temp_git_dir("duplicate-tool-result-after-close"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); - drive(&git_dir, &seam, &tool_result_event("bash", "call_1")).expect("tool_result"); - drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")).expect("Close"); - - drive(&git_dir, &seam, &tool_result_event("bash", "call_1")) - .expect("a late duplicate tool_result after Close must be a safe no-op"); - drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")) - .expect("a late duplicate tool_execution_end after Close must be a safe no-op"); - - assert_eq!( - seam.operations(), - vec!["start", "close"], - "a resurrected attempt must never re-enter the runtime seam after its own Close" - ); - assert!(read_state(&git_dir) - .expect("state readable") - .attempts - .is_empty()); - - cleanup(&git_dir); - } - - #[test] - fn duplicate_tool_execution_end_after_abandon_is_a_safe_no_op() { - let git_dir = temp_git_dir("duplicate-terminal-after-abandon"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("Start"); - drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")).expect("D7 abandon"); - - drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")) - .expect("a late duplicate terminal event after abandon must be a safe no-op"); - - assert_eq!( - seam.operations(), - vec!["start", "flush", "abandon", "flush"], - "a duplicate terminal delivery for an already-abandoned attempt must never issue a \ - second abandon" - ); - - cleanup(&git_dir); - } - - #[test] - fn abandoning_one_sibling_never_touches_a_concurrent_sibling_in_the_same_session() { - let git_dir = temp_git_dir("sibling-abandon-isolation"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_call_event("bash", "call_a")).expect("A Start"); - drive(&git_dir, &seam, &tool_call_event("bash", "call_b")).expect("B Start"); - drive(&git_dir, &seam, &tool_result_event("bash", "call_b")).expect("B tool_result"); - - drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_a")) - .expect("A's terminal event with no tool_result must abandon only A"); - - let state = read_state(&git_dir).expect("state readable"); - assert_eq!( - state.attempts.len(), - 1, - "abandoning A must never remove or block sibling B" - ); - assert_eq!(state.attempts[0].tool_call_id, "call_b"); - assert_eq!(state.attempts[0].phase, AttemptPhase::Executed); - - drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_b")) - .expect("B must still close normally after A's abandonment and recovery"); - assert!(read_state(&git_dir) - .expect("state readable") - .attempts - .is_empty()); - assert_eq!( - seam.operations(), - vec!["start", "start", "flush", "abandon", "flush", "close"] - ); - - cleanup(&git_dir); - } - - #[test] - fn a_crash_mid_abandon_loop_is_resumed_and_completed_on_the_next_boundary_lock_acquisition() { - let git_dir = temp_git_dir("crash-mid-abandon-loop"); - let crashing = RecordingSeam::failing_once_on(&["abandon"]); - - drive(&git_dir, &crashing, &tool_call_event("bash", "call_1")).expect("Start"); - drive( - &git_dir, - &crashing, - &tool_execution_end_event("bash", "call_1"), - ) - .expect( - "a transient abandon failure mid-recovery must leave recovery Pending, not surface \ - an error, simulating a crash between marking PendingAbandon and completing the \ - flush/abandon/flush sequence", - ); - - assert_eq!( - read_state(&git_dir).expect("state readable").recovery, - RecoveryState::Pending { generation: 1 }, - "the interrupted abandon loop must leave recovery durably Pending for the next \ - boundary-lock acquisition to resume, never Clear and never lost" - ); - assert_eq!( - read_state(&git_dir) - .expect("state readable") - .attempts - .first() - .expect("the doomed attempt must still be recorded") - .phase, - AttemptPhase::PendingAbandon - ); - - drive(&git_dir, &crashing, &tool_call_event("bash", "call_2")) - .expect("recovery must self-heal and complete on the very next invocation"); - - assert!(read_state(&git_dir) - .expect("state readable") - .recovery - .is_clear()); - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1); - assert_eq!(state.attempts[0].tool_call_id, "call_2"); - - cleanup(&git_dir); - } - - #[test] - fn a_reused_tool_call_id_after_terminal_cleanup_gets_a_distinct_scope_id() { - let git_dir = temp_git_dir("terminal-scope-id-non-reuse"); - let seam = RecordingSeam::new(); - - drive(&git_dir, &seam, &tool_call_event("bash", "call_1")).expect("first Start"); - drive(&git_dir, &seam, &tool_result_event("bash", "call_1")).expect("first tool_result"); - drive(&git_dir, &seam, &tool_execution_end_event("bash", "call_1")).expect("first Close"); - assert!(read_state(&git_dir) - .expect("state readable") - .attempts - .is_empty()); - - drive(&git_dir, &seam, &tool_call_event("bash", "call_1")) - .expect("reused toolCallId Start"); - let state = read_state(&git_dir).expect("state readable"); - assert_eq!(state.attempts.len(), 1); - assert_eq!(state.attempts[0].attempt_seq, 2); - - cleanup(&git_dir); - } -} +mod lifecycle_tests; #[cfg(test)] -mod runtime_seam_tests { - use std::fs; - use std::path::PathBuf; - use std::process::Command; - - use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; - use crate::services::agent_trace_storage::{ - resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, - }; - - use super::*; - - fn git(dir: &Path, args: &[&str]) { - let output = Command::new("git") - .args(args) - .current_dir(dir) - .output() - .expect("git should spawn"); - assert!( - output.status.success(), - "git {args:?} failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - - struct PiRepo { - _temp: tempfile::TempDir, - root: PathBuf, - state_root: PathBuf, - } - - impl PiRepo { - fn new(label: &str) -> Self { - let temp = tempfile::Builder::new() - .prefix(&format!("sce-pi-mutation-scope-seam-{label}-")) - .tempdir() - .expect("temp dir should be created"); - let root = temp.path().join("repo"); - fs::create_dir_all(&root).expect("repo dir should be created"); - git(&root, &["init", "-q"]); - git(&root, &["config", "user.email", "test@example.invalid"]); - git(&root, &["config", "user.name", "SCE Test"]); - git( - &root, - &["remote", "add", "origin", "git@github.com:acme/widgets.git"], - ); - fs::write(root.join("file.txt"), "one\n").expect("seed file should write"); - git(&root, &["add", "-A"]); - git(&root, &["commit", "-qm", "base"]); - - let state_root = temp.path().join("state"); - fs::create_dir_all(&state_root).expect("state root should be created"); - resolve_agent_trace_storage_at_state_root( - &AgentTraceStorageContext { - repository_root: &root, - explicit_repository_id: None, - repository_remote: "origin", - }, - &state_root, - ) - .expect("state-root storage should initialize the repository DB"); - - Self { - _temp: temp, - root, - state_root, - } - } - - fn cwd(&self) -> String { - self.root.to_string_lossy().into_owned() - } - - fn drive(&self, payload: &str) -> Result { - run_pi_mutation_scope_from_payload_at_state_root(&self.state_root, payload, None) - } - - fn write(&self, name: &str, contents: &str) { - fs::write(self.root.join(name), contents).expect("write should succeed"); - } - - fn db(&self) -> RepositoryAgentTraceDb { - crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( - &self.root, - &self.state_root, - "Pi mutation-scope seam test assertions", - ) - .expect("assertion DB should open") - } - - fn scope_status(&self, scope_id: &str) -> Option<(String, String)> { - self.db() - .query_map( - "SELECT actor_kind, status FROM mutation_trace_scopes WHERE scope_id = ?1", - (scope_id,), - |row| { - let actor_kind = row.get::(0).map_err(anyhow::Error::from)?; - let status = row.get::(1).map_err(anyhow::Error::from)?; - Ok((actor_kind, status)) - }, - ) - .expect("scope query should succeed") - .into_iter() - .next() - } - - fn scope_provenance(&self, scope_id: &str) -> Option<(String, Option)> { - self.db() - .query_map( - "SELECT session_id, model_id FROM mutation_trace_scope_provenance \ - WHERE scope_id = ?1", - (scope_id,), - |row| { - let session_id = row.get::(0).map_err(anyhow::Error::from)?; - let model_id = row.get::>(1).map_err(anyhow::Error::from)?; - Ok((session_id, model_id)) - }, - ) - .expect("scope-provenance query should succeed") - .into_iter() - .next() - } - - fn mutation_events(&self) -> Vec<(String, Option)> { - self.db() - .query_map( - "SELECT attribution_kind, attribution_scope_id \ - FROM mutation_trace_events ORDER BY revision", - (), - |row| { - let attribution_kind = row.get::(0).map_err(anyhow::Error::from)?; - let attribution_scope_id = - row.get::>(1).map_err(anyhow::Error::from)?; - Ok((attribution_kind, attribution_scope_id)) - }, - ) - .expect("mutation-events query should succeed") - } - } - - fn tool_call(repo: &PiRepo, tool_name: &str, tool_call_id: &str) -> String { - json!({ - "hook_event_name": "ToolCall", - "session_id": "01a091f4-seam-session", - "tool_call_id": tool_call_id, - "cwd": repo.cwd(), - "tool_name": tool_name, - "model": "openai-codex/gpt-5.5", - }) - .to_string() - } - - fn tool_result(repo: &PiRepo, tool_name: &str, tool_call_id: &str) -> String { - json!({ - "hook_event_name": "ToolResult", - "session_id": "01a091f4-seam-session", - "tool_call_id": tool_call_id, - "cwd": repo.cwd(), - "tool_name": tool_name, - }) - .to_string() - } - - fn tool_execution_end(repo: &PiRepo, tool_name: &str, tool_call_id: &str) -> String { - json!({ - "hook_event_name": "ToolExecutionEnd", - "session_id": "01a091f4-seam-session", - "tool_call_id": tool_call_id, - "cwd": repo.cwd(), - "tool_name": tool_name, - }) - .to_string() - } - - #[test] - fn a_write_start_result_close_lands_a_real_ai_exclusive_event_with_pi_provenance() { - let repo = PiRepo::new("real-lifecycle"); - let key = AttemptKey { - session_id: "01a091f4-seam-session".to_string(), - tool_call_id: "call_write".to_string(), - }; - let scope_id = format_pi_scope_id(&key, 1); - - repo.drive(&tool_call(&repo, "write", "call_write")) - .expect("Start should reach the real runtime"); - assert_eq!( - repo.scope_status(&scope_id), - Some(("pi".to_string(), "active".to_string())) - ); - assert_eq!( - repo.scope_provenance(&scope_id), - Some(( - "pi_01a091f4-seam-session".to_string(), - Some("openai-codex/gpt-5.5".to_string()) - )) - ); - - repo.write("file.txt", "one\ntwo\n"); - repo.drive(&tool_result(&repo, "write", "call_write")) - .expect("tool_result should mark Executed"); - - repo.drive(&tool_execution_end(&repo, "write", "call_write")) - .expect("Close should reach the real runtime"); - - assert_eq!( - repo.scope_status(&scope_id), - Some(("pi".to_string(), "closed".to_string())) - ); - assert_eq!( - repo.mutation_events(), - vec![("ai_exclusive".to_string(), Some(scope_id))] - ); - assert!( - state::read_state(&resolve_git_dir(&repo.root).expect("git dir resolves")) - .expect("state readable") - .attempts - .is_empty() - ); - } - - #[test] - fn a_start_followed_by_no_execution_abandons_through_the_real_runtime() { - let repo = PiRepo::new("real-abandon"); - let key = AttemptKey { - session_id: "01a091f4-seam-session".to_string(), - tool_call_id: "call_blocked".to_string(), - }; - let scope_id = format_pi_scope_id(&key, 1); - - repo.drive(&tool_call(&repo, "bash", "call_blocked")) - .expect("Start should reach the real runtime"); - - repo.drive(&tool_execution_end(&repo, "bash", "call_blocked")) - .expect("the terminal event must resolve via abandon, not surface an error"); - - assert_eq!( - repo.scope_status(&scope_id), - Some(("pi".to_string(), "abandoned".to_string())) - ); - assert!(repo.mutation_events().is_empty()); - } -} +mod runtime_seam_tests; #[cfg(all(unix, test))] -mod guard_reconciliation_tests { - use std::fs; - use std::path::PathBuf; - use std::process::Command; - use std::sync::mpsc; - use std::time::Duration; - - use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; - use crate::services::agent_trace_storage::{ - resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, - }; - use crate::services::mutation_trace::runtime::{ - coordinate, run_external_mutation_guard, GuardRequest, RuntimeBoundary, - }; - use crate::services::mutation_trace::types::{ActorKind, EventId, ScopeId}; - - use super::*; - - fn git(dir: &Path, args: &[&str]) { - let output = Command::new("git") - .args(args) - .current_dir(dir) - .output() - .expect("git should spawn"); - assert!( - output.status.success(), - "git {args:?} failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - - struct GuardRepo { - _temp: tempfile::TempDir, - root: PathBuf, - state_root: PathBuf, - } - - impl GuardRepo { - fn new(label: &str) -> Self { - let temp = tempfile::Builder::new() - .prefix(&format!("sce-pi-guard-reconciliation-{label}-")) - .tempdir() - .expect("temp dir should be created"); - let root = temp.path().join("repo"); - fs::create_dir_all(&root).expect("repo dir should be created"); - git(&root, &["init", "-q"]); - git(&root, &["config", "user.email", "test@example.invalid"]); - git(&root, &["config", "user.name", "SCE Test"]); - git( - &root, - &["remote", "add", "origin", "git@github.com:acme/widgets.git"], - ); - fs::write(root.join("file.txt"), "one\n").expect("seed file should write"); - git(&root, &["add", "-A"]); - git(&root, &["commit", "-qm", "base"]); - - let state_root = temp.path().join("state"); - fs::create_dir_all(&state_root).expect("state root should be created"); - resolve_agent_trace_storage_at_state_root( - &AgentTraceStorageContext { - repository_root: &root, - explicit_repository_id: None, - repository_remote: "origin", - }, - &state_root, - ) - .expect("state-root storage should initialize the repository DB"); - - Self { - _temp: temp, - root, - state_root, - } - } - - fn drive(&self, payload: &str) -> Result { - run_pi_mutation_scope_from_payload_at_state_root(&self.state_root, payload, None) - } - - fn open_db(&self) -> anyhow::Result { - crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( - &self.root, - &self.state_root, - "Pi guard-reconciliation test assertions", - ) - } - - fn db(&self) -> RepositoryAgentTraceDb { - self.open_db().expect("assertion DB should open") - } - - fn scope_status(&self, scope_id: &str) -> Option<(String, String)> { - self.db() - .query_map( - "SELECT actor_kind, status FROM mutation_trace_scopes WHERE scope_id = ?1", - (scope_id,), - |row| { - let actor_kind = row.get::(0).map_err(anyhow::Error::from)?; - let status = row.get::(1).map_err(anyhow::Error::from)?; - Ok((actor_kind, status)) - }, - ) - .expect("scope query should succeed") - .into_iter() - .next() - } - - fn write(&self, name: &str, contents: &str) { - fs::write(self.root.join(name), contents).expect("write should succeed"); - } - - fn mutation_events(&self) -> Vec<(String, Option)> { - self.db() - .query_map( - "SELECT attribution_kind, attribution_scope_id \ - FROM mutation_trace_events ORDER BY revision", - (), - |row| { - let attribution_kind = row.get::(0).map_err(anyhow::Error::from)?; - let attribution_scope_id = - row.get::>(1).map_err(anyhow::Error::from)?; - Ok((attribution_kind, attribution_scope_id)) - }, - ) - .expect("mutation-events query should succeed") - } - } - - fn tool_call(repo: &GuardRepo, tool_call_id: &str, session_id: &str) -> String { - json!({ - "hook_event_name": "ToolCall", - "session_id": session_id, - "tool_call_id": tool_call_id, - "cwd": repo.root.to_string_lossy(), - "tool_name": "bash", - "model": "openai-codex/gpt-5.5", - }) - .to_string() - } - - fn tool_execution_end(repo: &GuardRepo, tool_call_id: &str, session_id: &str) -> String { - json!({ - "hook_event_name": "ToolExecutionEnd", - "session_id": session_id, - "tool_call_id": tool_call_id, - "cwd": repo.root.to_string_lossy(), - "tool_name": "bash", - }) - .to_string() - } - - fn tool_result(repo: &GuardRepo, tool_call_id: &str, session_id: &str) -> String { - json!({ - "hook_event_name": "ToolResult", - "session_id": session_id, - "tool_call_id": tool_call_id, - "cwd": repo.root.to_string_lossy(), - "tool_name": "bash", - }) - .to_string() - } - - #[test] - fn a_guard_triggered_worktree_abandonment_reconciles_with_the_pi_adapters_own_state() { - let repo = GuardRepo::new("reconcile"); - let session = "01a091f4-guard-session"; - let key_a = AttemptKey { - session_id: session.to_string(), - tool_call_id: "call_a".to_string(), - }; - let key_b = AttemptKey { - session_id: session.to_string(), - tool_call_id: "call_b".to_string(), - }; - let scope_a = format_pi_scope_id(&key_a, 1); - let scope_b = format_pi_scope_id(&key_b, 2); - - repo.drive(&tool_call(&repo, "call_a", session)) - .expect("A's Start should reach the real runtime"); - repo.drive(&tool_call(&repo, "call_b", session)) - .expect("B's Start should reach the real runtime"); - assert_eq!( - repo.scope_status(&scope_a), - Some(("pi".to_string(), "active".to_string())) - ); - assert_eq!( - repo.scope_status(&scope_b), - Some(("pi".to_string(), "active".to_string())) - ); - - let (_cancel_tx, cancel_rx) = mpsc::channel(); - let root = repo.root.clone(); - let outcome = run_external_mutation_guard( - &root, - &GuardRequest { - command: "printf changed >> file.txt".to_string(), - cwd: None, - env: Vec::new(), - }, - || repo.open_db(), - |_event| {}, - cancel_rx, - ) - .expect("the guard should finish successfully"); - assert_eq!(outcome.exit_code, Some(0)); - assert!(!outcome.marker_clear_failed); - - assert_eq!( - repo.scope_status(&scope_a), - Some(("pi".to_string(), "abandoned".to_string())), - "the guard's finish-time forced recovery must abandon every scope live during the \ - guarded interval, regardless of which harness's boundary happened to observe \ - user_bash" - ); - assert_eq!( - repo.scope_status(&scope_b), - Some(("pi".to_string(), "abandoned".to_string())) - ); - - repo.drive(&tool_execution_end(&repo, "call_a", session)) - .expect( - "the adapter's next interaction for an already-abandoned scope must reconcile \ - safely (falling back through the existing Close-failure-to-abandon path) rather \ - than erroring or resurrecting the scope", - ); - repo.drive(&tool_execution_end(&repo, "call_b", session)) - .expect("the same reconciliation must hold for every sibling abandoned by the guard"); - - assert!( - state::read_state(&resolve_git_dir(&repo.root).expect("git dir resolves")) - .expect("state readable") - .attempts - .is_empty(), - "the Pi adapter's own durable local attempt state must converge to empty once it \ - observes the terminal event for a scope the generic runtime already abandoned out \ - from under it" - ); - } - - #[test] - fn a_guard_abandons_a_live_pi_scope_alongside_a_live_scope_from_another_harness() { - let repo = GuardRepo::new("cross-harness"); - let key = AttemptKey { - session_id: "01a091f4-guard-cross-session".to_string(), - tool_call_id: "call_pi".to_string(), - }; - let pi_scope_id = format_pi_scope_id(&key, 1); - let claude_scope = ScopeId("claude-scope-under-guard".to_string()); - - repo.drive(&tool_call(&repo, "call_pi", "01a091f4-guard-cross-session")) - .expect("Pi's Start should reach the real runtime"); - coordinate( - &repo.root, - &RuntimeBoundary::Start { - scope: claude_scope.clone(), - event: EventId("claude-evt-start".to_string()), - actor_kind: ActorKind::ClaudeCode, - provenance: None, - }, - || repo.open_db(), - ) - .expect("Claude's Start should reach the real runtime"); - - assert_eq!( - repo.scope_status(&pi_scope_id), - Some(("pi".to_string(), "active".to_string())) - ); - assert_eq!( - repo.scope_status(&claude_scope.0), - Some(("claude_code".to_string(), "active".to_string())) - ); - - let (_cancel_tx, cancel_rx) = mpsc::channel(); - let root = repo.root.clone(); - run_external_mutation_guard( - &root, - &GuardRequest { - command: "printf changed >> file.txt".to_string(), - cwd: None, - env: Vec::new(), - }, - || repo.open_db(), - |_event| {}, - cancel_rx, - ) - .expect("the guard should finish successfully"); - - assert_eq!( - repo.scope_status(&pi_scope_id), - Some(("pi".to_string(), "abandoned".to_string())), - "the guard's forced recovery must abandon the Pi scope even though a different \ - harness's boundary is the one that happened to observe user_bash" - ); - assert_eq!( - repo.scope_status(&claude_scope.0), - Some(("claude_code".to_string(), "abandoned".to_string())), - "the guard's forced recovery must abandon every live scope on the worktree \ - regardless of which harness owns it" - ); - - repo.drive(&tool_execution_end( - &repo, - "call_pi", - "01a091f4-guard-cross-session", - )) - .expect( - "the Pi adapter must still reconcile cleanly with its own scope even when a \ - sibling scope belonging to a different harness was abandoned by the same guard", - ); - assert!( - state::read_state(&resolve_git_dir(&repo.root).expect("git dir resolves")) - .expect("state readable") - .attempts - .is_empty() - ); - } - - #[test] - fn a_foreign_pi_start_racing_an_active_guard_fails_closed_touching_no_state_then_succeeds_on_retry( - ) { - let repo = GuardRepo::new("race"); - let ready = repo.root.join("ready"); - let release = repo.root.join("release"); - let command = format!( - "touch '{}'; while [ ! -f '{}' ]; do sleep 0.02; done", - ready.display(), - release.display(), - ); - - let root = repo.root.clone(); - let state_root = repo.state_root.clone(); - let guard_thread = std::thread::spawn(move || { - let (_cancel_tx, cancel_rx) = mpsc::channel(); - run_external_mutation_guard( - &root, - &GuardRequest { - command, - cwd: None, - env: Vec::new(), - }, - || { - crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( - &root, - &state_root, - "guard race test", - ) - }, - |_event| {}, - cancel_rx, - ) - }); - - let deadline = std::time::Instant::now() + Duration::from_secs(5); - while !ready.exists() { - assert!( - std::time::Instant::now() < deadline, - "the guarded shell never reported ready" - ); - std::thread::sleep(Duration::from_millis(20)); - } - - let key = AttemptKey { - session_id: "01a091f4-guard-race-session".to_string(), - tool_call_id: "call_race".to_string(), - }; - let scope_id = format_pi_scope_id(&key, 1); - - let error = repo - .drive(&tool_call( - &repo, - "call_race", - "01a091f4-guard-race-session", - )) - .expect_err( - "a Pi Start racing an active external-mutation guard must block then fail \ - closed with CoordinateError::LockAcquisition, never proceed", - ); - assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE)); - assert!( - repo.scope_status(&scope_id).is_none(), - "a boundary that fails closed on lock acquisition must touch no protocol state" - ); - - fs::write(&release, "go").expect("release file should write"); - let outcome = guard_thread - .join() - .expect("guard thread should not panic") - .expect("the guard should finish successfully once released"); - assert_eq!(outcome.exit_code, Some(0)); - - repo.drive(&tool_call( - &repo, - "call_race", - "01a091f4-guard-race-session", - )) - .expect("retrying the same Start after the guard finishes must succeed normally"); - assert_eq!( - repo.scope_status(&scope_id), - Some(("pi".to_string(), "active".to_string())) - ); - } - - #[test] - #[allow(clippy::too_many_lines)] - fn a_foreign_harnesss_boundary_racing_the_guard_fails_closed_then_succeeds_normally_on_retry() { - let repo = GuardRepo::new("cross-harness-race"); - let claude_scope = ScopeId("claude-scope-racing-guard".to_string()); - let key = AttemptKey { - session_id: "01a091f4-guard-cross-race-session".to_string(), - tool_call_id: "call_pi_race".to_string(), - }; - let pi_scope_id = format_pi_scope_id(&key, 1); - - repo.drive(&tool_call( - &repo, - "call_pi_race", - "01a091f4-guard-cross-race-session", - )) - .expect("Pi's Start should reach the real runtime"); - coordinate( - &repo.root, - &RuntimeBoundary::Start { - scope: claude_scope.clone(), - event: EventId("claude-evt-race-start".to_string()), - actor_kind: ActorKind::ClaudeCode, - provenance: None, - }, - || repo.open_db(), - ) - .expect("Claude's Start should reach the real runtime"); - - let ready = repo.root.join("ready"); - let release = repo.root.join("release"); - let command = format!( - "touch '{}'; printf w1 >> file.txt; while [ ! -f '{}' ]; do sleep 0.02; done; printf w2 >> file.txt", - ready.display(), - release.display(), - ); - - let root = repo.root.clone(); - let state_root = repo.state_root.clone(); - let guard_thread = std::thread::spawn(move || { - let (_cancel_tx, cancel_rx) = mpsc::channel(); - run_external_mutation_guard( - &root, - &GuardRequest { - command, - cwd: None, - env: Vec::new(), - }, - || { - crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( - &root, - &state_root, - "guard cross-harness race test", - ) - }, - |_event| {}, - cancel_rx, - ) - }); - - let deadline = std::time::Instant::now() + Duration::from_secs(5); - while !ready.exists() { - assert!( - std::time::Instant::now() < deadline, - "the guarded shell never reported ready" - ); - std::thread::sleep(Duration::from_millis(20)); - } - - let close_while_active = coordinate( - &repo.root, - &RuntimeBoundary::Close { - scope: claude_scope.clone(), - event: EventId("claude-evt-race-close".to_string()), - actor_kind: ActorKind::ClaudeCode, - }, - || repo.open_db(), - ); - assert!( - matches!( - close_while_active, - Err(crate::services::mutation_trace::runtime::CoordinateError::LockAcquisition(_)) - ), - "a foreign harness's boundary racing an active external-mutation guard must fail \ - closed with LockAcquisition, never proceed while the guard still holds the \ - worktree: {close_while_active:?}" - ); - assert!( - repo.mutation_events().is_empty(), - "a boundary that fails closed on lock acquisition must touch no protocol state" - ); - - fs::write(&release, "go").expect("release file should write"); - let outcome = guard_thread - .join() - .expect("guard thread should not panic") - .expect("the guard should finish successfully once released"); - assert_eq!(outcome.exit_code, Some(0)); - - assert_eq!( - repo.scope_status(&pi_scope_id), - Some(("pi".to_string(), "abandoned".to_string())) - ); - assert_eq!( - repo.scope_status(&claude_scope.0), - Some(("claude_code".to_string(), "abandoned".to_string())) - ); - - coordinate( - &repo.root, - &RuntimeBoundary::Close { - scope: claude_scope.clone(), - event: EventId("claude-evt-race-close-retry".to_string()), - actor_kind: ActorKind::ClaudeCode, - }, - || repo.open_db(), - ) - .expect( - "Claude's deferred boundary must succeed normally once retried against the \ - recovered worktree, rather than continuing to fail closed", - ); - repo.drive(&tool_execution_end( - &repo, - "call_pi_race", - "01a091f4-guard-cross-race-session", - )) - .expect("the Pi adapter must reconcile its own already-abandoned scope cleanly too"); - - assert!( - repo.mutation_events().is_empty(), - "both human writes made under the guard must remain excluded from positive AI \ - attribution for the Pi scope and for the racing foreign-harness scope alike" - ); - } - - #[test] - fn a_guard_triggered_abandonment_does_not_poison_the_checkout_for_a_fresh_pi_scope() { - let repo = GuardRepo::new("post-recovery-fresh-start"); - let session = "01a091f4-guard-fresh-session"; - let key_a = AttemptKey { - session_id: session.to_string(), - tool_call_id: "call_doomed".to_string(), - }; - let scope_a = format_pi_scope_id(&key_a, 1); - - repo.drive(&tool_call(&repo, "call_doomed", session)) - .expect("A's Start should reach the real runtime"); - assert_eq!( - repo.scope_status(&scope_a), - Some(("pi".to_string(), "active".to_string())) - ); - - let (_cancel_tx, cancel_rx) = mpsc::channel(); - let root = repo.root.clone(); - let outcome = run_external_mutation_guard( - &root, - &GuardRequest { - command: "printf changed >> file.txt".to_string(), - cwd: None, - env: Vec::new(), - }, - || repo.open_db(), - |_event| {}, - cancel_rx, - ) - .expect("the guard should finish successfully"); - assert_eq!(outcome.exit_code, Some(0)); - - assert_eq!( - repo.scope_status(&scope_a), - Some(("pi".to_string(), "abandoned".to_string())) - ); - repo.drive(&tool_execution_end(&repo, "call_doomed", session)) - .expect("the adapter must reconcile the guard-abandoned scope cleanly"); - - let key_c = AttemptKey { - session_id: session.to_string(), - tool_call_id: "call_clean".to_string(), - }; - let scope_c = format_pi_scope_id(&key_c, 2); - - repo.drive(&tool_call(&repo, "call_clean", session)) - .expect("a fresh Start on the same worktree after recovery must succeed normally"); - assert_eq!( - repo.scope_status(&scope_c), - Some(("pi".to_string(), "active".to_string())) - ); - - repo.write("file.txt", "one\nchanged\nclean\n"); - repo.drive(&tool_result(&repo, "call_clean", session)) - .expect("tool_result should mark Executed"); - repo.drive(&tool_execution_end(&repo, "call_clean", session)) - .expect("Close should reach the real runtime"); - - assert_eq!( - repo.scope_status(&scope_c), - Some(("pi".to_string(), "closed".to_string())) - ); - assert_eq!( - repo.mutation_events(), - vec![("ai_exclusive".to_string(), Some(scope_c))], - "a clean Pi mutation after guard-triggered recovery must still reach AiExclusive; \ - recovery must not permanently poison the checkout for later, uninterfered-with work" - ); - } -} +mod guard_reconciliation_tests; diff --git a/cli/src/services/hooks/pi_mutation_scope/payload.rs b/cli/src/services/hooks/pi_mutation_scope/payload.rs new file mode 100644 index 000000000..4b2e7dc81 --- /dev/null +++ b/cli/src/services/hooks/pi_mutation_scope/payload.rs @@ -0,0 +1,44 @@ +use serde_json::json; + +use super::events::ACTOR_KIND_PI; +use super::PiScopeProvenance; + +pub(super) fn scope_boundary_payload(operation: &str, scope_id: &str, event_id: &str) -> String { + json!({ + "operation": operation, + "scope_id": scope_id, + "event_id": event_id, + "actor_kind": ACTOR_KIND_PI, + }) + .to_string() +} + +pub(super) fn scope_start_payload( + scope_id: &str, + event_id: &str, + provenance: &PiScopeProvenance, +) -> String { + json!({ + "operation": "start", + "scope_id": scope_id, + "event_id": event_id, + "actor_kind": ACTOR_KIND_PI, + "provenance": { + "session_id": provenance.session_id, + "model_id": provenance.model_id, + }, + }) + .to_string() +} + +pub(super) fn abandon_payload(scope_id: &str) -> String { + json!({ + "operation": "abandon", + "scope_id": scope_id, + }) + .to_string() +} + +pub(super) fn flush_payload() -> String { + json!({ "operation": "flush" }).to_string() +} diff --git a/cli/src/services/hooks/pi_mutation_scope/runtime_seam_tests.rs b/cli/src/services/hooks/pi_mutation_scope/runtime_seam_tests.rs new file mode 100644 index 000000000..a35d65657 --- /dev/null +++ b/cli/src/services/hooks/pi_mutation_scope/runtime_seam_tests.rs @@ -0,0 +1,240 @@ +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; +use crate::services::agent_trace_storage::{ + resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, +}; + +use super::*; + +fn git(dir: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(dir) + .output() + .expect("git should spawn"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +struct PiRepo { + _temp: tempfile::TempDir, + root: PathBuf, + state_root: PathBuf, +} + +impl PiRepo { + fn new(label: &str) -> Self { + let temp = tempfile::Builder::new() + .prefix(&format!("sce-pi-mutation-scope-seam-{label}-")) + .tempdir() + .expect("temp dir should be created"); + let root = temp.path().join("repo"); + fs::create_dir_all(&root).expect("repo dir should be created"); + git(&root, &["init", "-q"]); + git(&root, &["config", "user.email", "test@example.invalid"]); + git(&root, &["config", "user.name", "SCE Test"]); + git( + &root, + &["remote", "add", "origin", "git@github.com:acme/widgets.git"], + ); + fs::write(root.join("file.txt"), "one\n").expect("seed file should write"); + git(&root, &["add", "-A"]); + git(&root, &["commit", "-qm", "base"]); + + let state_root = temp.path().join("state"); + fs::create_dir_all(&state_root).expect("state root should be created"); + resolve_agent_trace_storage_at_state_root( + &AgentTraceStorageContext { + repository_root: &root, + explicit_repository_id: None, + repository_remote: "origin", + }, + &state_root, + ) + .expect("state-root storage should initialize the repository DB"); + + Self { + _temp: temp, + root, + state_root, + } + } + + fn cwd(&self) -> String { + self.root.to_string_lossy().into_owned() + } + + fn drive(&self, payload: &str) -> Result { + run_pi_mutation_scope_from_payload_at_state_root(&self.state_root, payload, None) + } + + fn write(&self, name: &str, contents: &str) { + fs::write(self.root.join(name), contents).expect("write should succeed"); + } + + fn db(&self) -> RepositoryAgentTraceDb { + crate::services::hooks::open_agent_trace_db_for_hook_runtime_at_state_root( + &self.root, + &self.state_root, + "Pi mutation-scope seam test assertions", + ) + .expect("assertion DB should open") + } + + fn scope_status(&self, scope_id: &str) -> Option<(String, String)> { + self.db() + .query_map( + "SELECT actor_kind, status FROM mutation_trace_scopes WHERE scope_id = ?1", + (scope_id,), + |row| { + let actor_kind = row.get::(0).map_err(anyhow::Error::from)?; + let status = row.get::(1).map_err(anyhow::Error::from)?; + Ok((actor_kind, status)) + }, + ) + .expect("scope query should succeed") + .into_iter() + .next() + } + + fn scope_provenance(&self, scope_id: &str) -> Option<(String, Option)> { + self.db() + .query_map( + "SELECT session_id, model_id FROM mutation_trace_scope_provenance \ + WHERE scope_id = ?1", + (scope_id,), + |row| { + let session_id = row.get::(0).map_err(anyhow::Error::from)?; + let model_id = row.get::>(1).map_err(anyhow::Error::from)?; + Ok((session_id, model_id)) + }, + ) + .expect("scope-provenance query should succeed") + .into_iter() + .next() + } + + fn mutation_events(&self) -> Vec<(String, Option)> { + self.db() + .query_map( + "SELECT attribution_kind, attribution_scope_id \ + FROM mutation_trace_events ORDER BY revision", + (), + |row| { + let attribution_kind = row.get::(0).map_err(anyhow::Error::from)?; + let attribution_scope_id = + row.get::>(1).map_err(anyhow::Error::from)?; + Ok((attribution_kind, attribution_scope_id)) + }, + ) + .expect("mutation-events query should succeed") + } +} + +fn tool_call(repo: &PiRepo, tool_name: &str, tool_call_id: &str) -> String { + json!({ + "hook_event_name": "ToolCall", + "session_id": "01a091f4-seam-session", + "tool_call_id": tool_call_id, + "cwd": repo.cwd(), + "tool_name": tool_name, + "model": "openai-codex/gpt-5.5", + }) + .to_string() +} + +fn tool_result(repo: &PiRepo, tool_name: &str, tool_call_id: &str) -> String { + json!({ + "hook_event_name": "ToolResult", + "session_id": "01a091f4-seam-session", + "tool_call_id": tool_call_id, + "cwd": repo.cwd(), + "tool_name": tool_name, + }) + .to_string() +} + +fn tool_execution_end(repo: &PiRepo, tool_name: &str, tool_call_id: &str) -> String { + json!({ + "hook_event_name": "ToolExecutionEnd", + "session_id": "01a091f4-seam-session", + "tool_call_id": tool_call_id, + "cwd": repo.cwd(), + "tool_name": tool_name, + }) + .to_string() +} + +#[test] +fn a_write_start_result_close_lands_a_real_ai_exclusive_event_with_pi_provenance() { + let repo = PiRepo::new("real-lifecycle"); + let key = AttemptKey { + session_id: "01a091f4-seam-session".to_string(), + tool_call_id: "call_write".to_string(), + }; + let scope_id = format_pi_scope_id(&key, 1); + + repo.drive(&tool_call(&repo, "write", "call_write")) + .expect("Start should reach the real runtime"); + assert_eq!( + repo.scope_status(&scope_id), + Some(("pi".to_string(), "active".to_string())) + ); + assert_eq!( + repo.scope_provenance(&scope_id), + Some(( + "pi_01a091f4-seam-session".to_string(), + Some("openai-codex/gpt-5.5".to_string()) + )) + ); + + repo.write("file.txt", "one\ntwo\n"); + repo.drive(&tool_result(&repo, "write", "call_write")) + .expect("tool_result should mark Executed"); + + repo.drive(&tool_execution_end(&repo, "write", "call_write")) + .expect("Close should reach the real runtime"); + + assert_eq!( + repo.scope_status(&scope_id), + Some(("pi".to_string(), "closed".to_string())) + ); + assert_eq!( + repo.mutation_events(), + vec![("ai_exclusive".to_string(), Some(scope_id))] + ); + assert!( + state::read_state(&resolve_git_dir(&repo.root).expect("git dir resolves")) + .expect("state readable") + .attempts + .is_empty() + ); +} + +#[test] +fn a_start_followed_by_no_execution_abandons_through_the_real_runtime() { + let repo = PiRepo::new("real-abandon"); + let key = AttemptKey { + session_id: "01a091f4-seam-session".to_string(), + tool_call_id: "call_blocked".to_string(), + }; + let scope_id = format_pi_scope_id(&key, 1); + + repo.drive(&tool_call(&repo, "bash", "call_blocked")) + .expect("Start should reach the real runtime"); + + repo.drive(&tool_execution_end(&repo, "bash", "call_blocked")) + .expect("the terminal event must resolve via abandon, not surface an error"); + + assert_eq!( + repo.scope_status(&scope_id), + Some(("pi".to_string(), "abandoned".to_string())) + ); + assert!(repo.mutation_events().is_empty()); +} diff --git a/cli/src/services/hooks/pi_mutation_scope/state.rs b/cli/src/services/hooks/pi_mutation_scope/state.rs index 3c9ca81d6..049786361 100644 --- a/cli/src/services/hooks/pi_mutation_scope/state.rs +++ b/cli/src/services/hooks/pi_mutation_scope/state.rs @@ -7,8 +7,10 @@ use anyhow::{anyhow, Context, Result}; use serde::{Deserialize, Serialize}; use super::os_lock::{AdvisoryLockError, OsAdvisoryLock}; -use super::process_owner::{current_process_owner, is_definitely_dead, ProcessOwner}; use super::{format_pi_scope_id, AttemptKey}; +use crate::services::hooks::mutation_scope_owner::{ + current_process_owner, is_definitely_dead, ProcessOwner, +}; const SCE_STATE_DIR: &str = "sce"; const ADAPTER_STATE_FILE: &str = "pi-mutation-scope-state.json"; @@ -314,8 +316,6 @@ pub(crate) fn admit_tracked_attempt( })) } -/// Read-only D10 scan: `scope_id`s of live (`PendingStart`/`Executed`) attempts whose own -/// recorded owner is positively dead. Never includes `PendingAbandon`. pub(crate) fn find_definitely_dead_attempts(git_dir: &Path) -> Result> { let _lock = acquire_lock(git_dir)?; let state = read_state(git_dir)?; diff --git a/cli/src/services/hooks/pi_mutation_scope/tests.rs b/cli/src/services/hooks/pi_mutation_scope/tests.rs new file mode 100644 index 000000000..a9ba41621 --- /dev/null +++ b/cli/src/services/hooks/pi_mutation_scope/tests.rs @@ -0,0 +1,332 @@ +use super::*; + +fn tool_event_json(hook_event_name: &str, overrides: &[(&str, Value)]) -> String { + let mut object = Map::new(); + object.insert( + HOOK_EVENT_NAME_FIELD.to_string(), + Value::String(hook_event_name.to_string()), + ); + object.insert( + SESSION_ID_FIELD.to_string(), + Value::String("01a091f4-session".to_string()), + ); + object.insert( + TOOL_CALL_ID_FIELD.to_string(), + Value::String("call_1|fc_1".to_string()), + ); + object.insert( + CWD_FIELD.to_string(), + Value::String("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/repo/checkout".to_string()), + ); + object.insert( + TOOL_NAME_FIELD.to_string(), + Value::String("write".to_string()), + ); + for (field, value) in overrides { + object.insert((*field).to_string(), value.clone()); + } + Value::Object(object).to_string() +} + +fn key(session_id: &str, tool_call_id: &str) -> AttemptKey { + AttemptKey { + session_id: session_id.to_string(), + tool_call_id: tool_call_id.to_string(), + } +} + +fn tool_call(payload: &str) -> PiToolCall { + match parse_pi_hook_event(payload).expect("valid ToolCall parses") { + PiHookEvent::Call(call) => call, + other => panic!("expected ToolCall, got {other:?}"), + } +} + +#[test] +fn empty_payload_is_rejected() { + let error = parse_pi_hook_event(" ").unwrap_err().to_string(); + assert_eq!( + error, + "Invalid Pi hook event payload from STDIN: expected a JSON object, got an empty payload." + ); +} + +#[test] +fn non_object_json_is_rejected() { + for payload in ["[]", "\"ToolCall\"", "42", "null"] { + let error = parse_pi_hook_event(payload).unwrap_err().to_string(); + assert!( + error.contains("expected a JSON object"), + "payload {payload:?} produced {error:?}" + ); + } +} + +#[test] +fn invalid_json_is_rejected() { + let error = parse_pi_hook_event("{not json").unwrap_err().to_string(); + assert!( + error.contains("Invalid Pi hook event payload from STDIN: expected valid JSON"), + "{error:?}" + ); +} + +#[test] +fn unsupported_hook_event_name_is_rejected() { + for name in ["PreToolUse", "tool_call", "chat.params", ""] { + let payload = tool_event_json(name, &[]); + let error = parse_pi_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains("hook_event_name"), + "name {name:?} produced {error:?}" + ); + } +} + +#[test] +fn missing_required_fields_are_rejected_without_fabricating_identity() { + for field in [ + SESSION_ID_FIELD, + TOOL_CALL_ID_FIELD, + CWD_FIELD, + TOOL_NAME_FIELD, + ] { + let mut object: Map = + serde_json::from_str(&tool_event_json(HOOK_EVENT_TOOL_CALL, &[])).unwrap(); + object.remove(field); + let payload = Value::Object(object).to_string(); + + let error = parse_pi_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains(&format!("'{field}'")), + "missing {field} produced {error:?}" + ); + } +} + +#[test] +fn blank_required_fields_are_rejected() { + for field in [ + SESSION_ID_FIELD, + TOOL_CALL_ID_FIELD, + CWD_FIELD, + TOOL_NAME_FIELD, + ] { + let payload = tool_event_json( + HOOK_EVENT_TOOL_CALL, + &[(field, Value::String(" ".to_string()))], + ); + let error = parse_pi_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains(&format!("field '{field}' must be a non-blank string")), + "blank {field} produced {error:?}" + ); + } +} + +#[test] +fn wrong_typed_fields_are_rejected() { + let payload = tool_event_json( + HOOK_EVENT_TOOL_CALL, + &[(TOOL_CALL_ID_FIELD, Value::Bool(true))], + ); + let error = parse_pi_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains("field 'tool_call_id' must be a string"), + "{error:?}" + ); +} + +#[test] +fn wrong_typed_optional_model_is_rejected() { + let payload = tool_event_json(HOOK_EVENT_TOOL_CALL, &[(MODEL_FIELD, Value::Bool(false))]); + let error = parse_pi_hook_event(&payload).unwrap_err().to_string(); + assert!( + error.contains("field 'model' must be null, absent, or a non-blank string"), + "{error:?}" + ); +} + +#[test] +fn tool_call_parses_identity_and_model() { + let call = tool_call(&tool_event_json( + HOOK_EVENT_TOOL_CALL, + &[ + (TOOL_NAME_FIELD, Value::String("edit".to_string())), + ( + MODEL_FIELD, + Value::String("openai-codex/gpt-5.5".to_string()), + ), + ], + )); + assert_eq!(call.identity.session_id, "01a091f4-session"); + assert_eq!(call.identity.tool_call_id, "call_1|fc_1"); + assert_eq!(call.identity.tool_name, "edit"); + assert_eq!(call.model.as_deref(), Some("openai-codex/gpt-5.5")); + assert_eq!( + call.identity.classification(), + ToolClassification::TrackedMutation + ); +} + +#[test] +fn tool_call_model_is_optional() { + let call = tool_call(&tool_event_json(HOOK_EVENT_TOOL_CALL, &[])); + assert_eq!(call.model, None); +} + +#[test] +fn tool_result_and_tool_execution_end_parse_minimal_identity() { + for name in [ + HOOK_EVENT_TOOL_RESULT, + HOOK_EVENT_TOOL_EXECUTION_END, + HOOK_EVENT_TOOL_EXECUTION_ABANDON, + ] { + let event = parse_pi_hook_event(&tool_event_json(name, &[])).unwrap(); + let identity = match event { + PiHookEvent::Executed(identity) + | PiHookEvent::ExecutionEnd(identity) + | PiHookEvent::ExecutionAbandon(identity) => identity, + other => panic!("expected a minimal-identity event, got {other:?}"), + }; + assert_eq!( + identity.attempt_key(), + key("01a091f4-session", "call_1|fc_1") + ); + } +} + +#[test] +fn tool_execution_start_parses_and_is_never_evidence() { + let event = + parse_pi_hook_event(&tool_event_json(HOOK_EVENT_TOOL_EXECUTION_START, &[])).unwrap(); + let PiHookEvent::ExecutionStart(identity) = event else { + panic!("expected ToolExecutionStart"); + }; + assert_eq!(identity.tool_call_id, "call_1|fc_1"); +} + +#[test] +fn classification_table() { + let cases: &[(&str, ToolClassification)] = &[ + ("bash", ToolClassification::TrackedMutation), + ("edit", ToolClassification::TrackedMutation), + ("write", ToolClassification::TrackedMutation), + ("read", ToolClassification::Untracked), + ("grep", ToolClassification::Untracked), + ("find", ToolClassification::Untracked), + ("ls", ToolClassification::Untracked), + ("probe_mutate", ToolClassification::Untracked), + ("Bash", ToolClassification::Untracked), + ("some_future_pi_builtin", ToolClassification::Untracked), + ("", ToolClassification::Untracked), + ]; + for (tool_name, expected) in cases { + assert_eq!( + classify_tool(tool_name), + *expected, + "classify_tool({tool_name:?})" + ); + } +} + +#[test] +fn scope_id_embeds_attempt_seq_and_is_length_prefixed() { + let k = key("01a091f4-session", "call_1|fc_1"); + let scope_id = format_pi_scope_id(&k, 1); + assert_eq!( + scope_id, + "pi-tool-v1|n=1|s=16:01a091f4-session|c=11:call_1|fc_1" + ); + assert_ne!(format_pi_scope_id(&k, 1), format_pi_scope_id(&k, 2)); + assert_eq!( + pi_scope_start_event_id(&scope_id), + format!("{scope_id}|start") + ); + assert_eq!( + pi_scope_close_event_id(&scope_id), + format!("{scope_id}|close") + ); + assert_ne!( + pi_scope_start_event_id(&scope_id), + pi_scope_close_event_id(&scope_id) + ); +} + +#[test] +fn length_prefix_disambiguates_delimiter_collisions() { + let a = key("s|c=1:x", "y"); + let b = key("s", "1:x|y"); + assert_ne!(format_pi_scope_id(&a, 1), format_pi_scope_id(&b, 1)); +} + +#[test] +fn provenance_canonicalizes_the_session_and_normalizes_the_model() { + let provenance = pi_scope_provenance("01a091f4-session", Some("openai-codex/gpt-5.5")); + assert_eq!(provenance.session_id, "pi_01a091f4-session"); + assert_eq!(provenance.model_id.as_deref(), Some("openai-codex/gpt-5.5")); +} + +#[test] +fn provenance_keeps_an_already_prefixed_session_id() { + let provenance = pi_scope_provenance("pi_01a091f4-session", None); + assert_eq!(provenance.session_id, "pi_01a091f4-session"); +} + +#[test] +fn provenance_without_model_evidence_is_null() { + for model in [None, Some(""), Some(" ")] { + let provenance = pi_scope_provenance("01a091f4-session", model); + assert_eq!(provenance.model_id, None, "model {model:?}"); + } +} + +#[test] +fn run_from_payload_fails_closed_when_a_tracked_start_cannot_resolve_its_checkout() { + let payload = tool_event_json( + HOOK_EVENT_TOOL_CALL, + &[( + CWD_FIELD, + Value::String("/nonexistent/sce/pi/checkout".to_string()), + )], + ); + let error = run_pi_mutation_scope_from_payload(&payload, None) + .expect_err("a tracked Start that cannot resolve its checkout must fail closed"); + assert!(error.to_string().contains(FAIL_CLOSED_MESSAGE), "{error:?}"); +} + +#[test] +fn run_from_payload_is_neutral_for_untracked_events() { + for tool_name in ["read", "grep", "find", "ls", "probe_mutate"] { + let payload = tool_event_json( + HOOK_EVENT_TOOL_CALL, + &[(TOOL_NAME_FIELD, Value::String(tool_name.to_string()))], + ); + assert_eq!( + run_pi_mutation_scope_from_payload(&payload, None).unwrap(), + String::new() + ); + } +} + +#[test] +fn run_from_payload_surfaces_malformed_input() { + let error = run_pi_mutation_scope_from_payload("{bad", None) + .unwrap_err() + .to_string(); + assert!(error.contains("expected valid JSON"), "{error:?}"); +} + +#[test] +fn tool_execution_start_is_always_a_no_op_regardless_of_classification() { + for tool_name in ["bash", "read"] { + let payload = tool_event_json( + HOOK_EVENT_TOOL_EXECUTION_START, + &[(TOOL_NAME_FIELD, Value::String(tool_name.to_string()))], + ); + assert_eq!( + run_pi_mutation_scope_from_payload(&payload, None).unwrap(), + String::new() + ); + } +} diff --git a/cli/src/services/hooks/runtime.rs b/cli/src/services/hooks/runtime.rs new file mode 100644 index 000000000..6fcd8d8ea --- /dev/null +++ b/cli/src/services/hooks/runtime.rs @@ -0,0 +1,183 @@ +use std::io::{self, Read}; +use std::path::Path; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::services::agent_trace_db::repository::RepositoryAgentTraceDb; +#[cfg(test)] +use crate::services::agent_trace_storage::resolve_agent_trace_storage_for_hook_runtime_at_state_root; +use crate::services::agent_trace_storage::{ + resolve_agent_trace_storage_for_hook_runtime, AgentTraceStorageContext, +}; +use crate::services::config; +use anyhow::{bail, Context, Result}; + +pub(crate) const CLAUDE_MODEL_ID_PREFIX: &str = "claude/"; +pub(crate) const DIFF_TRACE_OPENCODE_SESSION_ID_PREFIX: &str = "oc_"; +pub(crate) const DIFF_TRACE_CLAUDE_SESSION_ID_PREFIX: &str = "cc_"; +pub(crate) const DIFF_TRACE_PI_SESSION_ID_PREFIX: &str = "pi_"; +pub(crate) const DIFF_TRACE_CODEX_SESSION_ID_PREFIX: &str = "cx_"; +pub(crate) const OPENCODE_TOOL_NAME: &str = "opencode"; +pub(crate) const CLAUDE_TOOL_NAME: &str = "claude"; +pub(crate) const PI_TOOL_NAME: &str = "pi"; +pub(crate) const CODEX_TOOL_NAME: &str = "codex"; +pub(crate) const NORMALIZED_CONVERSATION_TRACE_TOOL_NAMES: &[&str] = + &[OPENCODE_TOOL_NAME, PI_TOOL_NAME]; +pub(crate) type PayloadValidationError = fn(&str) -> String; + +pub(crate) fn prefixed_diff_trace_session_id(tool_name: &str, raw_session_id: &str) -> String { + prefixed_session_id(tool_name, raw_session_id) +} + +pub(crate) fn prefixed_conversation_trace_session_id( + tool_name: &str, + raw_session_id: &str, +) -> String { + prefixed_session_id(tool_name, raw_session_id) +} + +pub(crate) fn prefixed_session_id(tool_name: &str, raw_session_id: &str) -> String { + let prefix = match tool_name { + OPENCODE_TOOL_NAME => DIFF_TRACE_OPENCODE_SESSION_ID_PREFIX, + CLAUDE_TOOL_NAME => DIFF_TRACE_CLAUDE_SESSION_ID_PREFIX, + PI_TOOL_NAME => DIFF_TRACE_PI_SESSION_ID_PREFIX, + CODEX_TOOL_NAME => DIFF_TRACE_CODEX_SESSION_ID_PREFIX, + _ => return raw_session_id.to_string(), + }; + + if raw_session_id.starts_with(prefix) { + raw_session_id.to_string() + } else { + format!("{prefix}{raw_session_id}") + } +} +pub(crate) fn open_agent_trace_db_for_hook_runtime( + repository_root: &Path, + context_message: &'static str, +) -> Result { + let storage_config = config::resolve_agent_trace_storage_runtime_config(repository_root) + .context("Failed to resolve Agent Trace repository storage config.")?; + let storage_context = AgentTraceStorageContext { + repository_root, + explicit_repository_id: storage_config.repository_id.as_deref(), + repository_remote: &storage_config.repository_remote, + }; + + resolve_agent_trace_storage_for_hook_runtime(&storage_context) + .map(|storage| storage.db) + .context(context_message) +} + +#[cfg(test)] +pub(crate) fn open_agent_trace_db_for_hook_runtime_at_state_root( + repository_root: &Path, + state_root: &Path, + context_message: &'static str, +) -> Result { + let storage_config = config::resolve_agent_trace_storage_runtime_config(repository_root) + .context("Failed to resolve Agent Trace repository storage config.")?; + let storage_context = AgentTraceStorageContext { + repository_root, + explicit_repository_id: storage_config.repository_id.as_deref(), + repository_remote: &storage_config.repository_remote, + }; + + resolve_agent_trace_storage_for_hook_runtime_at_state_root(&storage_context, state_root) + .map(|storage| storage.db) + .context(context_message) +} + +pub(crate) fn current_unix_time_ms() -> Result { + i64::try_from(SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis()) + .context("Current time exceeds i64 range for post-commit intersection.") +} +pub(crate) fn read_hook_stdin() -> Result { + let mut stdin_payload = String::new(); + io::stdin() + .read_to_string(&mut stdin_payload) + .context("Failed to read hook input from STDIN.")?; + Ok(stdin_payload) +} + +pub(crate) fn run_git_command_capture_stdout( + repository_root: &Path, + args: &[&str], + context_message: &str, +) -> Result { + let output = Command::new("git") + .args(args) + .current_dir(repository_root) + .output() + .with_context(|| { + format!( + "{} (directory: '{}')", + context_message, + repository_root.display() + ) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let diagnostic = if stderr.is_empty() { + String::from("git command exited with a non-zero status") + } else { + stderr + }; + bail!("{context_message} {diagnostic}"); + } + + String::from_utf8(output.stdout).context("git command output contained invalid UTF-8") +} + +pub(crate) fn resolve_runtime_state(repository_root: &Path) -> Result { + Ok(HookRuntimeState { + sce_disabled: env_flag_is_truthy("SCE_DISABLED"), + attribution_hooks_enabled: config::resolve_hook_runtime_config(repository_root)? + .attribution_hooks_enabled, + }) +} + +pub(crate) fn env_flag_is_truthy(name: &str) -> bool { + std::env::var(name) + .ok() + .is_some_and(|value| env_value_is_truthy(&value)) +} + +pub(crate) fn env_value_is_truthy(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) +} + +pub(crate) fn commit_msg_policy_gate_passed(runtime: &HookRuntimeState) -> bool { + !runtime.sce_disabled && runtime.attribution_hooks_enabled +} + +pub(crate) fn pre_commit_no_op_reason(runtime: &HookRuntimeState) -> HookNoOpReason { + if runtime.sce_disabled { + HookNoOpReason::Disabled + } else { + HookNoOpReason::AttributionOnlyCommitMsgMode + } +} + +pub(crate) fn post_rewrite_no_op_reason(runtime: &HookRuntimeState) -> HookNoOpReason { + if runtime.sce_disabled { + HookNoOpReason::Disabled + } else { + HookNoOpReason::AttributionOnlyCommitMsgMode + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct HookRuntimeState { + pub sce_disabled: bool, + pub attribution_hooks_enabled: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum HookNoOpReason { + Disabled, + AttributionOnlyCommitMsgMode, +} diff --git a/cli/src/services/hooks/tests.rs b/cli/src/services/hooks/tests.rs new file mode 100644 index 000000000..260d5887e --- /dev/null +++ b/cli/src/services/hooks/tests.rs @@ -0,0 +1,4035 @@ +use std::{ + cell::RefCell, + fs, + path::{Path, PathBuf}, + process::Command, + thread, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use super::*; +use crate::services::agent_trace_db::{ + ClaudeModelStateObservation, ObservationKind, ParsedDiffTracePatch, SkippedDiffTracePatch, +}; + +#[derive(Debug, Eq, PartialEq)] +struct CapturedPostCommitIntersectionInsert { + commit_id: String, + post_commit_time_ms: i64, + recent_window_cutoff_ms: i64, + recent_window_end_ms: i64, + loaded_diff_trace_count: i64, + skipped_diff_trace_count: i64, + intersection_patch: String, +} + +fn valid_patch_text(path: &str, content: &str) -> String { + format!( + "Index: {path}\n===================================================================\n--- {path}\n+++ {path}\n@@ -0,0 +1,1 @@\n+{content}\n" + ) +} + +fn valid_patch(path: &str, content: &str) -> ParsedPatch { + let patch_text = valid_patch_text(path, content); + + parse_patch_from_text(&patch_text, None).expect("test patch should parse") +} + +#[test] +fn conversation_trace_mixed_payload_maps_to_message_and_part_insert_inputs() { + let patch_text = valid_patch_text("src/lib.rs", "let answer = 42;"); + let question_text = serde_json::json!([ + { + "question": "Proceed?", + "answer": "Yes" + } + ]) + .to_string(); + let payload = serde_json::json!({ + "tool_name": "opencode", + "payloads": [ + { + "type": "message", + "session_id": "session-1", + "message_id": "message-1", + "role": "assistant", + "generated_at_unix_ms": 1_800_000_000_000_i64 + }, + { + "type": "message.part", + "session_id": "session-1", + "message_id": "message-1", + "part_type": "reasoning", + "text": "thinking through validation", + "generated_at_unix_ms": 1_800_000_000_001_i64 + }, + { + "type": "message.part", + "session_id": "session-1", + "message_id": "message-1", + "part_type": "patch", + "text": patch_text, + "generated_at_unix_ms": 1_800_000_000_002_i64 + }, + { + "type": "message.part", + "session_id": "session-1", + "message_id": "message-1", + "part_type": "question", + "text": question_text, + "generated_at_unix_ms": 1_800_000_000_003_i64 + } + ] + }); + + let parsed = parse_conversation_trace_payload(&payload.to_string()) + .expect("conversation-trace mixed payload should parse"); + + assert_eq!(parsed.attempted_count, 4); + assert!(parsed.skipped.is_empty()); + assert!(parsed.message_updated.skipped.is_empty()); + assert!(parsed.message_part_updated.skipped.is_empty()); + + assert_eq!(parsed.message_updated.inserts.len(), 1); + let message = &parsed.message_updated.inserts[0]; + assert_eq!(message.session_id, "oc_session-1"); + assert_eq!(message.message_id, "message-1"); + assert_eq!(message.role, MessageRole::Assistant); + assert_eq!(message.generated_at_unix_ms, 1_800_000_000_000_i64); + + assert_eq!(parsed.message_part_updated.inserts.len(), 3); + let reasoning_part = &parsed.message_part_updated.inserts[0]; + assert_eq!(reasoning_part.session_id, "oc_session-1"); + assert_eq!(reasoning_part.message_id, "message-1"); + assert_eq!(reasoning_part.part_type, PartType::Reasoning); + assert_eq!(reasoning_part.text, "thinking through validation"); + assert_eq!(reasoning_part.generated_at_unix_ms, 1_800_000_000_001_i64); + + let patch_part = &parsed.message_part_updated.inserts[1]; + assert_eq!(patch_part.session_id, "oc_session-1"); + assert_eq!(patch_part.message_id, "message-1"); + assert_eq!(patch_part.part_type, PartType::Patch); + assert_eq!( + patch_part.text, + serialize_to_json(&valid_patch("src/lib.rs", "let answer = 42;")) + .expect("test patch should serialize") + ); + assert_eq!(patch_part.generated_at_unix_ms, 1_800_000_000_002_i64); + + let question_part = &parsed.message_part_updated.inserts[2]; + assert_eq!(question_part.session_id, "oc_session-1"); + assert_eq!(question_part.message_id, "message-1"); + assert_eq!(question_part.part_type, PartType::Question); + assert_eq!(question_part.text, question_text); + assert_eq!(question_part.generated_at_unix_ms, 1_800_000_000_003_i64); +} + +#[test] +fn conversation_trace_mixed_payload_skips_malformed_sibling_items() { + let invalid_question_text = serde_json::json!({ + "question": "Proceed?", + "answer": "Yes" + }) + .to_string(); + let payload = serde_json::json!({ + "tool_name": "opencode", + "payloads": [ + { + "type": "message", + "session_id": "session-1", + "message_id": "message-1", + "role": "assistant", + "generated_at_unix_ms": 1_800_000_000_000_i64 + }, + { + "type": "message", + "session_id": "session-2", + "message_id": "message-2", + "role": "system", + "generated_at_unix_ms": 1_800_000_000_002_i64 + }, + { + "type": "message.part", + "session_id": "session-3", + "message_id": "message-3", + "part_type": "text", + "generated_at_unix_ms": 1_800_000_000_003_i64 + }, + { + "type": "message.part", + "session_id": "session-4", + "message_id": "message-4", + "part_type": "patch", + "text": "--- src/main.rs", + "generated_at_unix_ms": 1_800_000_000_004_i64 + }, + { + "type": "message.part", + "session_id": "session-5", + "message_id": "message-5", + "part_type": "question", + "text": invalid_question_text, + "generated_at_unix_ms": 1_800_000_000_005_i64 + }, + { + "type": "session.started", + "session_id": "session-6" + }, + 42, + { + "type": null, + "session_id": "session-7" + } + ] + }); + + let parsed = parse_conversation_trace_payload(&payload.to_string()) + .expect("conversation-trace mixed payload should parse with skipped items"); + + assert_eq!(parsed.attempted_count, 8); + assert_eq!(parsed.message_updated.inserts.len(), 1); + assert_eq!(parsed.message_updated.skipped.len(), 1); + assert_eq!(parsed.message_updated.skipped[0].index, 1); + assert!(parsed.message_updated.skipped[0] + .reason + .contains("field 'role'")); + assert_eq!(parsed.message_part_updated.inserts.len(), 0); + assert_eq!(parsed.message_part_updated.skipped.len(), 3); + assert_eq!(parsed.message_part_updated.skipped[0].index, 2); + assert!(parsed.message_part_updated.skipped[0] + .reason + .contains("missing required field 'text'")); + assert_eq!(parsed.message_part_updated.skipped[1].index, 3); + assert!(parsed.message_part_updated.skipped[1] + .reason + .contains("neither valid patch-JSON nor a valid patch")); + assert_eq!(parsed.message_part_updated.skipped[2].index, 4); + assert!(parsed.message_part_updated.skipped[2] + .reason + .contains("question part must be a JSON array")); + assert_eq!(parsed.skipped.len(), 3); + assert_eq!(parsed.skipped[0].index, 5); + assert!(parsed.skipped[0].reason.contains("field 'type'")); + assert_eq!(parsed.skipped[1].index, 6); + assert!(parsed.skipped[1] + .reason + .contains("payloads[6] must be an object")); + assert_eq!(parsed.skipped[2].index, 7); + assert!(parsed.skipped[2] + .reason + .contains("field 'type' must be a string")); +} + +fn normalized_conversation_trace_message_payload(tool_name: &str, session_id: &str) -> String { + serde_json::json!({ + "tool_name": tool_name, + "payloads": [ + { + "type": "message", + "session_id": session_id, + "message_id": "message-1", + "role": "assistant", + "generated_at_unix_ms": 1_800_000_000_000_i64 + } + ] + }) + .to_string() +} + +#[test] +fn conversation_trace_normalized_payload_accepts_pi_tool_name_with_prefixed_session_id() { + let stdin_payload = normalized_conversation_trace_message_payload("pi", "session-1"); + + let parsed = parse_conversation_trace_payload(&stdin_payload) + .expect("Pi normalized conversation-trace payload should parse"); + + assert_eq!(parsed.message_updated.inserts.len(), 1); + assert_eq!(parsed.message_updated.inserts[0].session_id, "pi_session-1"); +} + +#[test] +fn conversation_trace_normalized_payload_rejects_unsupported_tool_name() { + let stdin_payload = normalized_conversation_trace_message_payload("cursor", "session-1"); + + let error = parse_conversation_trace_payload(&stdin_payload) + .expect_err("unsupported tool_name should be rejected"); + + assert!(error.to_string().contains("unsupported tool_name 'cursor'")); + assert!(error.to_string().contains("'opencode'")); + assert!(error.to_string().contains("'pi'")); +} + +#[test] +fn conversation_trace_normalized_payload_rejects_empty_tool_name() { + let stdin_payload = normalized_conversation_trace_message_payload("", "session-1"); + + let error = parse_conversation_trace_payload(&stdin_payload) + .expect_err("empty tool_name should be rejected"); + + assert!(error + .to_string() + .contains("field 'tool_name' must be a non-empty string")); +} + +#[test] +fn conversation_trace_normalized_payload_rejects_missing_tool_name() { + let stdin_payload = serde_json::json!({ + "payloads": [ + { + "type": "message", + "session_id": "session-1", + "message_id": "message-1", + "role": "assistant", + "generated_at_unix_ms": 1_800_000_000_000_i64 + } + ] + }) + .to_string(); + + let error = parse_conversation_trace_payload(&stdin_payload) + .expect_err("missing tool_name should be rejected"); + + assert!(error + .to_string() + .contains("missing required field 'tool_name'")); +} + +#[test] +fn conversation_trace_normalized_payload_keeps_already_prefixed_session_id() { + let stdin_payload = normalized_conversation_trace_message_payload("opencode", "oc_session-1"); + + let parsed = parse_conversation_trace_payload(&stdin_payload) + .expect("already-prefixed OpenCode session ID should parse"); + + assert_eq!(parsed.message_updated.inserts[0].session_id, "oc_session-1"); +} + +#[test] +fn conversation_trace_raw_claude_event_uses_claude_identity_with_cc_prefixed_session_id() { + let stdin_payload = serde_json::json!({ + "hook_event_name": "UserPromptSubmit", + "session_id": "session-1", + "prompt": "hello" + }) + .to_string(); + + let parsed = parse_conversation_trace_payload(&stdin_payload) + .expect("raw Claude UserPromptSubmit event should parse"); + + assert_eq!(parsed.message_updated.inserts.len(), 1); + assert_eq!(parsed.message_updated.inserts[0].session_id, "cc_session-1"); +} + +fn diff_trace_payload(model_id: Option<&str>, tool_version: Option<&str>) -> DiffTracePayload { + diff_trace_payload_with( + "claude", + "session-123", + PAYLOAD_TYPE_STRUCTURED, + model_id, + tool_version, + ) +} + +fn diff_trace_payload_with( + tool_name: &str, + session_id: &str, + payload_type: &str, + model_id: Option<&str>, + tool_version: Option<&str>, +) -> DiffTracePayload { + DiffTracePayload { + session_id: String::from(session_id), + diff: String::from("diff text"), + time: 1_800_000_000_000_u64, + model_id: model_id.map(String::from), + agent_id: None, + transcript_path: None, + tool_name: String::from(tool_name), + tool_version: tool_version.map(String::from), + payload_type: String::from(payload_type), + } +} + +fn claude_model_test_event(transcript_path: &Path, tool_use_id: &str) -> Value { + json!({ + "hook_event_name": "PostToolUse", + "session_id": "session-123", + "tool_name": "Write", + "tool_use_id": tool_use_id, + "transcript_path": transcript_path, + "tool_input": { + "file_path": "docs/status.md", + "content": "# Status\n\nThe new state is complete.\n" + }, + "tool_response": { + "originalFile": "# Status\n\nThe old state is pending.\n", + "structuredPatch": { + "hunks": [{ + "oldStart": 1, + "oldCount": 3, + "newStart": 1, + "newCount": 3, + "lines": [ + " # Status", + " ", + "-The old state is pending.", + "+The new state is complete." + ] + }] + } + } + }) +} + +fn parsed_claude_model_id(event: &Value) -> Option { + match parse_diff_trace_payload(&event.to_string()) + .expect("Claude PostToolUse diff-trace payload should parse") + { + DiffTraceParseResult::Persist(payload) => payload.model_id, + DiffTraceParseResult::NoOp(message) => { + panic!("Claude Write payload should persist, got no-op: {message}") + } + } +} + +fn parsed_claude_diff_trace(event: &Value) -> DiffTracePayload { + match parse_diff_trace_payload(&event.to_string()) + .expect("Claude PostToolUse diff-trace payload should parse") + { + DiffTraceParseResult::Persist(payload) => payload, + DiffTraceParseResult::NoOp(message) => { + panic!("Claude Write payload should persist, got no-op: {message}") + } + } +} + +fn unique_attribution_db_path(label: &str) -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after Unix epoch") + .as_nanos(); + std::env::temp_dir() + .join(format!("sce-claude-model-attribution-{label}-{suffix}")) + .join("agent-trace.db") +} + +fn resolved_claude_model_id_with(event: &Value, transcript_lookup: F) -> Option +where + F: FnOnce(&Path, &str) -> Option, +{ + resolve_claude_model_id_with( + event.as_object().expect("test event should be an object"), + transcript_lookup, + ) +} + +fn run_attribution_git(repo_root: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(repo_root) + .output() + .expect("git should start"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +fn init_attribution_git_repo(label: &str) -> PathBuf { + let repo_root = unique_attribution_db_path(label) + .parent() + .expect("test repository should have a parent") + .to_path_buf(); + fs::create_dir_all(&repo_root).expect("test repository directory should be created"); + run_attribution_git(&repo_root, &["init", "-q"]); + run_attribution_git( + &repo_root, + &["remote", "add", "origin", "git@github.com:acme/widgets.git"], + ); + repo_root +} + +fn model_less_claude_diff_event( + session_id: &str, + tool_use_id: &str, + agent_id: Option<&str>, +) -> Value { + let mut event = claude_model_test_event(Path::new("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/virtual/missing.jsonl"), tool_use_id); + let object = event + .as_object_mut() + .expect("Claude test event should be an object"); + object.insert("session_id".to_string(), json!(session_id)); + object.remove("transcript_path"); + object.remove("tool_use_id"); + if let Some(agent_id) = agent_id { + object.insert("agent_id".to_string(), json!(agent_id)); + } + event +} + +fn persisted_model_ids(db: &RepositoryAgentTraceDb) -> Vec> { + db.query_map( + "SELECT model_id FROM diff_traces ORDER BY id ASC", + (), + |row| row.get::>(0).map_err(Into::into), + ) + .expect("persisted model IDs should be readable") +} + +#[test] +fn claude_model_direct_nested_metadata_wins_over_transcript_without_double_prefixing() { + let transcript_path = Path::new("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/unused/direct-precedence.jsonl"); + let mut event = claude_model_test_event(transcript_path, "tool-123"); + event + .as_object_mut() + .expect("test event should be an object") + .insert("model".to_string(), json!({ "id": "claude/direct-model" })); + + let model_id = resolved_claude_model_id_with(&event, |_, _| { + panic!("transcript lookup must not run when direct metadata is present") + }); + + assert_eq!(model_id.as_deref(), Some("claude/direct-model")); + assert_eq!(parsed_claude_model_id(&event), model_id); +} + +#[test] +fn claude_model_falls_back_to_matching_transcript_and_normalizes_model() { + let transcript_path = Path::new("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/virtual/transcript-fallback.jsonl"); + let event = claude_model_test_event(transcript_path, "tool-123"); + + let model_id = resolved_claude_model_id_with(&event, |path, tool_use_id| { + assert_eq!(path, transcript_path); + assert_eq!(tool_use_id, "tool-123"); + Some(String::from("claude/claude-opus-4-1")) + }); + + assert_eq!(model_id.as_deref(), Some("claude/claude-opus-4-1")); +} + +#[test] +fn claude_model_remains_none_when_transcript_lookup_cannot_succeed() { + let event = claude_model_test_event(Path::new("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/virtual/missing.jsonl"), "tool-123"); + assert_eq!(resolved_claude_model_id_with(&event, |_, _| None), None); + + let mut event_without_lookup_fields = event; + let payload = event_without_lookup_fields + .as_object_mut() + .expect("test event should be an object"); + payload.remove("transcript_path"); + payload.remove("tool_use_id"); + assert_eq!( + resolved_claude_model_id_with(&event_without_lookup_fields, |_, _| { + panic!("lookup must not run without transcript event metadata") + }), + None + ); +} + +#[test] +fn claude_diff_trace_parser_keeps_agent_id_ephemeral_and_storage_free() { + let mut event = claude_model_test_event(Path::new("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/virtual/missing.jsonl"), "tool-123"); + event + .as_object_mut() + .expect("test event should be an object") + .insert("agent_id".to_string(), json!(" agent-1 ")); + + let payload = parsed_claude_diff_trace(&event); + + assert_eq!(payload.agent_id.as_deref(), Some("agent-1")); + assert!(serde_json::to_value(&payload) + .expect("internal payload should serialize") + .get("agent_id") + .is_none()); +} + +#[test] +fn claude_diff_trace_parser_keeps_transcript_path_ephemeral_and_storage_free() { + let transcript_path = Path::new("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/virtual/session-123.jsonl"); + let event = claude_model_test_event(transcript_path, "tool-123"); + + let payload = parsed_claude_diff_trace(&event); + + assert_eq!( + payload.transcript_path.as_deref(), + Some("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/virtual/session-123.jsonl") + ); + assert!(serde_json::to_value(&payload) + .expect("internal payload should serialize") + .get("transcript_path") + .is_none()); +} + +#[test] +fn claude_diff_trace_parser_leaves_transcript_path_none_without_the_field() { + let mut event = claude_model_test_event(Path::new("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/virtual/missing.jsonl"), "tool-123"); + event + .as_object_mut() + .expect("test event should be an object") + .remove("transcript_path"); + + assert_eq!(parsed_claude_diff_trace(&event).transcript_path, None); +} + +#[test] +fn claude_diff_trace_normalized_opencode_payload_carries_no_transcript_path() { + let stdin_payload = serde_json::json!({ + "sessionID": "session-123", + "diff": "diff text", + "time": 1_800_000_000_000_u64, + "model_id": "anthropic/claude-opus-4", + "tool_name": "opencode", + "tool_version": null + }) + .to_string(); + + let parsed = parse_diff_trace_payload(&stdin_payload) + .expect("normalized OpenCode diff-trace payload should parse"); + let payload = match parsed { + DiffTraceParseResult::Persist(payload) => payload, + DiffTraceParseResult::NoOp(message) => { + panic!("normalized OpenCode payload should persist, got no-op: {message}") + } + }; + + assert_eq!(payload.transcript_path, None); +} + +#[test] +#[allow(clippy::too_many_lines)] +fn claude_model_attribution_end_to_end_persists_lifecycle_fallback_precedence_and_scope() { + let repo_root = init_attribution_git_repo("end-to-end"); + let state_root = unique_attribution_db_path("end-to-end-state") + .parent() + .expect("test state should have a parent") + .to_path_buf(); + let storage = resolve_agent_trace_storage_at_state_root( + &AgentTraceStorageContext { + repository_root: &repo_root, + explicit_repository_id: None, + repository_remote: "origin", + }, + &state_root, + ) + .expect("setup path should initialize the test repository DB"); + drop(storage); + + let session_start = json!({ + "hook_event_name": "SessionStart", + "session_id": "session-123", + "model": "model-a", + "source": "startup" + }); + assert_eq!( + claude_model_state::run_claude_model_state_from_payload_at_state_root( + &repo_root, + &state_root, + &session_start.to_string(), + None, + || Ok(10), + ), + "" + ); + + let db = open_agent_trace_db_for_hook_runtime_at_state_root( + &repo_root, + &state_root, + "test DB should open after SessionStart", + ) + .expect("test DB should open after SessionStart"); + assert_eq!( + db.claude_model_state_by_session_and_agent("cc_session-123", "") + .expect("SessionStart state should be readable") + .expect("SessionStart should seed state") + .model_id, + "claude/model-a" + ); + let session_start_event = model_less_claude_diff_event("session-123", "tool-a", None); + persist_diff_trace_payload_to_agent_trace_db_with_db( + &db, + &parsed_claude_diff_trace(&session_start_event), + ) + .expect("SessionStart state should attribute the next diff trace"); + drop(db); + + let post_model_switch = json!({ + "hook_event_name": "PostModelSwitch", + "session_id": "session-123", + "from_model": "model-a", + "to_model": "model-b", + "source": "picker" + }); + assert_eq!( + claude_model_state::run_claude_model_state_from_payload_at_state_root( + &repo_root, + &state_root, + &post_model_switch.to_string(), + None, + || Ok(20), + ), + "" + ); + + let db = open_agent_trace_db_for_hook_runtime_at_state_root( + &repo_root, + &state_root, + "test DB should open after PostModelSwitch", + ) + .expect("test DB should open after PostModelSwitch"); + assert_eq!( + db.claude_model_state_by_session_and_agent("cc_session-123", "") + .expect("PostModelSwitch state should be readable") + .expect("PostModelSwitch should update state") + .model_id, + "claude/model-b" + ); + let switched_event = model_less_claude_diff_event("session-123", "tool-b", None); + persist_diff_trace_payload_to_agent_trace_db_with_db( + &db, + &parsed_claude_diff_trace(&switched_event), + ) + .expect("PostModelSwitch state should attribute the next diff trace"); + + let mut direct_event = model_less_claude_diff_event("session-123", "tool-direct", None); + direct_event + .as_object_mut() + .expect("Claude test event should be an object") + .insert("model".to_string(), json!("model-c")); + persist_diff_trace_payload_to_agent_trace_db_with_db( + &db, + &parsed_claude_diff_trace(&direct_event), + ) + .expect("direct model attribution should persist"); + + let transcript_path = state_root.join("transcript.jsonl"); + fs::write( + &transcript_path, + concat!( + r#"{"type":"assistant","message":{"role":"assistant","model":"model-c","content":[{"type":"tool_use","id":"tool-transcript"}]}}"#, + "\n" + ), + ) + .expect("transcript fixture should be written"); + let transcript_event = claude_model_test_event(&transcript_path, "tool-transcript"); + persist_diff_trace_payload_to_agent_trace_db_with_db( + &db, + &parsed_claude_diff_trace(&transcript_event), + ) + .expect("transcript model attribution should persist"); + + let no_state_event = model_less_claude_diff_event("session-without-state", "tool-none", None); + persist_diff_trace_payload_to_agent_trace_db_with_db( + &db, + &parsed_claude_diff_trace(&no_state_event), + ) + .expect("an attribution-less diff trace should still persist"); + + let subagent_event = + model_less_claude_diff_event("session-123", "tool-subagent", Some("subagent-1")); + persist_diff_trace_payload_to_agent_trace_db_with_db( + &db, + &parsed_claude_diff_trace(&subagent_event), + ) + .expect("a subagent diff trace should persist"); + + assert_eq!( + persisted_model_ids(&db), + vec![ + Some(String::from("claude/model-a")), + Some(String::from("claude/model-b")), + Some(String::from("claude/model-c")), + Some(String::from("claude/model-c")), + None, + None, + ] + ); + + drop(db); + fs::remove_file(transcript_path).expect("transcript fixture should be removed"); + fs::remove_dir_all(repo_root).expect("test repository should be removed"); + fs::remove_dir_all(state_root).expect("test state should be removed"); +} + +#[test] +fn claude_model_attribution_bridge_inheritance_seeds_state_and_diff_trace() { + let repo_root = init_attribution_git_repo("bridge-inheritance"); + let state_root = unique_attribution_db_path("bridge-inheritance-state") + .parent() + .expect("test state should have a parent") + .to_path_buf(); + let storage = resolve_agent_trace_storage_at_state_root( + &AgentTraceStorageContext { + repository_root: &repo_root, + explicit_repository_id: None, + repository_remote: "origin", + }, + &state_root, + ) + .expect("setup path should initialize the test repository DB"); + drop(storage); + + let sibling_transcript = state_root.join("session-old.jsonl"); + let current_transcript = state_root.join("session-current.jsonl"); + fs::write( + &sibling_transcript, + concat!( + r#"{"type":"file-history-snapshot"}"#, + "\n", + r#"{"type":"bridge-session","sessionId":"session-old","bridgeSessionId":"cse_shared"}"#, + "\n", + ), + ) + .expect("sibling transcript fixture should be written"); + fs::write( + ¤t_transcript, + concat!( + r#"{"type":"file-history-snapshot"}"#, + "\n", + r#"{"type":"bridge-session","sessionId":"session-current","bridgeSessionId":"cse_shared"}"#, + "\n", + ), + ) + .expect("current transcript fixture should be written"); + + let db = open_agent_trace_db_for_hook_runtime_at_state_root( + &repo_root, + &state_root, + "test DB should open before bridge inheritance", + ) + .expect("test DB should open before bridge inheritance"); + db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: String::from("cc_session-old"), + agent_id: String::new(), + model_id: String::from("claude/inherited-model"), + observation_kind: ObservationKind::SessionStart, + source: String::from("startup"), + observed_at_ms: 5, + }) + .expect("sibling state should be seeded"); + drop(db); + + let session_start = json!({ + "hook_event_name": "SessionStart", + "session_id": "session-current", + "source": "clear", + "transcript_path": current_transcript, + }); + assert_eq!( + claude_model_state::run_claude_model_state_from_payload_at_state_root( + &repo_root, + &state_root, + &session_start.to_string(), + None, + || Ok(10), + ), + "" + ); + + let db = open_agent_trace_db_for_hook_runtime_at_state_root( + &repo_root, + &state_root, + "test DB should open after bridge inheritance", + ) + .expect("test DB should open after bridge inheritance"); + let inherited = db + .claude_model_state_by_session_and_agent("cc_session-current", "") + .expect("inherited state lookup should succeed") + .expect("current session should inherit sibling state"); + assert_eq!(inherited.model_id, "claude/inherited-model"); + assert_eq!(inherited.source, "bridge_inherited"); + assert_eq!(inherited.observation_kind, ObservationKind::SessionStart); + assert_eq!(inherited.observed_at_ms, 10); + + let diff_event = model_less_claude_diff_event("session-current", "tool-inherited", None); + persist_diff_trace_payload_to_agent_trace_db_with_db( + &db, + &parsed_claude_diff_trace(&diff_event), + ) + .expect("inherited state should attribute the diff trace"); + assert_eq!( + persisted_model_ids(&db), + vec![Some(String::from("claude/inherited-model"))] + ); + + drop(db); + fs::remove_file(sibling_transcript).expect("sibling transcript should be removed"); + fs::remove_file(current_transcript).expect("current transcript should be removed"); + fs::remove_dir_all(repo_root).expect("test repository should be removed"); + fs::remove_dir_all(state_root).expect("test state should be removed"); +} + +#[test] +fn claude_diff_trace_persistence_uses_state_only_after_direct_and_transcript() { + let db_path = unique_attribution_db_path("precedence"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); + db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: String::from("cc_session-123"), + agent_id: String::new(), + model_id: String::from("claude/state-model"), + observation_kind: ObservationKind::SessionStart, + source: String::from("startup"), + observed_at_ms: 1, + }) + .expect("state should be seeded"); + + let mut state_event = claude_model_test_event(Path::new("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/virtual/missing.jsonl"), "state"); + let state_object = state_event + .as_object_mut() + .expect("test event should be an object"); + state_object.remove("transcript_path"); + state_object.remove("tool_use_id"); + let state_payload = parsed_claude_diff_trace(&state_event); + persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &state_payload) + .expect("state fallback should persist"); + + let mut direct_event = state_event.clone(); + direct_event + .as_object_mut() + .expect("test event should be an object") + .insert("model".to_string(), json!("direct-model")); + let direct_payload = parsed_claude_diff_trace(&direct_event); + persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &direct_payload) + .expect("direct attribution should persist"); + + let transcript_path = db_path.with_extension("jsonl"); + fs::write( + &transcript_path, + concat!( + r#"{"type":"assistant","message":{"role":"assistant","model":"transcript-model","content":[{"type":"tool_use","id":"transcript"}]}}"#, + "\n" + ), + ) + .expect("transcript fixture should be written"); + let transcript_event = claude_model_test_event(&transcript_path, "transcript"); + let transcript_payload = parsed_claude_diff_trace(&transcript_event); + persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &transcript_payload) + .expect("transcript attribution should persist"); + + let models = db + .query_map( + "SELECT model_id FROM diff_traces ORDER BY id ASC", + (), + |row| row.get::>(0).map_err(Into::into), + ) + .expect("persisted models should be readable"); + assert_eq!( + models, + vec![ + Some(String::from("claude/state-model")), + Some(String::from("claude/direct-model")), + Some(String::from("claude/transcript-model")), + ] + ); + + drop(db); + fs::remove_file(transcript_path).expect("transcript fixture should be removed"); + fs::remove_dir_all(db_path.parent().expect("test DB should have a parent")) + .expect("test DB directory should be removed"); +} + +#[test] +fn normalized_claude_tool_name_does_not_use_claude_state_fallback() { + let db_path = unique_attribution_db_path("normalized-claude"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); + db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: String::from("cc_session-123"), + agent_id: String::new(), + model_id: String::from("claude/parent-model"), + observation_kind: ObservationKind::SessionStart, + source: String::from("startup"), + observed_at_ms: 1, + }) + .expect("parent state should be seeded"); + + let payload = diff_trace_payload_with( + CLAUDE_TOOL_NAME, + "session-123", + PAYLOAD_TYPE_PATCH, + None, + None, + ); + persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &payload) + .expect("normalized Claude payload should persist"); + + let model = db + .query_map("SELECT model_id FROM diff_traces LIMIT 1", (), |row| { + row.get::>(0).map_err(Into::into) + }) + .expect("persisted model should be readable") + .into_iter() + .next() + .expect("diff trace row should exist"); + assert_eq!(model, None); + + drop(db); + fs::remove_dir_all(db_path.parent().expect("test DB should have a parent")) + .expect("test DB directory should be removed"); +} + +#[test] +fn claude_diff_trace_state_lookup_isolated_to_exact_subagent_scope() { + let db_path = unique_attribution_db_path("subagent"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); + db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: String::from("cc_session-123"), + agent_id: String::new(), + model_id: String::from("claude/parent-model"), + observation_kind: ObservationKind::SessionStart, + source: String::from("startup"), + observed_at_ms: 1, + }) + .expect("parent state should be seeded"); + + let mut event = claude_model_test_event(Path::new("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/virtual/missing.jsonl"), "subagent"); + let event_object = event + .as_object_mut() + .expect("test event should be an object"); + event_object.remove("transcript_path"); + event_object.remove("tool_use_id"); + event_object.insert("agent_id".to_string(), json!("subagent-1")); + let payload = parsed_claude_diff_trace(&event); + persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &payload) + .expect("subagent diff trace should persist"); + + let model = db + .query_map("SELECT model_id FROM diff_traces LIMIT 1", (), |row| { + row.get::>(0).map_err(Into::into) + }) + .expect("persisted model should be readable") + .into_iter() + .next() + .expect("diff trace row should exist"); + assert_eq!(model, None); + + drop(db); + fs::remove_dir_all(db_path.parent().expect("test DB should have a parent")) + .expect("test DB directory should be removed"); +} + +fn write_bridge_transcript(path: &Path, bridge_session_id: &str) { + fs::write( + path, + format!( + concat!( + "{{\"type\":\"file-history-snapshot\"}}\n", + "{{\"type\":\"bridge-session\",\"sessionId\":\"s\",", + "\"bridgeSessionId\":\"{bridge_session_id}\"}}\n" + ), + bridge_session_id = bridge_session_id, + ), + ) + .expect("bridge transcript fixture should be written"); +} + +#[test] +fn claude_diff_trace_seeds_bridge_chain_state_on_state_miss_and_reuses_it() { + let db_path = unique_attribution_db_path("bridge-chain-seed"); + let dir = db_path + .parent() + .expect("test DB should have a parent") + .to_path_buf(); + fs::create_dir_all(&dir).expect("test DB directory should be created"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); + + db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: String::from("cc_session-member"), + agent_id: String::new(), + model_id: String::from("claude/chain-model"), + observation_kind: ObservationKind::SessionStart, + source: String::from("startup"), + observed_at_ms: 100, + }) + .expect("chain member state should seed"); + + let current_transcript = dir.join("session-current.jsonl"); + let member_transcript = dir.join("session-member.jsonl"); + write_bridge_transcript(¤t_transcript, "cse_chain"); + write_bridge_transcript(&member_transcript, "cse_chain"); + + let payload = parsed_claude_diff_trace(&claude_model_test_event(¤t_transcript, "a")); + persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &payload) + .expect("bridge chain seeding should persist"); + + assert_eq!( + persisted_model_ids(&db), + vec![Some(String::from("claude/chain-model"))] + ); + let seeded = db + .claude_model_state_by_session_and_agent("cc_session-123", "") + .expect("seeded lookup should succeed") + .expect("current session should be seeded"); + assert_eq!(seeded.model_id, "claude/chain-model"); + assert_eq!(seeded.source, "bridge_inherited"); + + fs::remove_file(&member_transcript).expect("member transcript should be removed"); + let payload_two = parsed_claude_diff_trace(&claude_model_test_event(¤t_transcript, "b")); + persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &payload_two) + .expect("second diff trace should persist"); + assert_eq!( + persisted_model_ids(&db), + vec![ + Some(String::from("claude/chain-model")), + Some(String::from("claude/chain-model")), + ] + ); + let after = db + .claude_model_state_by_session_and_agent("cc_session-123", "") + .expect("lookup should succeed") + .expect("row should still exist"); + assert_eq!(after.observed_at_ms, seeded.observed_at_ms); + + drop(db); + fs::remove_dir_all(&dir).expect("test DB directory should be removed"); +} + +#[test] +fn claude_diff_trace_bridge_chain_selects_newest_observation_across_members() { + let db_path = unique_attribution_db_path("bridge-chain-newest"); + let dir = db_path + .parent() + .expect("test DB should have a parent") + .to_path_buf(); + fs::create_dir_all(&dir).expect("test DB directory should be created"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); + + db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: String::from("cc_session-root"), + agent_id: String::new(), + model_id: String::from("claude/sonnet-5"), + observation_kind: ObservationKind::SessionStart, + source: String::from("startup"), + observed_at_ms: 10, + }) + .expect("root state should seed"); + db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: String::from("cc_session-mid"), + agent_id: String::new(), + model_id: String::from("claude/opus-5"), + observation_kind: ObservationKind::PostModelSwitch, + source: String::from("picker"), + observed_at_ms: 20, + }) + .expect("mid state should seed"); + + let current_transcript = dir.join("session-current.jsonl"); + let root_transcript = dir.join("session-root.jsonl"); + let mid_transcript = dir.join("session-mid.jsonl"); + write_bridge_transcript(&mid_transcript, "cse_chain"); + write_bridge_transcript(¤t_transcript, "cse_chain"); + thread::sleep(Duration::from_millis(15)); + write_bridge_transcript(&root_transcript, "cse_chain"); + + let payload = parsed_claude_diff_trace(&claude_model_test_event(¤t_transcript, "x")); + persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &payload) + .expect("newest-observation resolution should persist"); + + assert_eq!( + persisted_model_ids(&db), + vec![Some(String::from("claude/opus-5"))] + ); + + drop(db); + fs::remove_dir_all(&dir).expect("test DB directory should be removed"); +} + +#[test] +fn claude_diff_trace_bridge_chain_fails_open_without_write_or_attribution() { + let db_path = unique_attribution_db_path("bridge-chain-fail-open"); + let dir = db_path + .parent() + .expect("test DB should have a parent") + .to_path_buf(); + fs::create_dir_all(&dir).expect("test DB directory should be created"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); + + let mut event = claude_model_test_event(Path::new("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/virtual/missing.jsonl"), "no-transcript"); + event + .as_object_mut() + .expect("event should be an object") + .remove("transcript_path"); + persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &parsed_claude_diff_trace(&event)) + .expect("missing transcript should fail open"); + + let current_transcript = dir.join("session-current.jsonl"); + let member_transcript = dir.join("session-member.jsonl"); + write_bridge_transcript(¤t_transcript, "cse_chain"); + write_bridge_transcript(&member_transcript, "cse_chain"); + persist_diff_trace_payload_to_agent_trace_db_with_db( + &db, + &parsed_claude_diff_trace(&claude_model_test_event(¤t_transcript, "no-state")), + ) + .expect("stateless chain should fail open"); + + assert_eq!(persisted_model_ids(&db), vec![None, None]); + assert!( + db.claude_model_state_by_session_and_agent("cc_session-123", "") + .expect("lookup should succeed") + .is_none(), + "no state row should be written on a fail-open branch" + ); + + drop(db); + fs::remove_dir_all(&dir).expect("test DB directory should be removed"); +} + +#[test] +fn claude_diff_trace_bridge_chain_does_not_seed_subagent_scope() { + let db_path = unique_attribution_db_path("bridge-chain-subagent"); + let dir = db_path + .parent() + .expect("test DB should have a parent") + .to_path_buf(); + fs::create_dir_all(&dir).expect("test DB directory should be created"); + let db = RepositoryAgentTraceDb::new_at(&db_path).expect("test DB should open"); + + db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: String::from("cc_session-member"), + agent_id: String::new(), + model_id: String::from("claude/chain-model"), + observation_kind: ObservationKind::SessionStart, + source: String::from("startup"), + observed_at_ms: 100, + }) + .expect("chain member state should seed"); + + let current_transcript = dir.join("session-current.jsonl"); + let member_transcript = dir.join("session-member.jsonl"); + write_bridge_transcript(¤t_transcript, "cse_chain"); + write_bridge_transcript(&member_transcript, "cse_chain"); + + let mut event = claude_model_test_event(¤t_transcript, "subagent"); + event + .as_object_mut() + .expect("event should be an object") + .insert("agent_id".to_string(), json!("subagent-1")); + persist_diff_trace_payload_to_agent_trace_db_with_db(&db, &parsed_claude_diff_trace(&event)) + .expect("subagent diff trace should persist"); + + assert_eq!(persisted_model_ids(&db), vec![None]); + assert!( + db.claude_model_state_by_session_and_agent("cc_session-123", "subagent-1") + .expect("lookup should succeed") + .is_none(), + "subagent scope must not inherit main-session chain state" + ); + + drop(db); + fs::remove_dir_all(&dir).expect("test DB directory should be removed"); +} + +#[test] +fn prefixed_diff_trace_session_id_prefixes_fresh_pi_session_id() { + assert_eq!( + prefixed_diff_trace_session_id("pi", "session-123"), + "pi_session-123" + ); +} + +#[test] +fn prefixed_diff_trace_session_id_keeps_already_prefixed_pi_session_id() { + assert_eq!( + prefixed_diff_trace_session_id("pi", "pi_session-123"), + "pi_session-123" + ); +} + +#[test] +fn prefixed_diff_trace_session_id_prefixes_fresh_codex_session_id() { + assert_eq!( + prefixed_diff_trace_session_id("codex", "session-123"), + "cx_session-123" + ); +} + +#[test] +fn prefixed_diff_trace_session_id_keeps_already_prefixed_codex_session_id() { + assert_eq!( + prefixed_diff_trace_session_id("codex", "cx_session-123"), + "cx_session-123" + ); +} + +#[test] +fn prefixed_diff_trace_session_id_adding_codex_does_not_affect_other_tool_prefixes() { + assert_eq!( + prefixed_diff_trace_session_id("opencode", "session-123"), + "oc_session-123" + ); + assert_eq!( + prefixed_diff_trace_session_id("claude", "session-123"), + "cc_session-123" + ); + assert_eq!( + prefixed_diff_trace_session_id("pi", "session-123"), + "pi_session-123" + ); +} + +#[test] +fn normalize_codex_model_id_preserves_fresh_model_id() { + assert_eq!( + normalize_codex_model_id("gpt-5.6-codex").as_deref(), + Some("gpt-5.6-codex") + ); +} + +#[test] +fn normalize_codex_model_id_preserves_qualified_model_ids() { + for model in ["openai/gpt-x", "qualified/custom-provider/model"] { + assert_eq!(normalize_codex_model_id(model).as_deref(), Some(model)); + } +} + +#[test] +fn normalize_codex_model_id_preserves_unqualified_model_ids() { + assert_eq!( + normalize_codex_model_id("custom-codex-model").as_deref(), + Some("custom-codex-model") + ); +} + +#[test] +fn normalize_codex_model_id_returns_none_for_blank_model_ids() { + assert_eq!(normalize_codex_model_id(" "), None); +} + +#[test] +fn normalize_opencode_model_id_preserves_qualified_and_unqualified_ids() { + for model in [ + "opencode/big-pickle", + "anthropic/claude-sonnet-4", + "custom-model", + ] { + assert_eq!(normalize_opencode_model_id(model).as_deref(), Some(model)); + } + assert_eq!( + normalize_opencode_model_id(" opencode/big-pickle ").as_deref(), + Some("opencode/big-pickle") + ); +} + +#[test] +fn normalize_opencode_model_id_returns_none_for_blank_model_ids() { + assert_eq!(normalize_opencode_model_id(""), None); + assert_eq!(normalize_opencode_model_id(" "), None); +} + +#[test] +fn pi_normalized_diff_trace_payload_persists_with_pi_prefixed_session_id() { + let stdin_payload = serde_json::json!({ + "sessionID": "session-123", + "diff": "diff text", + "time": 1_800_000_000_000_u64, + "model_id": "anthropic/claude-opus-4", + "tool_name": "pi", + "tool_version": null + }) + .to_string(); + + let parsed = parse_diff_trace_payload(&stdin_payload) + .expect("normalized Pi diff-trace payload should parse"); + let payload = match parsed { + DiffTraceParseResult::Persist(payload) => payload, + DiffTraceParseResult::NoOp(message) => { + panic!("Pi payload should persist, got no-op: {message}") + } + }; + + assert_eq!(payload.tool_name, "pi"); + assert_eq!(payload.model_id.as_deref(), Some("anthropic/claude-opus-4")); + assert_eq!(payload.tool_version, None); + + persist_diff_trace_payload_to_agent_trace_db_with( + &payload, + payload.model_id.as_deref(), + payload.tool_version.as_deref(), + |input| { + assert_eq!(input.time_ms, 1_800_000_000_000_i64); + assert_eq!(input.session_id, "pi_session-123"); + assert_eq!(input.model_id, Some("anthropic/claude-opus-4")); + assert_eq!(input.tool_name, "pi"); + assert_eq!(input.tool_version, None); + assert_eq!(input.payload_type, PAYLOAD_TYPE_PATCH); + + Ok(()) + }, + ) + .expect("Pi diff-trace payload should be persisted"); +} + +#[test] +fn post_commit_intersection_flow_preserves_pi_provenance() { + let now_ms = 1_800_000_000_000_i64; + let commit_time_ms = now_ms - 1_000; + + let output = run_post_commit_intersection_flow_with( + Path::new("/repo"), + |_| { + Ok(PostCommitPatchData { + commit_oid: String::from("def456"), + commit_time_ms, + parsed_patch: valid_patch("src/lib.rs", "shared line"), + }) + }, + || Ok(now_ms), + |_, _| { + Ok(RecentDiffTracePatches { + patches: vec![ParsedDiffTracePatch { + id: 9, + time_ms: now_ms - 500, + session_id: String::from("pi_valid-session"), + patch: valid_patch("src/lib.rs", "shared line"), + tool_name: Some(String::from("pi")), + tool_version: None, + payload_type: String::from(PAYLOAD_TYPE_PATCH), + }], + skipped: vec![], + }) + }, + |_| Ok(()), + ) + .expect("post-commit intersection flow should succeed"); + + assert_eq!(output.combined_recent_patch.files.len(), 1); + assert_eq!(output.tool_name, Some(String::from("pi"))); + assert_eq!(output.tool_version, None); +} + +#[test] +fn diff_trace_db_persistence_uses_direct_payload_model_and_tool_version() { + let payload = diff_trace_payload(Some("direct-model"), None); + + persist_diff_trace_payload_to_agent_trace_db_with( + &payload, + Some("direct-model"), + Some("Claude Code 1.2.3"), + |input| { + assert_eq!(input.time_ms, 1_800_000_000_000_i64); + assert_eq!(input.session_id, "cc_session-123"); + assert_eq!(input.model_id, Some("direct-model")); + assert_eq!(input.tool_name, "claude"); + assert_eq!(input.tool_version, Some("Claude Code 1.2.3")); + assert_eq!(input.payload_type, PAYLOAD_TYPE_STRUCTURED); + + Ok(()) + }, + ) + .expect("direct diff-trace attribution should be persisted"); +} + +#[test] +fn post_commit_intersection_flow_uses_same_window_end_for_query_and_persistence() { + let now_ms = 1_800_000_000_000_i64; + let commit_time_ms = now_ms - 1_000; + let expected_cutoff_ms = now_ms - RECENT_DAYS_MILLIS; + let query_window = RefCell::new(None); + let persisted = RefCell::new(None); + + let output = run_post_commit_intersection_flow_with( + Path::new("/repo"), + |_| { + Ok(PostCommitPatchData { + commit_oid: String::from("abc123"), + commit_time_ms, + parsed_patch: valid_patch("src/lib.rs", "shared line"), + }) + }, + || Ok(now_ms), + |cutoff_ms, end_ms| { + *query_window.borrow_mut() = Some((cutoff_ms, end_ms)); + + Ok(RecentDiffTracePatches { + patches: vec![ParsedDiffTracePatch { + id: 7, + time_ms: now_ms - 500, + session_id: String::from("oc_valid-session"), + patch: valid_patch("src/lib.rs", "shared line"), + tool_name: Some(String::from("opencode")), + tool_version: Some(String::from("1.2.3")), + payload_type: String::from(PAYLOAD_TYPE_PATCH), + }], + skipped: vec![SkippedDiffTracePatch { + id: 8, + time_ms: now_ms - 250, + session_id: String::from("oc_malformed-session"), + reason: String::from("invalid hunk header"), + }], + }) + }, + |insert_input| { + *persisted.borrow_mut() = Some(CapturedPostCommitIntersectionInsert { + commit_id: insert_input.commit_id.to_string(), + post_commit_time_ms: insert_input.post_commit_time_ms, + recent_window_cutoff_ms: insert_input.recent_window_cutoff_ms, + recent_window_end_ms: insert_input.recent_window_end_ms, + loaded_diff_trace_count: insert_input.loaded_diff_trace_count, + skipped_diff_trace_count: insert_input.skipped_diff_trace_count, + intersection_patch: insert_input.intersection_patch.to_string(), + }); + + Ok(()) + }, + ) + .expect("post-commit intersection flow should succeed"); + + assert_eq!( + query_window.into_inner(), + Some((expected_cutoff_ms, now_ms)) + ); + + let persisted = persisted + .into_inner() + .expect("intersection row should be persisted"); + assert_eq!(persisted.commit_id, "abc123"); + assert_eq!(persisted.post_commit_time_ms, commit_time_ms); + assert_eq!(persisted.recent_window_cutoff_ms, expected_cutoff_ms); + assert_eq!(persisted.recent_window_end_ms, now_ms); + assert_eq!(persisted.loaded_diff_trace_count, 1); + assert_eq!(persisted.skipped_diff_trace_count, 1); + + let intersection: ParsedPatch = serde_json::from_str(&persisted.intersection_patch) + .expect("persisted intersection patch should deserialize"); + assert_eq!(intersection.files.len(), 1); + assert_eq!(intersection.files[0].new_path, "src/lib.rs"); + assert_eq!(intersection.files[0].hunks[0].lines.len(), 1); + assert_eq!( + intersection.files[0].hunks[0].lines[0].content, + "shared line" + ); + + assert_eq!(output.post_commit_data.commit_oid, "abc123"); + assert_eq!(output.post_commit_data.commit_time_ms, commit_time_ms); + assert_eq!(output.combined_recent_patch.files.len(), 1); + assert_eq!(output.combined_recent_patch.files[0].new_path, "src/lib.rs"); + assert_eq!(output.tool_name, Some(String::from("opencode"))); + assert_eq!(output.tool_version, Some(String::from("1.2.3"))); +} + +fn post_commit_flow_result() -> PostCommitIntersectionFlowResult { + PostCommitIntersectionFlowResult { + combined_recent_patch: valid_patch("src/lib.rs", "shared line"), + post_commit_data: PostCommitPatchData { + commit_oid: String::from("abc123"), + commit_time_ms: 1_800_000_000_000, + parsed_patch: valid_patch("src/lib.rs", "shared line"), + }, + tool_name: None, + tool_version: None, + } +} + +fn minimal_agent_trace() -> AgentTrace { + serde_json::from_value(json!({ "files": [] })).expect("minimal Agent Trace should deserialize") +} + +#[test] +fn post_commit_auto_sync_launches_after_successful_persistence_when_enabled() { + let events = RefCell::new(Vec::new()); + + let output = run_post_commit_subcommand_with( + Path::new("/repo"), + None, + "", + |_| { + events.borrow_mut().push("intersection"); + Ok(post_commit_flow_result()) + }, + |_, _, _, _| { + events.borrow_mut().push("persistence"); + Ok(minimal_agent_trace()) + }, + |_| { + events.borrow_mut().push("config"); + Ok(true) + }, + |_| { + events.borrow_mut().push("launch"); + Ok(()) + }, + |_| { + events.borrow_mut().push("checkpoint"); + Ok(()) + }, + None, + ) + .expect("successful post-commit should remain successful"); + + assert!(output.contains("post-commit hook processed intersection")); + assert_eq!( + events.into_inner(), + vec![ + "intersection", + "persistence", + "checkpoint", + "config", + "launch" + ] + ); +} + +#[test] +fn post_commit_validation_failure_does_not_resolve_or_launch_auto_sync() { + let validation_called = RefCell::new(false); + let config_called = RefCell::new(false); + let launch_called = RefCell::new(false); + + let error = run_post_commit_subcommand_with( + Path::new("/repo"), + None, + "", + |_| Ok(post_commit_flow_result()), + |_, flow_result, vcs_type, remote_url| { + run_post_commit_agent_trace_flow_with( + flow_result, + vcs_type, + remote_url, + &ParsedPatch { files: Vec::new() }, + |_| { + *validation_called.borrow_mut() = true; + Err(anyhow!("Agent Trace validation failed")) + }, + |_| panic!("Agent Trace persistence must not run after validation failure"), + ) + }, + |_| { + *config_called.borrow_mut() = true; + Ok(true) + }, + |_| { + *launch_called.borrow_mut() = true; + Ok(()) + }, + |_| panic!("checkpoint must not run after persistence failure"), + None, + ) + .expect_err("validation failure should be returned"); + + assert!(*validation_called.borrow()); + assert!(!error.to_string().is_empty()); + assert!(!*config_called.borrow()); + assert!(!*launch_called.borrow()); +} + +fn post_commit_flow_result_for( + direct: ParsedPatch, + committed: ParsedPatch, +) -> PostCommitIntersectionFlowResult { + PostCommitIntersectionFlowResult { + combined_recent_patch: direct, + post_commit_data: PostCommitPatchData { + commit_oid: String::from("abc123"), + commit_time_ms: 1_800_000_000_000, + parsed_patch: committed, + }, + tool_name: Some(String::from("claude")), + tool_version: Some(String::from("9.9.9")), + } +} + +fn persisted_post_commit_trace( + flow_result: &PostCommitIntersectionFlowResult, + mutation_ai_patch: &ParsedPatch, +) -> Value { + let persisted = RefCell::new(None); + + run_post_commit_agent_trace_flow_with( + flow_result, + Some(AgentTraceVcsType::Git), + "", + mutation_ai_patch, + |_| Ok(()), + |insert| { + *persisted.borrow_mut() = Some(insert.trace_json.to_string()); + Ok(()) + }, + ) + .expect("post-commit Agent Trace flow should build and persist"); + + serde_json::from_str( + persisted + .into_inner() + .expect("trace should have been persisted") + .as_str(), + ) + .expect("persisted trace JSON should parse") +} + +#[test] +fn post_commit_agent_trace_flow_attributes_mutation_only_lines_as_ai_without_provenance() { + let flow_result = post_commit_flow_result_for( + ParsedPatch { files: Vec::new() }, + valid_patch("src/lib.rs", "mutated line"), + ); + let mutation_ai_patch = valid_patch("src/lib.rs", "mutated line"); + + let trace = persisted_post_commit_trace(&flow_result, &mutation_ai_patch); + + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["ai"]["added"], + json!(1) + ); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], + json!(0) + ); + assert!( + trace.get("tool").is_none(), + "mutation-only coverage fabricates no tool provenance" + ); + let contributor = &trace["files"][0]["conversations"][0]["contributor"]; + assert_eq!(contributor["type"], json!("ai")); + assert!( + contributor.get("model_id").is_none(), + "mutation-only coverage carries no model provenance" + ); + assert!( + trace["files"][0]["conversations"][0] + .get("related") + .is_none(), + "mutation-only coverage carries no session provenance" + ); +} + +#[test] +fn post_commit_agent_trace_flow_keeps_direct_provenance_when_direct_covers_the_line() { + let flow_result = post_commit_flow_result_for( + valid_patch("src/lib.rs", "shared line"), + valid_patch("src/lib.rs", "shared line"), + ); + + let trace = persisted_post_commit_trace(&flow_result, &ParsedPatch { files: Vec::new() }); + + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["ai"]["added"], + json!(1) + ); + assert_eq!( + trace["tool"], + json!({ "name": "claude", "version": "9.9.9" }) + ); + assert_eq!( + trace["files"][0]["conversations"][0]["contributor"]["type"], + json!("ai") + ); +} + +#[test] +fn post_commit_agent_trace_flow_with_empty_mutation_patch_leaves_uncovered_lines_unknown() { + let flow_result = post_commit_flow_result_for( + ParsedPatch { files: Vec::new() }, + valid_patch("src/lib.rs", "human line"), + ); + + let trace = persisted_post_commit_trace(&flow_result, &ParsedPatch { files: Vec::new() }); + + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], + json!(1) + ); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["ai"]["added"], + json!(0) + ); + assert!(trace.get("tool").is_none()); + assert_eq!( + trace["files"][0]["conversations"][0]["contributor"]["type"], + json!("unknown") + ); +} + +mod mutation_attribution_e2e { + use super::*; + use crate::services::mutation_trace::runtime::resolve_post_commit_mutation_ai_patch; + use crate::services::mutation_trace::runtime::resolve_worktree_id; + use crate::services::mutation_trace::store::encode_revision; + + fn git(repo: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(repo) + .output() + .expect("git should spawn"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).expect("git output should be UTF-8") + } + + fn commit_all(repo: &Path, message: &str) { + git(repo, &["add", "-A"]); + git( + repo, + &[ + "-c", + "user.name=SCE Test", + "-c", + "user.email=sce@example.invalid", + "commit", + "-qm", + message, + ], + ); + } + + struct E2eRepo { + _temp: tempfile::TempDir, + root: PathBuf, + db_path: PathBuf, + } + + impl E2eRepo { + fn new(label: &str) -> Self { + let temp = tempfile::Builder::new() + .prefix(&format!("sce-mutation-attr-e2e-{label}-")) + .tempdir() + .expect("temp dir should be created"); + let root = temp.path().join("repo"); + fs::create_dir_all(&root).expect("repo dir should be created"); + git(&root, &["init", "-q"]); + git( + &root, + &["remote", "add", "origin", "git@github.com:acme/widgets.git"], + ); + fs::write(root.join("file.rs"), "one\n").expect("seed file should write"); + commit_all(&root, "base"); + let db_path = temp.path().join("agent-trace.db"); + RepositoryAgentTraceDb::new_at(&db_path) + .expect("repository DB should open with schema"); + Self { + _temp: temp, + root, + db_path, + } + } + + fn db(&self) -> RepositoryAgentTraceDb { + RepositoryAgentTraceDb::open_for_hooks_without_migrations_at(&self.db_path) + .expect("repository DB should reopen") + } + + fn head_tree(&self) -> String { + git(&self.root, &["rev-parse", "HEAD^{tree}"]) + .trim() + .to_owned() + } + + fn parent_tree(&self) -> String { + git(&self.root, &["rev-parse", "HEAD~1^{tree}"]) + .trim() + .to_owned() + } + + fn checkout_id(&self) -> String { + resolve_worktree_id(&self.root) + .expect("worktree identity should resolve") + .0 + } + } + + fn seed_event( + db: &RepositoryAgentTraceDb, + worktree_id: &str, + revision: u64, + before_tree: &str, + after_tree: &str, + attribution_kind: &str, + attribution_scope_id: Option<&str>, + ) { + db.execute( + "INSERT INTO mutation_trace_events + (worktree_id, revision, before_tree, after_tree, tainted, failure_kind, + attribution_kind, attribution_scope_id, boundary_kind, boundary_scope_id, + boundary_event_id) + VALUES (?1, ?2, ?3, ?4, 0, 'healthy', ?5, ?6, 'flush', NULL, NULL)", + ( + worktree_id, + encode_revision(revision).as_slice(), + before_tree, + after_tree, + attribution_kind, + attribution_scope_id, + ), + ) + .expect("mutation event insert should succeed"); + } + + fn row_count(db: &RepositoryAgentTraceDb, table: &str) -> i64 { + db.query_map(&format!("SELECT COUNT(*) FROM {table}"), (), |row| { + row.get::(0).map_err(anyhow::Error::from) + }) + .expect("count query should succeed") + .into_iter() + .next() + .expect("count row should exist") + } + + fn touched_line_count(patch: &ParsedPatch) -> usize { + patch + .files + .iter() + .flat_map(|file| file.hunks.iter()) + .map(|hunk| hunk.lines.len()) + .sum() + } + + fn flow_result_for(repo: &E2eRepo, direct: ParsedPatch) -> PostCommitIntersectionFlowResult { + let post_commit_data = capture_post_commit_patch_from_git(&repo.root) + .expect("capturing the post-commit patch should succeed"); + PostCommitIntersectionFlowResult { + combined_recent_patch: direct, + post_commit_data, + tool_name: None, + tool_version: None, + } + } + + fn resolve_mutation_ai( + repo: &E2eRepo, + db: &RepositoryAgentTraceDb, + flow_result: &PostCommitIntersectionFlowResult, + ) -> ParsedPatch { + let direct_intersection = intersect_patches_fn( + &flow_result.combined_recent_patch, + &flow_result.post_commit_data.parsed_patch, + ); + resolve_post_commit_mutation_ai_patch( + &repo.root, + db, + &direct_intersection, + &flow_result.post_commit_data.parsed_patch, + ) + } + + fn persist_trace( + flow_result: &PostCommitIntersectionFlowResult, + db: &RepositoryAgentTraceDb, + mutation_ai_patch: &ParsedPatch, + ) -> Value { + let persisted = RefCell::new(None); + run_post_commit_agent_trace_flow_with( + flow_result, + Some(AgentTraceVcsType::Git), + "git@github.com:acme/widgets.git", + mutation_ai_patch, + |value| validate_agent_trace_value(value).map_err(|error| anyhow!(error.to_string())), + |insert| { + *persisted.borrow_mut() = Some(insert.trace_json.to_string()); + db.insert_agent_trace(insert).map(|_| ()) + }, + ) + .expect("the post-commit Agent Trace flow should build, validate, and persist"); + + serde_json::from_str( + persisted + .into_inner() + .expect("a trace should have been persisted") + .as_str(), + ) + .expect("the persisted trace JSON should parse") + } + + #[test] + fn a_mutation_only_line_persists_as_ai_without_fabricated_provenance() { + let repo = E2eRepo::new("mutation-only"); + fs::write(repo.root.join("file.rs"), "one\ntwo\n").expect("the edit should write"); + commit_all(&repo.root, "add two"); + + let db = repo.db(); + seed_event( + &db, + &repo.checkout_id(), + 1, + &repo.parent_tree(), + &repo.head_tree(), + "ai_exclusive", + Some("scope-x"), + ); + + let flow_result = flow_result_for(&repo, ParsedPatch { files: Vec::new() }); + let mutation_ai_patch = resolve_mutation_ai(&repo, &db, &flow_result); + assert_eq!( + touched_line_count(&mutation_ai_patch), + 1, + "a healthy untainted exclusive event covers the committed line" + ); + + let trace = persist_trace(&flow_result, &db, &mutation_ai_patch); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["ai"]["added"], + json!(1) + ); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], + json!(0) + ); + assert!( + trace.get("tool").is_none(), + "mutation-only coverage fabricates no tool provenance" + ); + let contributor = &trace["files"][0]["conversations"][0]["contributor"]; + assert_eq!(contributor["type"], json!("ai")); + assert!( + contributor.get("model_id").is_none(), + "mutation-only coverage carries no model provenance" + ); + + assert_eq!( + row_count(&db, "diff_traces"), + 0, + "mutation evidence is never inserted into diff_traces" + ); + assert_eq!( + row_count(&db, "post_commit_patch_intersections"), + 0, + "the direct-only intersection table is untouched by this flow" + ); + assert_eq!(row_count(&db, "agent_traces"), 1); + } + + #[test] + fn direct_plus_mutation_evidence_completes_hunk_coverage_and_keeps_direct_provenance() { + let repo = E2eRepo::new("direct-plus-mutation"); + fs::write(repo.root.join("file.rs"), "one\ntwo\nthree\n").expect("the edit should write"); + commit_all(&repo.root, "add two and three"); + + let db = repo.db(); + seed_event( + &db, + &repo.checkout_id(), + 1, + &repo.parent_tree(), + &repo.head_tree(), + "ai_exclusive", + Some("scope-x"), + ); + + let direct = parse_patch_from_text( + "diff --git a/file.rs b/file.rs\n--- a/file.rs\n+++ b/file.rs\n@@ -1,1 +1,2 @@\n one\n+two\n", + None, + ) + .expect("the direct patch should parse"); + let mut flow_result = flow_result_for(&repo, direct); + flow_result.tool_name = Some(String::from("claude")); + flow_result.tool_version = Some(String::from("9.9.9")); + + let mutation_ai_patch = resolve_mutation_ai(&repo, &db, &flow_result); + assert_eq!( + touched_line_count(&mutation_ai_patch), + 1, + "only the line direct evidence did not cover is resolved from mutation history" + ); + + let trace = persist_trace(&flow_result, &db, &mutation_ai_patch); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["ai"]["added"], + json!(2), + "the union of direct and mutation coverage classifies the hunk ai" + ); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], + json!(0) + ); + assert_eq!( + trace["tool"], + json!({ "name": "claude", "version": "9.9.9" }) + ); + } + + #[test] + fn a_newer_nonexclusive_event_keeps_the_line_non_ai() { + let repo = E2eRepo::new("newer-nonexclusive"); + fs::write(repo.root.join("file.rs"), "one\ntwo\n").expect("the edit should write"); + commit_all(&repo.root, "add two"); + + let db = repo.db(); + let worktree = repo.checkout_id(); + seed_event( + &db, + &worktree, + 1, + &repo.parent_tree(), + &repo.head_tree(), + "ai_exclusive", + Some("scope-old"), + ); + seed_event( + &db, + &worktree, + 2, + &repo.parent_tree(), + &repo.head_tree(), + "ai_contended", + None, + ); + + let flow_result = flow_result_for(&repo, ParsedPatch { files: Vec::new() }); + let mutation_ai_patch = resolve_mutation_ai(&repo, &db, &flow_result); + assert_eq!( + touched_line_count(&mutation_ai_patch), + 0, + "the newer contended match resolves the line and blocks the older exclusive event" + ); + + let trace = persist_trace(&flow_result, &db, &mutation_ai_patch); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], + json!(1) + ); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["ai"]["added"], + json!(0) + ); + assert_eq!( + trace["files"][0]["conversations"][0]["contributor"]["type"], + json!("unknown") + ); + } + + #[test] + fn an_adversarial_foreign_worktree_event_cannot_block_the_current_worktrees_exclusive_event() { + let repo = E2eRepo::new("adversarial-linked"); + + let linked_root = repo + .root + .parent() + .expect("the repo should have a parent directory") + .join("linked"); + git( + &repo.root, + &[ + "worktree", + "add", + "-q", + linked_root.to_str().expect("worktree path should be UTF-8"), + ], + ); + + fs::write(repo.root.join("file.rs"), "one\ntwo\n").expect("the edit should write"); + commit_all(&repo.root, "add two"); + + let db = repo.db(); + let current_worktree = repo.checkout_id(); + let foreign_worktree = resolve_worktree_id(&linked_root) + .expect("the linked worktree's identity should resolve") + .0; + assert_ne!( + current_worktree, foreign_worktree, + "the linked worktree must derive its own distinct identity" + ); + + seed_event( + &db, + ¤t_worktree, + 1, + &repo.parent_tree(), + &repo.head_tree(), + "ai_exclusive", + Some("scope-current"), + ); + seed_event( + &db, + &foreign_worktree, + 2, + &repo.parent_tree(), + &repo.head_tree(), + "ai_contended", + None, + ); + + let flow_result = flow_result_for(&repo, ParsedPatch { files: Vec::new() }); + let mutation_ai_patch = resolve_mutation_ai(&repo, &db, &flow_result); + assert_eq!( + touched_line_count(&mutation_ai_patch), + 1, + "only the current worktree's history is eligible, so the older exclusive event contributes" + ); + + let trace = persist_trace(&flow_result, &db, &mutation_ai_patch); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["ai"]["added"], + json!(1), + "worktree isolation lets the current worktree's exclusive event classify the target ai" + ); + assert_eq!( + trace["files"][0]["conversations"][0]["contributor"]["type"], + json!("ai") + ); + assert!(trace.get("tool").is_none()); + } + + fn touched_contents(patch: &ParsedPatch) -> Vec { + patch + .files + .iter() + .flat_map(|file| file.hunks.iter()) + .flat_map(|hunk| hunk.lines.iter()) + .map(|line| line.content.clone()) + .collect() + } + + #[test] + #[allow(clippy::too_many_lines)] + fn persistence_boundaries_stay_separated_across_diff_traces_intersection_and_agent_trace() { + let repo = E2eRepo::new("persistence-boundary"); + + fs::write(repo.root.join("file.rs"), "one\ntwo\n").expect("the direct edit should write"); + git(&repo.root, &["add", "-A"]); + let intermediate_tree = git(&repo.root, &["write-tree"]).trim().to_owned(); + + fs::write(repo.root.join("file.rs"), "one\ntwo\nthree\n") + .expect("the mutation edit should write"); + commit_all(&repo.root, "add two and three"); + + let base_tree = repo.parent_tree(); + let final_tree = repo.head_tree(); + assert_ne!( + base_tree, intermediate_tree, + "the direct edit must move the tree" + ); + assert_ne!( + intermediate_tree, final_tree, + "the mutation edit must move the tree again" + ); + + let db = repo.db(); + + let now_ms = current_unix_time_ms().expect("the clock should resolve"); + db.insert_diff_trace(DiffTraceInsert { + time_ms: now_ms - 60_000, + session_id: "cc_session-direct", + patch: "diff --git a/file.rs b/file.rs\n--- a/file.rs\n+++ b/file.rs\n@@ -1,1 +1,2 @@\n one\n+two\n", + model_id: Some("claude/model-direct"), + tool_name: "claude", + tool_version: Some("9.9.9"), + payload_type: PAYLOAD_TYPE_PATCH, + }) + .expect("the direct diff_traces row should insert"); + + seed_event( + &db, + &repo.checkout_id(), + 1, + &intermediate_tree, + &final_tree, + "ai_exclusive", + Some("scope-mutation"), + ); + + let flow_result = run_post_commit_intersection_flow_with( + &repo.root, + capture_post_commit_patch_from_git, + current_unix_time_ms, + |cutoff_ms, end_ms| db.recent_diff_trace_patches(cutoff_ms, end_ms), + |insert| db.insert_post_commit_patch_intersection(insert).map(|_| ()), + ) + .expect("the real post-commit intersection flow should run"); + assert_eq!( + touched_contents(&flow_result.combined_recent_patch), + vec!["two".to_owned()], + "the combined recent patch comes from the real diff_traces query, not an in-memory patch" + ); + + let mutation_ai_patch = resolve_mutation_ai(&repo, &db, &flow_result); + assert_eq!( + touched_contents(&mutation_ai_patch), + vec!["three".to_owned()], + "mutation history resolves only the committed line direct evidence missed" + ); + + persist_trace(&flow_result, &db, &mutation_ai_patch); + + assert_eq!( + row_count(&db, "diff_traces"), + 1, + "mutation attribution must not create another diff_traces row" + ); + let stored_direct_patch: String = db + .query_map("SELECT patch FROM diff_traces", (), |row| { + row.get::(0).map_err(anyhow::Error::from) + }) + .expect("diff_traces query should succeed") + .into_iter() + .next() + .expect("one diff_traces row should exist"); + let stored_direct = parse_patch_from_text(&stored_direct_patch, None) + .expect("the stored direct patch should parse"); + assert_eq!( + touched_contents(&stored_direct), + vec!["two".to_owned()], + "the direct diff_traces row contains 'two' and never 'three'" + ); + + assert_eq!( + row_count(&db, "post_commit_patch_intersections"), + 1, + "the intersection flow persists exactly one direct-only row" + ); + let stored_intersection_json: String = db + .query_map( + "SELECT intersection_patch FROM post_commit_patch_intersections", + (), + |row| row.get::(0).map_err(anyhow::Error::from), + ) + .expect("intersection query should succeed") + .into_iter() + .next() + .expect("one intersection row should exist"); + let stored_intersection = load_patch_from_json(&stored_intersection_json) + .expect("the persisted intersection patch should reconstruct"); + assert_eq!( + touched_contents(&stored_intersection), + vec!["two".to_owned()], + "post_commit_patch_intersections stays direct-only; the mutation line 'three' \ + must never contaminate this table" + ); + + assert_eq!(row_count(&db, "agent_traces"), 1); + let stored_trace_json: String = db + .query_map("SELECT trace_json FROM agent_traces", (), |row| { + row.get::(0).map_err(anyhow::Error::from) + }) + .expect("agent_traces query should succeed") + .into_iter() + .next() + .expect("one Agent Trace row should exist"); + let trace: Value = serde_json::from_str(&stored_trace_json) + .expect("the persisted Agent Trace JSON should parse"); + validate_agent_trace_value(&trace).expect( + "the persisted agent_traces.trace_json validates against the embedded Agent Trace schema", + ); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["ai"]["added"], + json!(2), + "direct + mutation coverage classifies both committed added lines as ai" + ); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], + json!(0) + ); + assert_eq!( + trace["files"][0]["conversations"][0]["contributor"]["type"], + json!("ai") + ); + + assert_eq!( + trace["tool"], + json!({ "name": "claude", "version": "9.9.9" }) + ); + + assert_eq!( + row_count(&db, "mutation_trace_events"), + 1, + "attribution performs no mutation-cursor write" + ); + } +} + +mod mutation_provenance_e2e { + use super::*; + use crate::services::agent_trace_db::{ClaudeModelStateObservation, ObservationKind}; + use crate::services::agent_trace_storage::{ + resolve_agent_trace_storage_at_state_root, AgentTraceStorageContext, + }; + use crate::services::hooks::claude_mutation_scope; + use crate::services::hooks::codex_mutation_scope; + use crate::services::hooks::opencode_mutation_scope; + use crate::services::hooks::pi_mutation_scope; + use crate::services::mutation_trace::runtime::resolve_git_dir; + use crate::services::mutation_trace::runtime::resolve_post_commit_mutation_ai_patch; + + fn git(repo: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(repo) + .output() + .expect("git should spawn"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).expect("git output should be UTF-8") + } + + fn row_count(db: &RepositoryAgentTraceDb, table: &str) -> i64 { + db.query_map(&format!("SELECT COUNT(*) FROM {table}"), (), |row| { + row.get::(0).map_err(anyhow::Error::from) + }) + .expect("count query should succeed") + .into_iter() + .next() + .expect("count row should exist") + } + + struct ProvenanceE2eRepo { + _temp: tempfile::TempDir, + root: PathBuf, + state_root: PathBuf, + db_path: PathBuf, + } + + impl ProvenanceE2eRepo { + fn new(label: &str) -> Self { + let temp = tempfile::Builder::new() + .prefix(&format!("sce-mutation-provenance-e2e-{label}-")) + .tempdir() + .expect("temp dir should be created"); + let root = temp.path().join("repo"); + fs::create_dir_all(&root).expect("repo dir should be created"); + git(&root, &["init", "-q"]); + git(&root, &["config", "user.email", "test@example.invalid"]); + git(&root, &["config", "user.name", "SCE Test"]); + git( + &root, + &["remote", "add", "origin", "git@github.com:acme/widgets.git"], + ); + fs::write(root.join("file.txt"), "one\n").expect("seed file should write"); + git(&root, &["add", "-A"]); + git(&root, &["commit", "-qm", "base"]); + + let state_root = temp.path().join("state"); + fs::create_dir_all(&state_root).expect("state root should be created"); + let storage = resolve_agent_trace_storage_at_state_root( + &AgentTraceStorageContext { + repository_root: &root, + explicit_repository_id: None, + repository_remote: "origin", + }, + &state_root, + ) + .expect("state-root storage should initialize the repository DB"); + + Self { + _temp: temp, + root, + state_root, + db_path: storage.db_path, + } + } + + fn db(&self) -> RepositoryAgentTraceDb { + RepositoryAgentTraceDb::open_for_hooks_without_migrations_at(&self.db_path) + .expect("repository DB should reopen") + } + + fn cwd(&self) -> String { + self.root.to_string_lossy().into_owned() + } + + fn write_change(&self, content: &str) { + fs::write(self.root.join("file.txt"), content).expect("mutation should write"); + } + + fn commit_change(&self) { + git(&self.root, &["add", "-A"]); + git(&self.root, &["commit", "-qm", "AI mutation"]); + } + + fn mutation_events(&self) -> Vec<(String, Option)> { + self.db() + .query_map( + "SELECT attribution_kind, attribution_scope_id \ + FROM mutation_trace_events ORDER BY revision", + (), + |row| { + let attribution_kind = row.get::(0).map_err(anyhow::Error::from)?; + let attribution_scope_id = + row.get::>(1).map_err(anyhow::Error::from)?; + Ok((attribution_kind, attribution_scope_id)) + }, + ) + .expect("mutation-events query should succeed") + } + + fn run_post_commit(&self) -> Value { + let db = self.db(); + run_post_commit_subcommand_with( + &self.root, + Some(AgentTraceVcsType::Git), + "git@github.com:acme/widgets.git", + |root| { + run_post_commit_intersection_flow_with( + root, + capture_post_commit_patch_from_git, + current_unix_time_ms, + |cutoff_ms, end_ms| db.recent_diff_trace_patches(cutoff_ms, end_ms), + |insert| db.insert_post_commit_patch_intersection(insert).map(|_| ()), + ) + }, + |root, flow_result, vcs_type, remote_url| { + let direct_intersection = intersect_patches_fn( + &flow_result.combined_recent_patch, + &flow_result.post_commit_data.parsed_patch, + ); + let mutation_ai_patch = resolve_post_commit_mutation_ai_patch( + root, + &db, + &direct_intersection, + &flow_result.post_commit_data.parsed_patch, + ); + + run_post_commit_agent_trace_flow_with( + flow_result, + vcs_type, + remote_url, + &mutation_ai_patch, + |value| { + validate_agent_trace_value(value) + .map_err(|error| anyhow!(error.to_string())) + }, + |insert| db.insert_agent_trace(insert).map(|_| ()), + ) + }, + |_| Ok(false), + |_| Ok(()), + |_| db.passive_checkpoint(), + None, + ) + .expect("the real post-commit hook flow should persist Agent Trace"); + + db.query_map("SELECT trace_json FROM agent_traces", (), |row| { + row.get::(0).map_err(anyhow::Error::from) + }) + .expect("persisted Agent Trace should be readable") + .into_iter() + .next() + .map(|trace| serde_json::from_str(&trace).expect("trace JSON should parse")) + .expect("one Agent Trace row should exist") + } + } + + fn assert_mutation_trace_provenance(trace: &Value, model_id: &str, session_id: &str) { + assert_eq!(trace["files"][0]["path"], json!("file.txt")); + assert_eq!( + trace["files"][0]["conversations"][0]["contributor"], + json!({"type": "ai", "model_id": model_id}) + ); + assert_eq!( + trace["files"][0]["conversations"][0]["related"], + json!([{ + "type": "session", + "url": format!("https://sce.crocoder.dev/sessions/{session_id}"), + }]) + ); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["ai"]["added"], + json!(1) + ); + assert_eq!( + trace["metadata"]["sce"]["line_changes"]["unknown"]["added"], + json!(0) + ); + } + + fn opencode_before( + cwd: &str, + session_id: &str, + call_id: &str, + tool_name: &str, + model: Option<&str>, + ) -> String { + let mut payload = json!({ + "hook_event_name": "ToolExecuteBefore", + "session_id": session_id, + "call_id": call_id, + "cwd": cwd, + "tool_name": tool_name, + }); + if let Some(model) = model { + payload["model"] = json!(model); + } + payload.to_string() + } + + fn opencode_shell_env( + cwd: &str, + session_id: &str, + call_id: &str, + model: Option<&str>, + ) -> String { + let mut payload = json!({ + "hook_event_name": "ShellEnv", + "session_id": session_id, + "call_id": call_id, + "cwd": cwd, + }); + if let Some(model) = model { + payload["model"] = json!(model); + } + payload.to_string() + } + + fn opencode_after(cwd: &str, session_id: &str, call_id: &str, tool_name: &str) -> String { + json!({ + "hook_event_name": "ToolExecuteAfter", + "session_id": session_id, + "call_id": call_id, + "cwd": cwd, + "tool_name": tool_name, + }) + .to_string() + } + + fn opencode_tool_error(cwd: &str, session_id: &str, call_id: &str, tool_name: &str) -> String { + json!({ + "hook_event_name": "ToolError", + "session_id": session_id, + "call_id": call_id, + "cwd": cwd, + "tool_name": tool_name, + }) + .to_string() + } + + fn drive_opencode(repo: &ProvenanceE2eRepo, payload: &str) -> Result { + opencode_mutation_scope::run_opencode_mutation_scope_from_payload_at_state_root( + &repo.state_root, + payload, + None, + ) + } + + #[test] + fn opencode_bash_mutation_persists_model_and_session_in_agent_trace() { + let repo = ProvenanceE2eRepo::new("opencode-bash"); + let session_id = "ses_opencode_bash"; + let cwd = repo.cwd(); + + drive_opencode( + &repo, + &opencode_shell_env(&cwd, session_id, "call_bash", Some("opencode/big-pickle")), + ) + .expect("OpenCode shell.env should establish the bash scope"); + + repo.write_change("one\nopencode bash mutation\n"); + + drive_opencode( + &repo, + &opencode_after(&cwd, session_id, "call_bash", "bash"), + ) + .expect("OpenCode ToolExecuteAfter should close the bash scope"); + repo.commit_change(); + + let trace = repo.run_post_commit(); + assert_mutation_trace_provenance(&trace, "opencode/big-pickle", "oc_ses_opencode_bash"); + assert_eq!(row_count(&repo.db(), "diff_traces"), 0); + assert_eq!(row_count(&repo.db(), "post_commit_patch_intersections"), 1); + assert_eq!(row_count(&repo.db(), "mutation_trace_events"), 1); + assert_eq!(row_count(&repo.db(), "agent_traces"), 1); + } + + #[test] + fn opencode_apply_patch_mutation_with_missing_model_persists_no_model_in_agent_trace() { + let repo = ProvenanceE2eRepo::new("opencode-apply-patch"); + let session_id = "ses_opencode_no_model"; + let cwd = repo.cwd(); + + drive_opencode( + &repo, + &opencode_before(&cwd, session_id, "call_patch", "apply_patch", None), + ) + .expect("OpenCode ToolExecuteBefore should establish the apply_patch scope"); + + repo.write_change("one\npatched without model evidence\n"); + + drive_opencode( + &repo, + &opencode_after(&cwd, session_id, "call_patch", "apply_patch"), + ) + .expect("OpenCode ToolExecuteAfter should close the apply_patch scope"); + repo.commit_change(); + + let trace = repo.run_post_commit(); + assert_eq!(trace["files"][0]["path"], json!("file.txt")); + let contributor = &trace["files"][0]["conversations"][0]["contributor"]; + assert_eq!(contributor["type"], json!("ai")); + assert!( + contributor.get("model_id").is_none(), + "absent model evidence must never be guessed or fabricated" + ); + assert_eq!( + trace["files"][0]["conversations"][0]["related"], + json!([{ + "type": "session", + "url": "https://sce.crocoder.dev/sessions/oc_ses_opencode_no_model", + }]) + ); + } + + #[test] + fn opencode_write_mutation_persists_model_while_task_delegation_stays_zero_footprint() { + let repo = ProvenanceE2eRepo::new("opencode-write-task"); + let session_id = "ses_opencode_write"; + let cwd = repo.cwd(); + + drive_opencode( + &repo, + &opencode_before(&cwd, session_id, "call_task", "task", None), + ) + .expect("task delegation ToolExecuteBefore is neutral"); + drive_opencode( + &repo, + &opencode_after(&cwd, session_id, "call_task", "task"), + ) + .expect("task delegation ToolExecuteAfter is neutral"); + + assert_eq!( + row_count(&repo.db(), "mutation_trace_scopes"), + 0, + "a delegation event must create no mutation scope" + ); + + drive_opencode( + &repo, + &opencode_before( + &cwd, + session_id, + "call_write", + "write", + Some("opencode/big-pickle"), + ), + ) + .expect("OpenCode write ToolExecuteBefore should establish the scope"); + repo.write_change("one\nwrite mutation\n"); + drive_opencode( + &repo, + &opencode_after(&cwd, session_id, "call_write", "write"), + ) + .expect("OpenCode write ToolExecuteAfter should close the scope"); + repo.commit_change(); + + let trace = repo.run_post_commit(); + assert_mutation_trace_provenance(&trace, "opencode/big-pickle", "oc_ses_opencode_write"); + assert_eq!( + row_count(&repo.db(), "mutation_trace_scopes"), + 1, + "only the tracked write call created a scope" + ); + } + + #[test] + fn opencode_unknown_tool_events_create_no_scope_or_mutation_state() { + let repo = ProvenanceE2eRepo::new("opencode-untracked"); + let session_id = "ses_opencode_untracked"; + let cwd = repo.cwd(); + + for tool_name in ["read", "custom_mcp_tool", "totally_unknown_future_tool"] { + let call_id = format!("call_{tool_name}"); + drive_opencode( + &repo, + &opencode_before(&cwd, session_id, &call_id, tool_name, None), + ) + .unwrap_or_else(|_| panic!("{tool_name} ToolExecuteBefore should be neutral")); + drive_opencode( + &repo, + &opencode_after(&cwd, session_id, &call_id, tool_name), + ) + .unwrap_or_else(|_| panic!("{tool_name} ToolExecuteAfter should be neutral")); + drive_opencode( + &repo, + &opencode_tool_error(&cwd, session_id, &call_id, tool_name), + ) + .unwrap_or_else(|_| panic!("{tool_name} ToolError should be neutral")); + } + + assert_eq!(row_count(&repo.db(), "mutation_trace_scopes"), 0); + assert_eq!(row_count(&repo.db(), "mutation_trace_events"), 0); + } + + #[test] + fn opencode_child_task_session_gets_its_own_independent_scope_and_provenance() { + let repo = ProvenanceE2eRepo::new("opencode-child-session"); + let parent_session = "ses_opencode_parent"; + let child_session = "ses_opencode_child"; + let cwd = repo.cwd(); + + drive_opencode( + &repo, + &opencode_before(&cwd, parent_session, "call_task", "task", None), + ) + .expect("the parent's task delegation is neutral"); + + drive_opencode( + &repo, + &opencode_before( + &cwd, + child_session, + "call_child_write", + "write", + Some("opencode/child-model"), + ), + ) + .expect("the child session's write ToolExecuteBefore should establish its own scope"); + repo.write_change("one\nchild session mutation\n"); + drive_opencode( + &repo, + &opencode_after(&cwd, child_session, "call_child_write", "write"), + ) + .expect("the child session's write ToolExecuteAfter should close its own scope"); + repo.commit_change(); + + let trace = repo.run_post_commit(); + assert_mutation_trace_provenance(&trace, "opencode/child-model", "oc_ses_opencode_child"); + assert_eq!( + row_count(&repo.db(), "mutation_trace_scopes"), + 1, + "the parent's task delegation created no scope; only the child session's write did" + ); + } + + #[test] + fn opencode_concurrent_reject_and_confirm_keeps_only_the_confirmed_mutation_ai() { + let repo = ProvenanceE2eRepo::new("opencode-concurrent-reject"); + let session_id = "ses_opencode_concurrent"; + let cwd = repo.cwd(); + + drive_opencode( + &repo, + &opencode_before( + &cwd, + session_id, + "call_a_edit", + "edit", + Some("opencode/big-pickle"), + ), + ) + .expect("A's edit ToolExecuteBefore should establish a scope"); + drive_opencode( + &repo, + &opencode_before( + &cwd, + session_id, + "call_b_write", + "write", + Some("opencode/big-pickle"), + ), + ) + .expect("B's write ToolExecuteBefore should establish a distinct concurrent scope"); + + fs::write(repo.root.join("rejected.txt"), "rejected mutation\n") + .expect("A's mutation should write"); + fs::write( + repo.root.join("ambiguous.txt"), + "B's mutation before recovery\n", + ) + .expect("B's pre-recovery mutation should write"); + + drive_opencode( + &repo, + &opencode_tool_error(&cwd, session_id, "call_a_edit", "edit"), + ) + .expect("A's ToolError should abandon A's scope and consume the shared ambiguous interval"); + + fs::write( + repo.root.join("confirmed.txt"), + "B's mutation after recovery\n", + ) + .expect("B's post-recovery mutation should write"); + drive_opencode( + &repo, + &opencode_after(&cwd, session_id, "call_b_write", "write"), + ) + .expect("B's ToolExecuteAfter should confirm exactly B's own surviving scope"); + + git(&repo.root, &["add", "-A"]); + git(&repo.root, &["commit", "-qm", "concurrent mutation"]); + + let db = repo.db(); + let post_commit_data = capture_post_commit_patch_from_git(&repo.root) + .expect("capturing the post-commit patch should succeed"); + let mutation_ai_patch = resolve_post_commit_mutation_ai_patch( + &repo.root, + &db, + &ParsedPatch { files: Vec::new() }, + &post_commit_data.parsed_patch, + ); + + let ai_paths: Vec<&str> = mutation_ai_patch + .files + .iter() + .map(|file| file.new_path.as_str()) + .collect(); + assert!( + !ai_paths.contains(&"rejected.txt"), + "the abandoned scope's own mutation must never enter mutation_ai_patch" + ); + assert!( + !ai_paths.contains(&"ambiguous.txt"), + "B's mutation made before the ambiguity-consuming flush is genuinely \ + indistinguishable from A's and must stay non-AI, not merely non-A" + ); + assert!( + ai_paths.contains(&"confirmed.txt"), + "B's own later mutation, made after A's interval was consumed and confirmed \ + by B's own Close, must be attributed AI" + ); + } + + #[test] + fn opencode_and_codex_unconfirmed_overlap_stays_ineligible_until_codex_confirms() { + let repo = ProvenanceE2eRepo::new("opencode-codex-overlap"); + let oc_session = "ses_opencode_overlap"; + let codex_session = "codex-overlap-session"; + let cwd = repo.cwd(); + + drive_opencode( + &repo, + &opencode_before( + &cwd, + oc_session, + "call_oc", + "write", + Some("opencode/big-pickle"), + ), + ) + .expect("OpenCode write should establish a scope"); + + let codex_pre = json!({ + "hook_event_name": "PreToolUse", + "session_id": codex_session, + "turn_id": "codex-overlap-turn", + "cwd": cwd, + "tool_name": "Bash", + "tool_use_id": "codex-overlap-bash", + "model": "gpt-5.6-sol", + "tool_input": {"command": "true"}, + }); + codex_mutation_scope::run_codex_mutation_scope_from_payload_at_state_root( + &repo.state_root, + &codex_pre.to_string(), + None, + ) + .expect("Codex Bash PreToolUse should establish a concurrent scope"); + + repo.write_change("one\nopencode overlap mutation\n"); + + drive_opencode(&repo, &opencode_after(&cwd, oc_session, "call_oc", "write")) + .expect("OpenCode ToolExecuteAfter should close its own scope"); + + let attribution_after_first_close = repo.mutation_events(); + assert_eq!( + attribution_after_first_close + .last() + .map(|(kind, _)| kind.as_str()), + Some("ineligible_unscoped"), + "an unconfirmed live Codex scope must suppress OpenCode's own confirming close" + ); + + let codex_post = json!({ + "hook_event_name": "PostToolUse", + "session_id": codex_session, + "turn_id": "codex-overlap-turn", + "cwd": cwd, + "tool_name": "Bash", + "tool_use_id": "codex-overlap-bash", + }); + codex_mutation_scope::run_codex_mutation_scope_from_payload_at_state_root( + &repo.state_root, + &codex_post.to_string(), + None, + ) + .expect("Codex PostToolUse should close its own scope"); + + drive_opencode( + &repo, + &opencode_before( + &cwd, + oc_session, + "call_oc_2", + "write", + Some("opencode/big-pickle"), + ), + ) + .expect("a fresh OpenCode write should establish a new scope"); + repo.write_change("one\nopencode overlap mutation\nsecond change\n"); + drive_opencode( + &repo, + &opencode_after(&cwd, oc_session, "call_oc_2", "write"), + ) + .expect("the fresh OpenCode scope should close cleanly once Codex is confirmed"); + + let attribution_after_second_close = repo.mutation_events(); + assert_eq!( + attribution_after_second_close + .last() + .map(|(kind, _)| kind.as_str()), + Some("ai_exclusive"), + "once every other live scope is confirmation-safe, a solo confirming close is AiExclusive" + ); + } + + #[test] + fn opencode_and_claude_overlap_produces_ai_contended() { + let repo = ProvenanceE2eRepo::new("opencode-claude-overlap"); + let oc_session = "ses_opencode_contended"; + let claude_session = "claude-overlap-session"; + let cwd = repo.cwd(); + + drive_opencode( + &repo, + &opencode_before( + &cwd, + oc_session, + "call_oc_contended", + "write", + Some("opencode/big-pickle"), + ), + ) + .expect("OpenCode write should establish a scope"); + + let claude_pre = json!({ + "hook_event_name": "PreToolUse", + "session_id": claude_session, + "cwd": cwd, + "tool_name": "Bash", + "tool_use_id": "claude-overlap-bash", + "tool_input": {"command": "printf mutation"}, + }); + claude_mutation_scope::run_claude_mutation_scope_from_payload_at_state_root( + &repo.state_root, + &claude_pre.to_string(), + None, + ) + .expect( + "Claude Bash PreToolUse should establish a concurrent, non-confirmation-required scope", + ); + + repo.write_change("one\ncontended mutation\n"); + + drive_opencode( + &repo, + &opencode_after(&cwd, oc_session, "call_oc_contended", "write"), + ) + .expect("OpenCode ToolExecuteAfter should confirm its own scope"); + + let attribution = repo.mutation_events(); + assert_eq!( + attribution.last().map(|(kind, _)| kind.as_str()), + Some("ai_contended"), + "a confirmed OpenCode close alongside a live non-confirmation-required Claude scope is contended, not suppressed" + ); + + let claude_post = json!({ + "hook_event_name": "PostToolUse", + "session_id": claude_session, + "cwd": cwd, + "tool_name": "Bash", + "tool_use_id": "claude-overlap-bash", + }); + claude_mutation_scope::run_claude_mutation_scope_from_payload_at_state_root( + &repo.state_root, + &claude_post.to_string(), + None, + ) + .expect("Claude PostToolUse should close its own scope"); + } + + #[test] + fn claude_bash_mutation_persists_model_and_session_in_agent_trace() { + let repo = ProvenanceE2eRepo::new("claude"); + let session_id = "claude-session-e2e"; + let db = repo.db(); + db.upsert_claude_model_state(ClaudeModelStateObservation { + session_id: format!("cc_{session_id}"), + agent_id: String::new(), + model_id: String::from("claude/opus-4-1"), + observation_kind: ObservationKind::SessionStart, + source: String::from("test"), + observed_at_ms: 1, + }) + .expect("Claude model state should be persisted"); + + let cwd = repo.cwd(); + let pre = json!({ + "hook_event_name": "PreToolUse", + "session_id": session_id, + "cwd": cwd, + "tool_name": "Bash", + "tool_use_id": "claude-bash-e2e", + "tool_input": {"command": "printf mutation"}, + }); + claude_mutation_scope::run_claude_mutation_scope_from_payload_at_state_root( + &repo.state_root, + &pre.to_string(), + None, + ) + .expect("Claude Bash PreToolUse should establish a scope"); + + let post = json!({ + "hook_event_name": "PostToolUse", + "session_id": session_id, + "cwd": repo.cwd(), + "tool_name": "Bash", + "tool_use_id": "claude-bash-e2e", + }); + repo.write_change("one\nclaude mutation\n"); + claude_mutation_scope::run_claude_mutation_scope_from_payload_at_state_root( + &repo.state_root, + &post.to_string(), + None, + ) + .expect("Claude Bash PostToolUse should close the scope"); + repo.commit_change(); + + let trace = repo.run_post_commit(); + assert_mutation_trace_provenance(&trace, "claude/opus-4-1", "cc_claude-session-e2e"); + assert_eq!(row_count(&repo.db(), "diff_traces"), 0); + assert_eq!(row_count(&repo.db(), "post_commit_patch_intersections"), 1); + assert_eq!(row_count(&repo.db(), "mutation_trace_events"), 1); + assert_eq!(row_count(&repo.db(), "agent_traces"), 1); + } + + #[test] + fn codex_bash_mutation_persists_model_and_session_in_agent_trace() { + let repo = ProvenanceE2eRepo::new("codex"); + let session_id = "codex-session-e2e"; + let cwd = repo.cwd(); + let pre = json!({ + "hook_event_name": "PreToolUse", + "session_id": session_id, + "turn_id": "codex-turn-e2e", + "cwd": cwd, + "tool_name": "Bash", + "tool_use_id": "codex-bash-e2e", + "model": "gpt-5.6-sol", + "tool_input": {"command": "true"}, + }); + codex_mutation_scope::run_codex_mutation_scope_from_payload_at_state_root( + &repo.state_root, + &pre.to_string(), + None, + ) + .expect("Codex Bash PreToolUse should establish a scope"); + + let post = json!({ + "hook_event_name": "PostToolUse", + "session_id": session_id, + "turn_id": "codex-turn-e2e", + "cwd": repo.cwd(), + "tool_name": "Bash", + "tool_use_id": "codex-bash-e2e", + }); + repo.write_change("one\ncodex mutation\n"); + codex_mutation_scope::run_codex_mutation_scope_from_payload_at_state_root( + &repo.state_root, + &post.to_string(), + None, + ) + .expect("Codex Bash PostToolUse should close the scope"); + repo.commit_change(); + + let trace = repo.run_post_commit(); + assert_mutation_trace_provenance(&trace, "gpt-5.6-sol", "cx_codex-session-e2e"); + assert_eq!(row_count(&repo.db(), "diff_traces"), 0); + assert_eq!(row_count(&repo.db(), "post_commit_patch_intersections"), 1); + assert_eq!(row_count(&repo.db(), "mutation_trace_events"), 1); + assert_eq!(row_count(&repo.db(), "agent_traces"), 1); + } + + fn pi_tool_call( + cwd: &str, + session_id: &str, + tool_call_id: &str, + tool_name: &str, + model: Option<&str>, + ) -> String { + let mut payload = json!({ + "hook_event_name": "ToolCall", + "session_id": session_id, + "tool_call_id": tool_call_id, + "cwd": cwd, + "tool_name": tool_name, + }); + if let Some(model) = model { + payload["model"] = json!(model); + } + payload.to_string() + } + + fn pi_tool_result(cwd: &str, session_id: &str, tool_call_id: &str, tool_name: &str) -> String { + json!({ + "hook_event_name": "ToolResult", + "session_id": session_id, + "tool_call_id": tool_call_id, + "cwd": cwd, + "tool_name": tool_name, + }) + .to_string() + } + + fn pi_tool_execution_end( + cwd: &str, + session_id: &str, + tool_call_id: &str, + tool_name: &str, + ) -> String { + json!({ + "hook_event_name": "ToolExecutionEnd", + "session_id": session_id, + "tool_call_id": tool_call_id, + "cwd": cwd, + "tool_name": tool_name, + }) + .to_string() + } + + fn drive_pi(repo: &ProvenanceE2eRepo, payload: &str) -> Result { + pi_mutation_scope::run_pi_mutation_scope_from_payload_at_state_root( + &repo.state_root, + payload, + None, + ) + } + + fn pi_confirmed_tool_case(tool_name: &str, label: &str) { + let repo = ProvenanceE2eRepo::new(label); + let session_id = format!("ses-{label}"); + let call_id = format!("call-{label}"); + let cwd = repo.cwd(); + + drive_pi( + &repo, + &pi_tool_call( + &cwd, + &session_id, + &call_id, + tool_name, + Some("anthropic/opus-5"), + ), + ) + .expect("Pi ToolCall should establish the tracked scope before execution"); + + repo.write_change(&format!("one\npi {tool_name} mutation\n")); + + drive_pi( + &repo, + &pi_tool_result(&cwd, &session_id, &call_id, tool_name), + ) + .expect("Pi ToolResult should mark the attempt executed"); + drive_pi( + &repo, + &pi_tool_execution_end(&cwd, &session_id, &call_id, tool_name), + ) + .expect("Pi ToolExecutionEnd paired with an observed ToolResult should Close"); + repo.commit_change(); + + let trace = repo.run_post_commit(); + assert_mutation_trace_provenance(&trace, "anthropic/opus-5", &format!("pi_{session_id}")); + assert_eq!(row_count(&repo.db(), "diff_traces"), 0); + assert_eq!(row_count(&repo.db(), "post_commit_patch_intersections"), 1); + assert_eq!(row_count(&repo.db(), "mutation_trace_events"), 1); + assert_eq!(row_count(&repo.db(), "agent_traces"), 1); + } + + #[test] + fn pi_bash_mutation_persists_model_and_session_in_agent_trace() { + pi_confirmed_tool_case("bash", "pi-bash"); + } + + #[test] + fn pi_write_mutation_persists_model_and_session_in_agent_trace() { + pi_confirmed_tool_case("write", "pi-write"); + } + + #[test] + fn pi_edit_mutation_persists_model_and_session_in_agent_trace() { + pi_confirmed_tool_case("edit", "pi-edit"); + } + + #[test] + fn pi_missing_model_preserves_session_with_null_model_in_agent_trace() { + let repo = ProvenanceE2eRepo::new("pi-no-model"); + let session_id = "ses-pi-no-model"; + let cwd = repo.cwd(); + + drive_pi( + &repo, + &pi_tool_call(&cwd, session_id, "call-1", "bash", None), + ) + .expect("Pi ToolCall should establish the scope without model evidence"); + + repo.write_change("one\npi mutation without model\n"); + + drive_pi(&repo, &pi_tool_result(&cwd, session_id, "call-1", "bash")) + .expect("Pi ToolResult should mark the attempt executed"); + drive_pi( + &repo, + &pi_tool_execution_end(&cwd, session_id, "call-1", "bash"), + ) + .expect("Pi ToolExecutionEnd should close the scope"); + repo.commit_change(); + + let trace = repo.run_post_commit(); + assert_eq!(trace["files"][0]["path"], json!("file.txt")); + let contributor = &trace["files"][0]["conversations"][0]["contributor"]; + assert_eq!(contributor["type"], json!("ai")); + assert!( + contributor.get("model_id").is_none(), + "absent model evidence must never be guessed or fabricated" + ); + assert_eq!( + trace["files"][0]["conversations"][0]["related"], + json!([{ + "type": "session", + "url": "https://sce.crocoder.dev/sessions/pi_ses-pi-no-model", + }]) + ); + } + + #[test] + fn pi_read_only_and_unknown_tools_create_no_scope_or_mutation_state() { + let repo = ProvenanceE2eRepo::new("pi-untracked"); + let session_id = "ses-pi-untracked"; + let cwd = repo.cwd(); + + for tool_name in [ + "read", + "grep", + "find", + "ls", + "custom_mcp_tool", + "totally_unknown_future_tool", + "user_bash", + ] { + let call_id = format!("call-{tool_name}"); + drive_pi( + &repo, + &pi_tool_call(&cwd, session_id, &call_id, tool_name, None), + ) + .unwrap_or_else(|_| panic!("{tool_name} ToolCall should be neutral")); + drive_pi( + &repo, + &pi_tool_result(&cwd, session_id, &call_id, tool_name), + ) + .unwrap_or_else(|_| panic!("{tool_name} ToolResult should be neutral")); + drive_pi( + &repo, + &pi_tool_execution_end(&cwd, session_id, &call_id, tool_name), + ) + .unwrap_or_else(|_| panic!("{tool_name} ToolExecutionEnd should be neutral")); + } + + assert_eq!(row_count(&repo.db(), "mutation_trace_scopes"), 0); + assert_eq!(row_count(&repo.db(), "mutation_trace_events"), 0); + } + + #[test] + fn pi_later_extension_rejection_after_start_produces_no_mutation_ai_patch() { + let repo = ProvenanceE2eRepo::new("pi-later-rejection"); + let session_id = "ses-pi-rejected"; + let cwd = repo.cwd(); + + drive_pi( + &repo, + &pi_tool_call(&cwd, session_id, "call-1", "bash", Some("anthropic/opus-5")), + ) + .expect("Pi ToolCall should establish the scope before a later extension can reject it"); + + fs::write( + repo.root.join("rejected.txt"), + "should never be attributed AI\n", + ) + .expect("the blocked attempt's incidental write should still land on disk"); + + drive_pi( + &repo, + &pi_tool_execution_end(&cwd, session_id, "call-1", "bash"), + ) + .expect("ToolExecutionEnd with no preceding ToolResult must abandon, not error"); + + let scope_status = repo + .db() + .query_map("SELECT status FROM mutation_trace_scopes", (), |row| { + row.get::(0).map_err(anyhow::Error::from) + }) + .expect("scope-status query should succeed"); + assert_eq!( + scope_status, + vec!["abandoned".to_string()], + "the D7 abandon path must leave the scope durably abandoned, never closed or active" + ); + + git(&repo.root, &["add", "-A"]); + git(&repo.root, &["commit", "-qm", "rejected mutation"]); + + let db = repo.db(); + let post_commit_data = capture_post_commit_patch_from_git(&repo.root) + .expect("capturing the post-commit patch should succeed"); + let mutation_ai_patch = resolve_post_commit_mutation_ai_patch( + &repo.root, + &db, + &ParsedPatch { files: Vec::new() }, + &post_commit_data.parsed_patch, + ); + + assert!( + mutation_ai_patch.files.is_empty(), + "a Start that never reached a confirmed Close must never produce mutation_ai_patch entries" + ); + } + + #[test] + fn pi_mutate_then_error_still_persists_confirmed_mutation_through_close() { + let repo = ProvenanceE2eRepo::new("pi-error-executed"); + let session_id = "ses-pi-error"; + let cwd = repo.cwd(); + + drive_pi( + &repo, + &pi_tool_call(&cwd, session_id, "call-1", "bash", Some("anthropic/opus-5")), + ) + .expect("Pi ToolCall should establish the scope"); + + repo.write_change("one\npartial mutation before failure\n"); + + let mut result_payload: Value = + serde_json::from_str(&pi_tool_result(&cwd, session_id, "call-1", "bash")) + .expect("tool_result payload should parse as JSON"); + result_payload["isError"] = json!(true); + drive_pi(&repo, &result_payload.to_string()) + .expect("a failed-but-executed ToolResult is still positive execution evidence"); + + drive_pi( + &repo, + &pi_tool_execution_end(&cwd, session_id, "call-1", "bash"), + ) + .expect("ToolExecutionEnd paired with an observed ToolResult must Close, not abandon"); + repo.commit_change(); + + let trace = repo.run_post_commit(); + assert_mutation_trace_provenance(&trace, "anthropic/opus-5", "pi_ses-pi-error"); + } + + #[test] + fn pi_concurrent_reject_and_confirm_keeps_only_the_confirmed_mutation_ai() { + let repo = ProvenanceE2eRepo::new("pi-concurrent-reject"); + let session_id = "ses-pi-concurrent"; + let cwd = repo.cwd(); + + drive_pi( + &repo, + &pi_tool_call( + &cwd, + session_id, + "call-a-edit", + "edit", + Some("anthropic/opus-5"), + ), + ) + .expect("A's edit ToolCall should establish a scope"); + drive_pi( + &repo, + &pi_tool_call( + &cwd, + session_id, + "call-b-write", + "write", + Some("anthropic/opus-5"), + ), + ) + .expect("B's write ToolCall should establish a distinct concurrent scope"); + + fs::write(repo.root.join("rejected.txt"), "rejected mutation\n") + .expect("A's mutation should write"); + fs::write( + repo.root.join("ambiguous.txt"), + "B's mutation before recovery\n", + ) + .expect("B's pre-recovery mutation should write"); + + drive_pi( + &repo, + &pi_tool_execution_end(&cwd, session_id, "call-a-edit", "edit"), + ) + .expect( + "A's ToolExecutionEnd with no ToolResult should abandon A's scope and consume \ + the shared ambiguous interval", + ); + + fs::write( + repo.root.join("confirmed.txt"), + "B's mutation after recovery\n", + ) + .expect("B's post-recovery mutation should write"); + + drive_pi( + &repo, + &pi_tool_result(&cwd, session_id, "call-b-write", "write"), + ) + .expect("B's ToolResult should mark it executed"); + drive_pi( + &repo, + &pi_tool_execution_end(&cwd, session_id, "call-b-write", "write"), + ) + .expect("B's ToolExecutionEnd should confirm exactly B's own surviving scope"); + + git(&repo.root, &["add", "-A"]); + git(&repo.root, &["commit", "-qm", "concurrent mutation"]); + + let db = repo.db(); + let post_commit_data = capture_post_commit_patch_from_git(&repo.root) + .expect("capturing the post-commit patch should succeed"); + let mutation_ai_patch = resolve_post_commit_mutation_ai_patch( + &repo.root, + &db, + &ParsedPatch { files: Vec::new() }, + &post_commit_data.parsed_patch, + ); + + let ai_paths: Vec<&str> = mutation_ai_patch + .files + .iter() + .map(|file| file.new_path.as_str()) + .collect(); + assert!( + !ai_paths.contains(&"rejected.txt"), + "the abandoned scope's own mutation must never enter mutation_ai_patch" + ); + assert!( + !ai_paths.contains(&"ambiguous.txt"), + "B's mutation made before the ambiguity-consuming flush is genuinely \ + indistinguishable from A's and must stay non-AI, not merely non-A" + ); + assert!( + ai_paths.contains(&"confirmed.txt"), + "B's own later mutation, made after A's interval was consumed and confirmed \ + by B's own Close, must be attributed AI" + ); + } + + #[test] + fn pi_and_claude_overlap_produces_ai_contended() { + let repo = ProvenanceE2eRepo::new("pi-claude-overlap"); + let pi_session = "ses-pi-contended"; + let claude_session = "claude-pi-overlap-session"; + let cwd = repo.cwd(); + + drive_pi( + &repo, + &pi_tool_call( + &cwd, + pi_session, + "call-pi-contended", + "write", + Some("anthropic/opus-5"), + ), + ) + .expect("Pi write should establish a scope"); + + let claude_pre = json!({ + "hook_event_name": "PreToolUse", + "session_id": claude_session, + "cwd": cwd, + "tool_name": "Bash", + "tool_use_id": "claude-pi-overlap-bash", + "tool_input": {"command": "printf mutation"}, + }); + claude_mutation_scope::run_claude_mutation_scope_from_payload_at_state_root( + &repo.state_root, + &claude_pre.to_string(), + None, + ) + .expect( + "Claude Bash PreToolUse should establish a concurrent, non-confirmation-required scope", + ); + + repo.write_change("one\npi+claude contended mutation\n"); + + drive_pi( + &repo, + &pi_tool_result(&cwd, pi_session, "call-pi-contended", "write"), + ) + .expect("Pi ToolResult should mark the attempt executed"); + drive_pi( + &repo, + &pi_tool_execution_end(&cwd, pi_session, "call-pi-contended", "write"), + ) + .expect("Pi ToolExecutionEnd should confirm its own scope"); + + let attribution = repo.mutation_events(); + assert_eq!( + attribution.last().map(|(kind, _)| kind.as_str()), + Some("ai_contended"), + "a confirmed Pi close alongside a live non-confirmation-required Claude scope is contended, not suppressed" + ); + + let claude_post = json!({ + "hook_event_name": "PostToolUse", + "session_id": claude_session, + "cwd": cwd, + "tool_name": "Bash", + "tool_use_id": "claude-pi-overlap-bash", + }); + claude_mutation_scope::run_claude_mutation_scope_from_payload_at_state_root( + &repo.state_root, + &claude_post.to_string(), + None, + ) + .expect("Claude PostToolUse should close its own scope"); + } + + #[test] + fn pi_and_codex_overlap_stays_ineligible_until_codex_confirms() { + let repo = ProvenanceE2eRepo::new("pi-codex-overlap"); + let pi_session = "ses-pi-overlap"; + let codex_session = "codex-pi-overlap-session"; + let cwd = repo.cwd(); + + drive_pi( + &repo, + &pi_tool_call( + &cwd, + pi_session, + "call-pi", + "write", + Some("anthropic/opus-5"), + ), + ) + .expect("Pi write should establish a scope"); + + let codex_pre = json!({ + "hook_event_name": "PreToolUse", + "session_id": codex_session, + "turn_id": "codex-pi-overlap-turn", + "cwd": cwd, + "tool_name": "Bash", + "tool_use_id": "codex-pi-overlap-bash", + "model": "gpt-5.6-sol", + "tool_input": {"command": "true"}, + }); + codex_mutation_scope::run_codex_mutation_scope_from_payload_at_state_root( + &repo.state_root, + &codex_pre.to_string(), + None, + ) + .expect("Codex Bash PreToolUse should establish a concurrent scope"); + + repo.write_change("one\npi codex overlap mutation\n"); + + drive_pi(&repo, &pi_tool_result(&cwd, pi_session, "call-pi", "write")) + .expect("Pi ToolResult should mark the attempt executed"); + drive_pi( + &repo, + &pi_tool_execution_end(&cwd, pi_session, "call-pi", "write"), + ) + .expect("Pi ToolExecutionEnd should attempt to confirm its own scope"); + + let attribution_after_first_close = repo.mutation_events(); + assert_eq!( + attribution_after_first_close + .last() + .map(|(kind, _)| kind.as_str()), + Some("ineligible_unscoped"), + "an unconfirmed live Codex scope must suppress Pi's own confirming close" + ); + + let codex_post = json!({ + "hook_event_name": "PostToolUse", + "session_id": codex_session, + "turn_id": "codex-pi-overlap-turn", + "cwd": cwd, + "tool_name": "Bash", + "tool_use_id": "codex-pi-overlap-bash", + }); + codex_mutation_scope::run_codex_mutation_scope_from_payload_at_state_root( + &repo.state_root, + &codex_post.to_string(), + None, + ) + .expect("Codex PostToolUse should close its own scope"); + + drive_pi( + &repo, + &pi_tool_call( + &cwd, + pi_session, + "call-pi-2", + "write", + Some("anthropic/opus-5"), + ), + ) + .expect("a fresh Pi write should establish a new scope"); + repo.write_change("one\npi codex overlap mutation\nsecond change\n"); + drive_pi( + &repo, + &pi_tool_result(&cwd, pi_session, "call-pi-2", "write"), + ) + .expect("Pi ToolResult should mark the fresh attempt executed"); + drive_pi( + &repo, + &pi_tool_execution_end(&cwd, pi_session, "call-pi-2", "write"), + ) + .expect("the fresh Pi scope should close cleanly once Codex is confirmed"); + + let attribution_after_second_close = repo.mutation_events(); + assert_eq!( + attribution_after_second_close + .last() + .map(|(kind, _)| kind.as_str()), + Some("ai_exclusive"), + "once every other live scope is confirmation-safe, a solo confirming close is AiExclusive" + ); + } + + #[test] + fn pi_and_opencode_overlap_stays_ineligible_until_opencode_confirms() { + let repo = ProvenanceE2eRepo::new("pi-opencode-overlap"); + let pi_session = "ses-pi-oc-overlap"; + let oc_session = "ses_opencode_pi_overlap"; + let cwd = repo.cwd(); + + drive_pi( + &repo, + &pi_tool_call( + &cwd, + pi_session, + "call-pi", + "write", + Some("anthropic/opus-5"), + ), + ) + .expect("Pi write should establish a scope"); + + drive_opencode( + &repo, + &opencode_before( + &cwd, + oc_session, + "call_oc", + "write", + Some("opencode/big-pickle"), + ), + ) + .expect("OpenCode write ToolExecuteBefore should establish a concurrent scope"); + + repo.write_change("one\npi opencode overlap mutation\n"); + + drive_pi(&repo, &pi_tool_result(&cwd, pi_session, "call-pi", "write")) + .expect("Pi ToolResult should mark the attempt executed"); + drive_pi( + &repo, + &pi_tool_execution_end(&cwd, pi_session, "call-pi", "write"), + ) + .expect("Pi ToolExecutionEnd should attempt to confirm its own scope"); + + let attribution_after_first_close = repo.mutation_events(); + assert_eq!( + attribution_after_first_close + .last() + .map(|(kind, _)| kind.as_str()), + Some("ineligible_unscoped"), + "an unconfirmed live OpenCode scope must suppress Pi's own confirming close" + ); + + drive_opencode(&repo, &opencode_after(&cwd, oc_session, "call_oc", "write")) + .expect("OpenCode ToolExecuteAfter should close its own scope"); + + drive_pi( + &repo, + &pi_tool_call( + &cwd, + pi_session, + "call-pi-2", + "write", + Some("anthropic/opus-5"), + ), + ) + .expect("a fresh Pi write should establish a new scope"); + repo.write_change("one\npi opencode overlap mutation\nsecond change\n"); + drive_pi( + &repo, + &pi_tool_result(&cwd, pi_session, "call-pi-2", "write"), + ) + .expect("Pi ToolResult should mark the fresh attempt executed"); + drive_pi( + &repo, + &pi_tool_execution_end(&cwd, pi_session, "call-pi-2", "write"), + ) + .expect("the fresh Pi scope should close cleanly once OpenCode is confirmed"); + + let attribution_after_second_close = repo.mutation_events(); + assert_eq!( + attribution_after_second_close + .last() + .map(|(kind, _)| kind.as_str()), + Some("ai_exclusive"), + "once every other live scope is confirmation-safe, a solo confirming close is AiExclusive" + ); + } + + #[test] + fn pi_stale_process_recovery_discards_ambiguous_interval_while_fresh_pi_work_remains_usable() { + let repo = ProvenanceE2eRepo::new("pi-stale-recovery"); + let stale_session = "ses-pi-stale"; + let fresh_session = "ses-pi-fresh"; + let cwd = repo.cwd(); + + drive_pi( + &repo, + &pi_tool_call( + &cwd, + stale_session, + "call-stale", + "bash", + Some("anthropic/opus-5"), + ), + ) + .expect("the stale attempt's Pi ToolCall should establish a scope"); + + let git_dir = resolve_git_dir(&repo.root).expect("git dir should resolve"); + let scope_id = pi_mutation_scope::state::read_state(&git_dir) + .expect("state should be readable") + .attempts + .iter() + .find(|attempt| attempt.session_id == stale_session) + .expect("the stale attempt should exist") + .scope_id + .clone(); + pi_mutation_scope::force_attempt_owner_dead_for_tests(&git_dir, &scope_id); + + fs::write( + repo.root.join("ambiguous.txt"), + "left behind by the dead Pi process\n", + ) + .expect("the stale attempt's own mutation should still land on disk"); + + drive_pi( + &repo, + &pi_tool_call( + &cwd, + fresh_session, + "call-fresh", + "write", + Some("anthropic/opus-5"), + ), + ) + .expect("a fresh Pi ToolCall should trigger dead-owner recovery and then establish its own scope"); + + fs::write(repo.root.join("confirmed.txt"), "the fresh Pi work\n") + .expect("the fresh attempt's mutation should write"); + + drive_pi( + &repo, + &pi_tool_result(&cwd, fresh_session, "call-fresh", "write"), + ) + .expect("the fresh attempt's ToolResult should mark it executed"); + drive_pi( + &repo, + &pi_tool_execution_end(&cwd, fresh_session, "call-fresh", "write"), + ) + .expect("the fresh attempt should close and reach AiExclusive"); + + git(&repo.root, &["add", "-A"]); + git(&repo.root, &["commit", "-qm", "stale recovery"]); + + let db = repo.db(); + let post_commit_data = capture_post_commit_patch_from_git(&repo.root) + .expect("capturing the post-commit patch should succeed"); + let mutation_ai_patch = resolve_post_commit_mutation_ai_patch( + &repo.root, + &db, + &ParsedPatch { files: Vec::new() }, + &post_commit_data.parsed_patch, + ); + + let ai_paths: Vec<&str> = mutation_ai_patch + .files + .iter() + .map(|file| file.new_path.as_str()) + .collect(); + assert!( + !ai_paths.contains(&"ambiguous.txt"), + "the dead process's ambiguous interval must never be attributed AI" + ); + assert!( + ai_paths.contains(&"confirmed.txt"), + "later fresh Pi work must remain usable and reach AiExclusive" + ); + + let attribution = repo.mutation_events(); + assert_eq!( + attribution.last().map(|(kind, _)| kind.as_str()), + Some("ai_exclusive"), + "the fresh attempt, unencumbered by the recovered stale scope, should reach AiExclusive" + ); + } +} + +#[test] +fn post_commit_auto_sync_does_not_launch_when_disabled() { + let launch_called = RefCell::new(false); + + run_post_commit_subcommand_with( + Path::new("/repo"), + None, + "", + |_| Ok(post_commit_flow_result()), + |_, _, _, _| Ok(minimal_agent_trace()), + |_| Ok(false), + |_| { + *launch_called.borrow_mut() = true; + Ok(()) + }, + |_| Ok(()), + None, + ) + .expect("disabled auto-sync should not affect post-commit success"); + + assert!(!*launch_called.borrow()); +} + +#[test] +fn post_commit_persistence_failure_does_not_launch_auto_sync() { + let launch_called = RefCell::new(false); + + let error = run_post_commit_subcommand_with( + Path::new("/repo"), + None, + "", + |_| Ok(post_commit_flow_result()), + |_, _, _, _| Err(anyhow!("Agent Trace persistence failed")), + |_| panic!("auto-sync config must not be resolved after persistence failure"), + |_| { + *launch_called.borrow_mut() = true; + Ok(()) + }, + |_| panic!("checkpoint must not run after persistence failure"), + None, + ) + .expect_err("persistence failure should be returned"); + + assert!(error.to_string().contains("persistence failed")); + assert!(!*launch_called.borrow()); +} + +#[test] +fn post_commit_auto_sync_launcher_failure_is_fail_open() { + let output = run_post_commit_subcommand_with( + Path::new("/repo"), + None, + "", + |_| Ok(post_commit_flow_result()), + |_, _, _, _| Ok(minimal_agent_trace()), + |_| Ok(true), + |_| Err(anyhow!("spawn unavailable")), + |_| Ok(()), + None, + ) + .expect("launcher failure must not affect post-commit success"); + + assert!(output.contains("post-commit hook processed intersection")); +} + +#[derive(Default)] +struct RecordingLogger { + warnings: std::sync::Mutex>, +} + +impl Logger for RecordingLogger { + fn info( + &self, + _event_id: &str, + _message: &str, + _fields: &[(&str, &str)], + _session_id: Option<&str>, + ) { + } + + fn debug( + &self, + _event_id: &str, + _message: &str, + _fields: &[(&str, &str)], + _session_id: Option<&str>, + ) { + } + + fn warn( + &self, + event_id: &str, + message: &str, + _fields: &[(&str, &str)], + _session_id: Option<&str>, + ) { + self.warnings + .lock() + .expect("warnings mutex should not be poisoned") + .push((event_id.to_string(), message.to_string())); + } + + fn error( + &self, + _event_id: &str, + _message: &str, + _fields: &[(&str, &str)], + _session_id: Option<&str>, + ) { + } + + fn log_cli_error(&self, _error: &crate::services::error::CliError, _session_id: Option<&str>) {} +} + +#[test] +fn post_commit_checkpoint_runs_once_after_successful_persistence() { + let events = RefCell::new(Vec::new()); + + let output = run_post_commit_subcommand_with( + Path::new("/repo"), + None, + "", + |_| Ok(post_commit_flow_result()), + |_, _, _, _| { + events.borrow_mut().push("persistence"); + Ok(minimal_agent_trace()) + }, + |_| Ok(false), + |_| Ok(()), + |_| { + events.borrow_mut().push("checkpoint"); + Ok(()) + }, + None, + ) + .expect("successful checkpoint should not affect post-commit success"); + + assert!(output.contains("post-commit hook processed intersection")); + assert_eq!(events.into_inner(), vec!["persistence", "checkpoint"]); +} + +#[test] +fn post_commit_checkpoint_failure_is_fail_open_and_logs_warning() { + let logger = RecordingLogger::default(); + let persisted = RefCell::new(false); + + let output = run_post_commit_subcommand_with( + Path::new("/repo"), + None, + "", + |_| Ok(post_commit_flow_result()), + |_, _, _, _| { + *persisted.borrow_mut() = true; + Ok(minimal_agent_trace()) + }, + |_| Ok(false), + |_| Ok(()), + |_| Err(anyhow!("checkpoint failed")), + Some(&logger), + ) + .expect("checkpoint failure must not affect post-commit success"); + + assert!(output.contains("post-commit hook processed intersection")); + assert!(*persisted.borrow()); + assert_eq!( + logger + .warnings + .into_inner() + .expect("warnings mutex should not be poisoned"), + vec![( + String::from("sce.agent_trace_db.passive_checkpoint_failed"), + String::from("checkpoint failed") + )] + ); +} diff --git a/cli/src/services/mutation_trace/runtime/git_snapshot.rs b/cli/src/services/mutation_trace/runtime/git_snapshot.rs index 2e1614209..0a0379bc2 100644 --- a/cli/src/services/mutation_trace/runtime/git_snapshot.rs +++ b/cli/src/services/mutation_trace/runtime/git_snapshot.rs @@ -26,10 +26,11 @@ pub struct GitSnapshotService { impl GitSnapshotService { pub fn new(repository_root: &Path) -> Result { - let git_dir = resolve_git_dir(repository_root)?; + let repository_root = resolve_worktree_root(repository_root)?; + let git_dir = resolve_git_dir(&repository_root)?; Ok(GitSnapshotService { git_dir, - repository_root: repository_root.to_path_buf(), + repository_root, }) } @@ -713,6 +714,37 @@ mod tests { assert!(!ls_tree.contains("ignored.txt")); } + #[test] + fn capture_from_nested_directory_uses_worktree_root_for_gitignore_rules() { + let repo = test_repo("nested-directory-ignore"); + let repo_root = repo.root().to_path_buf(); + let nested_root = repo_root.join("cli"); + init_repo(&repo_root); + std::fs::write(repo_root.join(".gitignore"), b"cli/target/\n") + .expect(".gitignore should be writable"); + std::fs::write(repo_root.join("README.md"), b"tracked\n") + .expect("README should be writable"); + commit_all(&repo_root, "add ignore rule"); + + std::fs::create_dir_all(nested_root.join("target")) + .expect("nested target directory should be creatable"); + std::fs::write(nested_root.join("target/artifact"), b"ignored\n") + .expect("ignored artifact should be writable"); + + let service = GitSnapshotService::new(&nested_root) + .expect("service should resolve the containing worktree root"); + let tree = service + .capture_tree() + .expect("capture should succeed from a nested directory"); + + let ls_tree = run(&repo_root, &["ls-tree", "-r", "--name-only", &tree.0]); + assert!(ls_tree.contains("README.md")); + assert!( + !ls_tree.lines().any(|path| path == "cli/target/artifact"), + "ignored files below cli/target must not enter a snapshot: {ls_tree}" + ); + } + #[test] fn capture_reflects_deletion_of_a_committed_file() { let repo = test_repo("deletion"); diff --git a/cli/src/services/setup/command.rs b/cli/src/services/setup/command.rs index 86985dc5e..b48d2af6e 100644 --- a/cli/src/services/setup/command.rs +++ b/cli/src/services/setup/command.rs @@ -20,15 +20,11 @@ impl SetupCommand { .map_err(unexpected_failure)?, }; - // The repository root is resolved before any prompt so the interactive - // optional-workflow prompt can pre-check the persisted selection. let repository_root = resolve_setup_repository(&setup_start_path)?; let setup_dispatch = if self.request.context_only { None } else if let Some(mode) = self.request.config_mode { - // A supplied `--workflow` selection seeds the prompt; otherwise the - // repository's persisted selection does. let optional_workflow_defaults = match &self.request.optional_workflows { Some(selection) => selection.clone(), None => setup::persisted_optional_workflows(&repository_root), @@ -62,7 +58,6 @@ impl SetupCommand { let mut sections = Vec::new(); - // Every successful setup path ensures the durable-context baseline exists. let context_message = setup::bootstrap_context_baseline(&repository_root).map_err(unexpected_failure)?; sections.push(context_message); @@ -71,11 +66,8 @@ impl SetupCommand { return Ok(sections.join("\n\n")); } - // Scope the runtime AppContext to the resolved repository root for lifecycle providers. let ctx = context.with_repo_root(repository_root.clone()); - // Aggregate setup steps from lifecycle providers in order: - // config → local_db → auth_db → agent_trace_db → hooks (when requested). let providers = lifecycle_providers(self.request.install_hooks); for provider in &providers { @@ -90,7 +82,6 @@ impl SetupCommand { } } - // Handle config target installation (OpenCode/Claude assets). if let Some(( resolved_mode, prompted_optional_workflows, @@ -98,8 +89,6 @@ impl SetupCommand { attribution_hooks_enabled, )) = setup_dispatch { - // A prompted selection is authoritative for the run; without one the - // `--workflow` selection (or, absent that, the persisted one) applies. let optional_workflows = prompted_optional_workflows .as_deref() .or(self.request.optional_workflows.as_deref()); diff --git a/cli/src/services/setup/config_merge.rs b/cli/src/services/setup/config_merge.rs index 1d703f466..e8457b13e 100644 --- a/cli/src/services/setup/config_merge.rs +++ b/cli/src/services/setup/config_merge.rs @@ -1,29 +1,11 @@ -//! Pure JSON merge for setup-installed config files that a user may already own -//! and extend. Two known shapes today: Claude's `.claude/settings.json` hook -//! registry and `OpenCode`'s `.opencode/opencode.json` plugin registry. Each -//! merge keeps every non-SCE key and entry untouched, and replaces SCE-owned -//! content wholesale so repeated installs stay idempotent. - use anyhow::{Context, Result}; use serde_json::Value; -/// Substring identifying an SCE-authored Claude hook command -/// (`config/pkl/renderers/claude-content.pkl`). const CLAUDE_SCE_HOOK_MARKER: &str = "run-sce-or-show-install-guidance.sh"; const LEGACY_CLAUDE_AGENT_TRACE_PLUGIN: &str = ".claude/plugins/sce-agent-trace.ts"; -/// Path prefix identifying an SCE-authored `OpenCode` plugin registration -/// (`config/pkl/base/opencode.pkl`), matched structurally so a plugin path an -/// older or renamed catalog installed is still recognized as SCE-owned even -/// though the current generated document no longer declares it. const OPENCODE_SCE_PLUGIN_PREFIX: &str = "./plugins/sce-"; -/// Merges `generated` (the freshly rendered SCE settings document) into -/// `existing_bytes` (the user's current `.claude/settings.json`, if any) and -/// returns the merged document's bytes, pretty-printed with a trailing -/// newline. When `existing_bytes` is `None`, returns `generated` verbatim. -/// -/// `source_path` is used only to name the offending file in a parse error. pub fn merge_or_create_claude_settings( existing_bytes: Option<&[u8]>, generated_bytes: &[u8], @@ -47,14 +29,6 @@ pub fn merge_or_create_claude_settings( Ok(serialized.into_bytes()) } -/// Merges `generated` into `existing` for the Claude settings shape: -/// - `$schema` is SCE-owned and taken from `generated`. -/// - `hooks` is merged event-by-event: for each event key `generated.hooks` -/// declares, entries in `existing.hooks[event]` whose command contains the -/// SCE marker are dropped, and `generated.hooks[event]`'s entries are -/// appended after the surviving (non-SCE) entries. Event keys `existing` -/// holds that `generated` does not declare are left untouched. -/// - Every other top-level key in `existing` is left untouched. fn merge_claude_settings(existing: &Value, generated: &Value, source_path: &str) -> Result { let mut existing_obj = existing.as_object().cloned().with_context(|| { format!("Existing config file '{source_path}' must contain a top-level JSON object.") @@ -132,12 +106,6 @@ fn hook_is_legacy_claude_agent_trace(hook: &Value) -> bool { == Some(LEGACY_CLAUDE_AGENT_TRACE_PLUGIN) } -/// Merges `generated` (the freshly rendered SCE `OpenCode` config) into -/// `existing_bytes` (the user's current `.opencode/opencode.json`, if any) and -/// returns the merged document's bytes, pretty-printed with a trailing -/// newline. When `existing_bytes` is `None`, returns `generated` verbatim. -/// -/// `source_path` is used only to name the offending file in a parse error. pub fn merge_or_create_opencode_config( existing_bytes: Option<&[u8]>, generated_bytes: &[u8], @@ -161,13 +129,6 @@ pub fn merge_or_create_opencode_config( Ok(serialized.into_bytes()) } -/// Merges `generated` into `existing` for the `OpenCode` config shape: -/// - `$schema` is SCE-owned and taken from `generated`. -/// - `plugin` is merged as a set: entries in `existing.plugin` shaped like an -/// SCE plugin path are dropped (whether or not `generated.plugin` still -/// declares them), and `generated.plugin`'s entries are appended after the -/// surviving (non-SCE) entries. -/// - Every other top-level key in `existing` is left untouched. fn merge_opencode_config(existing: &Value, generated: &Value, source_path: &str) -> Result { let mut existing_obj = existing.as_object().cloned().with_context(|| { format!("Existing config file '{source_path}' must contain a top-level JSON object.") @@ -204,19 +165,12 @@ fn merge_opencode_config(existing: &Value, generated: &Value, source_path: &str) Ok(Value::Object(existing_obj)) } -/// True when a `plugin` array entry is a string shaped like an SCE plugin -/// registration path (`./plugins/sce-*`). fn plugin_entry_is_sce_owned(entry: &Value) -> bool { entry .as_str() .is_some_and(|path| path.starts_with(OPENCODE_SCE_PLUGIN_PREFIX)) } -/// True when merging `generated` into `existing_bytes` would be a no-op, i.e. -/// `existing_bytes` already carries a current, complete copy of every -/// SCE-owned hook entry the generated document declares. Used by `sce doctor` -/// to tell a merged file that legitimately carries extra user content apart -/// from an SCE-owned fragment that is missing or stale. pub(crate) fn claude_settings_fragment_is_current( existing_bytes: &[u8], generated_bytes: &[u8], @@ -230,10 +184,6 @@ pub(crate) fn claude_settings_fragment_is_current( Ok(merged == existing) } -/// True when merging `generated` into `existing_bytes` would be a no-op, i.e. -/// `existing_bytes` already carries every canonical SCE plugin path the -/// generated document declares and no stale SCE-shaped plugin path. Used by -/// `sce doctor` for the same purpose as `claude_settings_fragment_is_current`. pub(crate) fn opencode_config_fragment_is_current( existing_bytes: &[u8], generated_bytes: &[u8], diff --git a/cli/src/services/setup/hook_merge.rs b/cli/src/services/setup/hook_merge.rs index 452ab5af5..4f3f1944d 100644 --- a/cli/src/services/setup/hook_merge.rs +++ b/cli/src/services/setup/hook_merge.rs @@ -1,64 +1,26 @@ -//! Pure byte-level merge for git hooks that a repository may already own and -//! extend with its own script (husky, lefthook, or a hand-written hook). -//! Mirrors `config_merge.rs`'s approach for the two JSON merge targets: no -//! filesystem access, a classification of what happened, and idempotence -//! across repeated merges. -//! -//! SCE's logic in a hook lives inside a stable marker pair, -//! `MANAGED_BLOCK_START` / `MANAGED_BLOCK_END`. A hook that already carries -//! the pair is owned within that block; a hook predating the markers is -//! recognized as SCE-owned wholesale by the presence of the canonical -//! guidance URL and replaced entirely; any other hook is foreign, and its -//! bytes are kept as an exact prefix with the canonical block appended after -//! them. - use anyhow::{bail, Result}; -/// Opening marker line of the SCE managed block, matched as an exact line -/// (`config/pkl/renderers/*-hooks.pkl` templates emit it verbatim). pub const MANAGED_BLOCK_START: &str = "# >>> sce managed block (do not edit) >>>"; -/// Closing marker line of the SCE managed block. pub const MANAGED_BLOCK_END: &str = "# <<< sce managed block <<<"; -/// Substring identifying a pre-marker SCE hook payload, from before the -/// managed block existed: the CLI installation guidance URL every canonical -/// template has always printed when `sce` is missing. const LEGACY_GUIDANCE_URL: &str = "https://sce.crocoder.dev/docs/getting-started#install-cli"; -/// What `merge_or_create_hook` did to produce its output bytes. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum HookMergeKind { - /// No hook existed; the canonical template was used verbatim. Created, - /// A hook already carried a managed block, and the block's content - /// differed from the canonical block, or the hook was a legacy - /// pre-marker SCE payload replaced wholesale. ManagedBlockReplaced, - /// A foreign hook without a managed block was kept, with the canonical - /// block appended after its content. AppendedToForeign, - /// A hook already carried a managed block identical to the canonical - /// one; the input bytes are returned unchanged. AlreadyCurrent, } -/// Result of merging a hook's existing bytes with the canonical template. #[derive(Clone, Debug, Eq, PartialEq)] pub struct HookMerge { - /// The bytes to install. pub bytes: Vec, - /// What kind of merge produced `bytes`. pub kind: HookMergeKind, - /// True when `kind` is `AppendedToForeign` and the foreign hook's last - /// effective line is a zero-indent `exec` or `exit`, so the appended - /// block would never run. pub unreachable_block_advisory: bool, } -/// Computes the bytes to install for a git hook named `hook_name`, given its -/// current bytes (`existing`, `None` when no hook exists) and the canonical -/// template (`canonical`). Performs no filesystem access. pub fn merge_or_create_hook( existing: Option<&[u8]>, canonical: &[u8], @@ -131,10 +93,6 @@ pub fn merge_or_create_hook( } } -/// Where the SCE managed block's marker lines were found in a byte buffer, -/// as byte offsets: `Balanced(start, end)` is the offset of the start -/// marker line's first byte and the offset just past the end marker line's -/// trailing newline (or end of buffer). enum BlockLocation { Absent, Balanced(usize, usize), @@ -151,10 +109,6 @@ fn locate_block(bytes: &[u8]) -> BlockLocation { } } -/// Finds the line in `bytes` whose content, with a trailing `\r?\n` -/// stripped, is exactly `marker`. Returns `(line_start, line_end)` byte -/// offsets, where `line_end` includes the line's own trailing newline (or is -/// the buffer length for a final line with none). fn locate_marker_line(bytes: &[u8], marker: &str) -> Option<(usize, usize)> { let mut offset = 0; for line in bytes.split_inclusive(|&byte| byte == b'\n') { @@ -168,10 +122,6 @@ fn locate_marker_line(bytes: &[u8], marker: &str) -> Option<(usize, usize)> { None } -/// True when the last non-blank, non-comment line of `text` sits at zero -/// indentation and starts with `exec ` or `exit` — a narrow heuristic (no -/// shell parsing) for "a block appended after this line would not run". -/// Deliberately misses an early `exit` guarded by a conditional. fn ends_with_unreachable_control_flow(text: &str) -> bool { let Some(line) = text.lines().rev().find(|line| { let trimmed = line.trim(); diff --git a/cli/src/services/setup/install.rs b/cli/src/services/setup/install.rs new file mode 100644 index 000000000..986a4c531 --- /dev/null +++ b/cli/src/services/setup/install.rs @@ -0,0 +1,776 @@ +use anyhow::{bail, Context, Result}; +use std::{ + fs, io, + path::{Component, Path, PathBuf}, + process::Command, + time::{SystemTime, UNIX_EPOCH}, +}; + +use crate::services::codex_hook_config; +use crate::services::default_paths::InstallTargetPaths; +use crate::services::security::{ensure_directory_is_writable, redact_sensitive_text}; + +use super::config_merge; +use super::hook_merge; +use super::{ + classify_git_exit, cleanup_path_if_exists, concrete_targets_for, + embedded_assets_for_concrete_target, hook_install_recovery_guidance, + iter_embedded_assets_for_setup_target_with_selection, iter_required_hook_assets, + setup_install_recovery_guidance, EmbeddedAsset, GitExitKind, GitRepositoryResolutionError, + RequiredHookInstallResult, RequiredHookInstallStatus, RequiredHooksInstallOutcome, + SetupInstallOutcome, SetupInstallTargetResult, SetupTarget, +}; +use crate::services::default_paths; +use crate::services::default_paths::claude_asset; + +pub(super) fn prepare_setup_hooks_repository(repository_root: &Path) -> Result { + let normalized_repository_root = normalize_user_repository_path(repository_root)?; + Ok(resolve_git_repository_root(&normalized_repository_root)?) +} + +pub(super) fn ensure_git_repository( + directory: &Path, +) -> Result { + resolve_git_repository_root(directory) +} + +pub(super) fn install_required_git_hooks( + repository_root: &Path, +) -> Result { + install_required_git_hooks_with_rename(repository_root, |from, to| fs::rename(from, to)) +} + +pub(super) fn install_required_git_hooks_with_rename( + repository_root: &Path, + rename_fn: F, +) -> Result +where + F: FnMut(&Path, &Path) -> io::Result<()>, +{ + let resolved_repository_root = prepare_setup_hooks_repository(repository_root)?; + install_required_git_hooks_in_resolved_repository(&resolved_repository_root, rename_fn) +} + +pub(super) fn install_embedded_setup_assets( + repository_root: &Path, + target: SetupTarget, + selected_optional_workflows: &[String], +) -> Result { + install_embedded_setup_assets_with_rename( + repository_root, + target, + selected_optional_workflows, + |from, to| fs::rename(from, to), + ) +} + +pub(super) fn repair_merge_target_asset( + repository_root: &Path, + target: SetupTarget, + relative_path: &str, +) -> Result<()> { + let asset = embedded_assets_for_concrete_target(target) + .iter() + .find(|asset| asset.relative_path == relative_path) + .with_context(|| { + format!("No embedded asset named '{relative_path}' for target {target:?}") + })?; + + let install_targets = InstallTargetPaths::new(repository_root); + let destination_root = match target { + SetupTarget::OpenCode => install_targets.opencode_target_dir(), + SetupTarget::Claude => install_targets.claude_target_dir(), + SetupTarget::Pi => install_targets.pi_target_dir(), + SetupTarget::Codex => install_targets.codex_target_dir(), + SetupTarget::All => unreachable!("meta targets are expanded into concrete targets"), + }; + + install_single_asset_with_rename(target, &destination_root, asset, &mut |from, to| { + fs::rename(from, to) + }) +} + +fn install_required_git_hooks_in_resolved_repository( + resolved_repository_root: &Path, + mut rename_fn: F, +) -> Result +where + F: FnMut(&Path, &Path) -> io::Result<()>, +{ + ensure_directory_is_writable(resolved_repository_root, "repository root")?; + let hooks_directory = resolve_git_hooks_directory(resolved_repository_root)?; + fs::create_dir_all(&hooks_directory).with_context(|| { + format!( + "Failed to create git hooks directory '{}'", + hooks_directory.display() + ) + })?; + ensure_directory_is_writable(&hooks_directory, "git hooks directory")?; + + let mut hook_results = Vec::new(); + for hook_asset in iter_required_hook_assets() { + let hook_result = + install_single_required_hook_with_rename(&hooks_directory, hook_asset, &mut rename_fn)?; + hook_results.push(hook_result); + } + + Ok(RequiredHooksInstallOutcome { + repository_root: resolved_repository_root.to_path_buf(), + hooks_directory, + hook_results, + }) +} + +fn install_single_required_hook_with_rename( + hooks_directory: &Path, + hook_asset: &EmbeddedAsset, + rename_fn: &mut F, +) -> Result +where + F: FnMut(&Path, &Path) -> io::Result<()>, +{ + validate_embedded_relative_path(hook_asset.relative_path)?; + + let hook_path = hooks_directory.join(hook_asset.relative_path); + let existing_metadata = fs::metadata(&hook_path).ok(); + + let existing_bytes = + if existing_metadata + .as_ref() + .is_some_and(std::fs::Metadata::is_file) + { + Some(fs::read(&hook_path).with_context(|| { + format!("Failed to read existing hook '{}'", hook_path.display()) + })?) + } else if existing_metadata.is_some() { + bail!( + "Existing hook target '{}' is not a file", + hook_path.display() + ); + } else { + None + }; + + let merge = hook_merge::merge_or_create_hook( + existing_bytes.as_deref(), + hook_asset.bytes, + hook_asset.relative_path, + )?; + + if let Some(existing_bytes) = existing_bytes.as_deref() { + let executable = is_executable_file(&hook_path)?; + if merge.bytes == existing_bytes && executable { + return Ok(RequiredHookInstallResult { + hook_name: hook_asset.relative_path.to_string(), + hook_path, + status: RequiredHookInstallStatus::Skipped, + unreachable_block_advisory: merge.unreachable_block_advisory, + }); + } + } + + let had_existing_hook = existing_metadata.is_some(); + + let hook_staging_path = create_hook_staging_path(hooks_directory, hook_asset.relative_path)?; + if let Err(error) = write_hook_payload_to_staging(&hook_staging_path, &merge.bytes) { + cleanup_path_if_exists(&hook_staging_path); + return Err(error); + } + + let action = if had_existing_hook { + "update" + } else { + "install" + }; + if let Err(error) = rename_fn(&hook_staging_path, &hook_path).with_context(|| { + format!( + "Failed to {action} required hook '{}' at '{}'", + hook_asset.relative_path, + hook_path.display() + ) + }) { + cleanup_path_if_exists(&hook_staging_path); + let error = if had_existing_hook { + error.context(hook_install_recovery_guidance(&hook_path)) + } else { + error + }; + return Err(error); + } + + Ok(RequiredHookInstallResult { + hook_name: hook_asset.relative_path.to_string(), + hook_path, + status: if had_existing_hook { + RequiredHookInstallStatus::Updated + } else { + RequiredHookInstallStatus::Installed + }, + unreachable_block_advisory: merge.unreachable_block_advisory, + }) +} + +fn write_hook_payload_to_staging(staging_path: &Path, bytes: &[u8]) -> Result<()> { + fs::write(staging_path, bytes).with_context(|| { + format!( + "Failed to write staged hook payload '{}'", + staging_path.display() + ) + })?; + ensure_executable_permissions(staging_path)?; + Ok(()) +} + +fn create_hook_staging_path(hooks_directory: &Path, hook_name: &str) -> Result { + let epoch_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("System clock is before UNIX_EPOCH")? + .as_nanos(); + let sanitized_hook_name = hook_name.replace('/', "-"); + + for attempt in 0..1000_u16 { + let candidate = hooks_directory.join(format!( + ".sce-hook-staging-{sanitized_hook_name}-{epoch_nanos}-{}-{attempt}", + std::process::id() + )); + + match fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&candidate) + { + Ok(_) => return Ok(candidate), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} + Err(error) => { + return Err(error).with_context(|| { + format!( + "Failed to allocate hook staging file '{}'", + candidate.display() + ) + }); + } + } + } + + bail!( + "Could not allocate a unique hook staging file under '{}'", + hooks_directory.display() + ) +} + +fn normalize_user_repository_path(repository_root: &Path) -> Result { + if repository_root.as_os_str().is_empty() { + bail!("Option '--repo' must not be empty. Try: pass a path to an existing git repository."); + } + + let canonical_repository_root = fs::canonicalize(repository_root).with_context(|| { + format!( + "Failed to resolve repository path '{}'. Try: pass a path to an existing git repository.", + repository_root.display() + ) + })?; + + let metadata = fs::metadata(&canonical_repository_root).with_context(|| { + format!( + "Failed to inspect repository path '{}'.", + canonical_repository_root.display() + ) + })?; + + if !metadata.is_dir() { + bail!( + "Repository path '{}' is not a directory. Try: pass a path to an existing git repository.", + canonical_repository_root.display() + ); + } + + Ok(canonical_repository_root) +} + +fn resolve_git_repository_root( + repository_root: &Path, +) -> Result { + let repository_root_output = run_git_command_in_directory( + repository_root, + &["rev-parse", "--show-toplevel"], + "Failed to resolve repository root. Ensure '--repo' points to an accessible git repository.", + ) + .map_err(map_setup_repository_resolution_error)?; + Ok(PathBuf::from(repository_root_output)) +} + +fn map_setup_repository_resolution_error(error: GitCommandError) -> GitRepositoryResolutionError { + let is_not_repository = matches!( + &error, + GitCommandError::NonZeroExit { + kind: GitExitKind::NotRepository, + .. + } + ); + let source = anyhow::Error::new(error); + + if is_not_repository { + GitRepositoryResolutionError::NotGitRepository(source) + } else { + GitRepositoryResolutionError::Unexpected(source) + } +} + +fn resolve_git_hooks_directory(repository_root: &Path) -> Result { + let hooks_directory_output = run_git_command_in_directory( + repository_root, + &["rev-parse", "--git-path", "hooks"], + "Failed to resolve effective git hooks path.", + )?; + + let hooks_directory = PathBuf::from(&hooks_directory_output); + if hooks_directory.is_absolute() { + return Ok(hooks_directory); + } + + Ok(repository_root.join(hooks_directory)) +} + +#[derive(Debug)] +enum GitCommandError { + Spawn { + context: String, + directory: PathBuf, + source: std::io::Error, + }, + NonZeroExit { + context: String, + directory: PathBuf, + status: std::process::ExitStatus, + kind: GitExitKind, + diagnostic: String, + }, + InvalidUtf8 { + context: String, + source: std::string::FromUtf8Error, + }, + EmptyOutput { + context: String, + directory: PathBuf, + }, +} + +impl std::fmt::Display for GitCommandError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Spawn { + context, + directory, + source, + } => write!( + f, + "{context} (directory: '{}'): {source}", + directory.display() + ), + Self::NonZeroExit { + context, + directory, + status, + diagnostic, + .. + } => write!( + f, + "{context} (directory: '{}', status: {status:?}) {diagnostic}", + directory.display() + ), + Self::InvalidUtf8 { context, source } => { + write!( + f, + "{context}: git command output contained invalid UTF-8: {source}" + ) + } + Self::EmptyOutput { context, directory } => write!( + f, + "{context} (directory: '{}'): git command returned empty output", + directory.display() + ), + } + } +} + +impl std::error::Error for GitCommandError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Spawn { source, .. } => Some(source), + Self::InvalidUtf8 { source, .. } => Some(source), + Self::NonZeroExit { .. } | Self::EmptyOutput { .. } => None, + } + } +} + +fn run_git_command_in_directory( + repository_root: &Path, + args: &[&str], + context_message: &str, +) -> std::result::Result { + let output = Command::new("git") + .env("LC_ALL", "C") + .env("LANG", "C") + .env_remove("LANGUAGE") + .args(args) + .current_dir(repository_root) + .output() + .map_err(|source| GitCommandError::Spawn { + context: context_message.to_string(), + directory: repository_root.to_path_buf(), + source, + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let kind = classify_git_exit(&stderr); + let diagnostic = if stderr.is_empty() { + String::from("git command exited with a non-zero status") + } else { + redact_sensitive_text(&stderr) + }; + return Err(GitCommandError::NonZeroExit { + context: context_message.to_string(), + directory: repository_root.to_path_buf(), + status: output.status, + kind, + diagnostic, + }); + } + + let stdout = + String::from_utf8(output.stdout).map_err(|source| GitCommandError::InvalidUtf8 { + context: context_message.to_string(), + source, + })?; + let stdout = stdout.trim().to_string(); + if stdout.is_empty() { + return Err(GitCommandError::EmptyOutput { + context: context_message.to_string(), + directory: repository_root.to_path_buf(), + }); + } + + Ok(stdout) +} + +#[cfg(unix)] +fn ensure_executable_permissions(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + let metadata = fs::metadata(path) + .with_context(|| format!("Failed to read metadata for '{}'", path.display()))?; + let mut permissions = metadata.permissions(); + permissions.set_mode(permissions.mode() | 0o111); + fs::set_permissions(path, permissions).with_context(|| { + format!( + "Failed to set executable permissions for '{}'", + path.display() + ) + })?; + Ok(()) +} + +#[cfg(not(unix))] +fn ensure_executable_permissions(_path: &Path) -> Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn is_executable_file(path: &Path) -> Result { + use std::os::unix::fs::PermissionsExt; + + let metadata = fs::metadata(path) + .with_context(|| format!("Failed to read metadata for '{}'", path.display()))?; + Ok(metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) +} + +#[cfg(not(unix))] +fn is_executable_file(path: &Path) -> Result { + let metadata = fs::metadata(path) + .with_context(|| format!("Failed to read metadata for '{}'", path.display()))?; + Ok(metadata.is_file()) +} + +pub(super) fn install_embedded_setup_assets_with_rename( + repository_root: &Path, + target: SetupTarget, + selected_optional_workflows: &[String], + mut rename_fn: F, +) -> Result +where + F: FnMut(&Path, &Path) -> io::Result<()>, +{ + ensure_directory_is_writable(repository_root, "setup repository root")?; + + let mut target_results = Vec::new(); + + for concrete_target in concrete_targets_for(target) { + let concrete_target = *concrete_target; + let assets: Vec<&'static EmbeddedAsset> = + iter_embedded_assets_for_setup_target_with_selection( + concrete_target, + selected_optional_workflows, + ) + .collect(); + let result = install_assets_for_concrete_target_with_rename( + repository_root, + concrete_target, + &assets, + &mut rename_fn, + )?; + target_results.push(result); + } + + Ok(SetupInstallOutcome { target_results }) +} + +fn install_assets_for_concrete_target_with_rename( + repository_root: &Path, + target: SetupTarget, + assets: &[&'static EmbeddedAsset], + rename_fn: &mut F, +) -> Result +where + F: FnMut(&Path, &Path) -> io::Result<()>, +{ + let install_targets = InstallTargetPaths::new(repository_root); + let destination_root = match target { + SetupTarget::OpenCode => install_targets.opencode_target_dir(), + SetupTarget::Claude => install_targets.claude_target_dir(), + SetupTarget::Pi => install_targets.pi_target_dir(), + SetupTarget::Codex => install_targets.codex_target_dir(), + SetupTarget::All => { + unreachable!("meta targets are expanded into concrete targets") + } + }; + + for asset in assets { + install_single_asset_with_rename(target, &destination_root, asset, rename_fn)?; + } + + prune_stale_assets_for_concrete_target(&destination_root, target, assets)?; + + Ok(SetupInstallTargetResult { + target, + destination_root, + installed_file_count: assets.len(), + }) +} + +fn prune_stale_assets_for_concrete_target( + destination_root: &Path, + target: SetupTarget, + installed_assets: &[&'static EmbeddedAsset], +) -> Result<()> { + let installed_paths: std::collections::HashSet<&'static str> = installed_assets + .iter() + .map(|asset| asset.relative_path) + .collect(); + + for asset in embedded_assets_for_concrete_target(target) { + if installed_paths.contains(asset.relative_path) { + continue; + } + + let destination = destination_root.join(asset.relative_path); + if !destination.is_file() { + continue; + } + + fs::remove_file(&destination).with_context(|| { + format!( + "Failed to prune unselected setup asset '{}'", + destination.display() + ) + })?; + + remove_empty_ancestor_directories(destination_root, &destination); + } + + Ok(()) +} + +fn remove_empty_ancestor_directories(destination_root: &Path, removed_file: &Path) { + let mut current = removed_file.parent(); + while let Some(directory) = current { + if directory == destination_root || !directory.starts_with(destination_root) { + break; + } + if fs::remove_dir(directory).is_err() { + break; + } + current = directory.parent(); + } +} + +fn is_claude_settings_merge_target(target: SetupTarget, relative_path: &str) -> bool { + target == SetupTarget::Claude && relative_path == claude_asset::SETTINGS_FILE +} + +fn is_opencode_config_merge_target(target: SetupTarget, relative_path: &str) -> bool { + target == SetupTarget::OpenCode && relative_path == default_paths::repo_file::OPENCODE_MANIFEST +} + +fn is_codex_hooks_merge_target(target: SetupTarget, relative_path: &str) -> bool { + target == SetupTarget::Codex && relative_path == ".codex/hooks.json" +} + +fn install_single_asset_with_rename( + target: SetupTarget, + destination_root: &Path, + asset: &'static EmbeddedAsset, + rename_fn: &mut F, +) -> Result<()> +where + F: FnMut(&Path, &Path) -> io::Result<()>, +{ + validate_embedded_relative_path(asset.relative_path)?; + let destination = destination_root.join(asset.relative_path); + let parent = destination + .parent() + .context("Embedded asset destination should have a parent directory")?; + + fs::create_dir_all(parent).with_context(|| { + format!( + "Failed to create parent directory '{}' for setup asset", + parent.display() + ) + })?; + + if destination.is_dir() { + bail!( + "Setup asset destination '{}' is an existing directory, not a file. Try: remove or rename the directory and rerun 'sce setup'.", + destination.display() + ); + } + + let install_bytes: Vec = if is_claude_settings_merge_target(target, asset.relative_path) { + let existing_bytes = if destination.is_file() { + Some(fs::read(&destination).with_context(|| { + format!( + "Failed to read existing setup asset '{}' for merge", + destination.display() + ) + })?) + } else { + None + }; + config_merge::merge_or_create_claude_settings( + existing_bytes.as_deref(), + asset.bytes, + &destination.display().to_string(), + )? + } else if is_opencode_config_merge_target(target, asset.relative_path) { + let existing_bytes = if destination.is_file() { + Some(fs::read(&destination).with_context(|| { + format!( + "Failed to read existing setup asset '{}' for merge", + destination.display() + ) + })?) + } else { + None + }; + config_merge::merge_or_create_opencode_config( + existing_bytes.as_deref(), + asset.bytes, + &destination.display().to_string(), + )? + } else if is_codex_hooks_merge_target(target, asset.relative_path) { + let existing_bytes = if destination.is_file() { + Some(fs::read(&destination).with_context(|| { + format!( + "Failed to read existing setup asset '{}' for merge", + destination.display() + ) + })?) + } else { + None + }; + codex_hook_config::merge_or_create( + existing_bytes.as_deref(), + asset.bytes, + &destination.display().to_string(), + )? + } else { + asset.bytes.to_vec() + }; + + let staging_path = create_asset_staging_path(parent, asset.relative_path)?; + if let Err(error) = fs::write(&staging_path, &install_bytes).with_context(|| { + format!( + "Failed to write staged embedded asset '{}'", + staging_path.display() + ) + }) { + cleanup_path_if_exists(&staging_path); + return Err(error); + } + + if let Err(error) = rename_fn(&staging_path, &destination).with_context(|| { + format!( + "Failed to install staged asset '{}' into destination '{}'", + staging_path.display(), + destination.display() + ) + }) { + cleanup_path_if_exists(&staging_path); + return Err(error.context(setup_install_recovery_guidance(target, &destination))); + } + + Ok(()) +} + +fn create_asset_staging_path(parent: &Path, relative_path: &str) -> Result { + let epoch_nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("System clock is before UNIX_EPOCH")? + .as_nanos(); + let sanitized_name = relative_path.replace(['/', '\\'], "-"); + + for attempt in 0..1000_u16 { + let candidate = parent.join(format!( + ".sce-setup-staging-{sanitized_name}-{epoch_nanos}-{}-{attempt}", + std::process::id() + )); + + match fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&candidate) + { + Ok(_) => return Ok(candidate), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} + Err(error) => { + return Err(error).with_context(|| { + format!("Failed to allocate staging file '{}'", candidate.display()) + }); + } + } + } + + bail!( + "Could not allocate a unique staging file under '{}'", + parent.display() + ) +} + +fn validate_embedded_relative_path(relative_path: &str) -> Result<()> { + let path = Path::new(relative_path); + + if path.is_absolute() { + bail!("Embedded asset path '{relative_path}' must be relative, not absolute"); + } + + for component in path.components() { + match component { + Component::Normal(_) => {} + _ => { + bail!("Embedded asset path '{relative_path}' contains disallowed component"); + } + } + } + + Ok(()) +} diff --git a/cli/src/services/setup/mod.rs b/cli/src/services/setup/mod.rs index 9b5859836..feeb101ec 100644 --- a/cli/src/services/setup/mod.rs +++ b/cli/src/services/setup/mod.rs @@ -42,14 +42,10 @@ fn repo_local_config_bootstrap_payload() -> String { pub const NAME: &str = "setup"; -/// Classifies repository-root resolution failures while retaining the -/// underlying technical error for the CLI's observability boundary. #[derive(Debug)] pub enum GitRepositoryResolutionError { - /// Git positively identified the target as outside a repository. NotGitRepository(anyhow::Error), - /// Resolution failed for an unexpected filesystem, process, or output - /// reason. + Unexpected(anyhow::Error), } @@ -152,8 +148,6 @@ fn embedded_assets_for_concrete_target(target: SetupTarget) -> &'static [Embedde #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct WorkflowAssetLayout { - /// `None` for a target with no command directory (skills only), such as - /// Codex. command_dir: Option<&'static str>, skills_dir: &'static str, } @@ -255,7 +249,7 @@ pub struct SetupCliOptions { pub hooks: bool, pub repo_path: Option, pub bootstrap_context: bool, - /// Repeated `--workflow ` values. Empty means the flag was absent. + pub workflows: Vec, } @@ -265,8 +259,7 @@ pub struct SetupRequest { pub install_hooks: bool, pub hooks_repo_path: Option, pub context_only: bool, - /// The optional workflows this run installs. `None` means no selection was - /// supplied, so the persisted `integrations.optional_workflows` is reused. + pub optional_workflows: Option>, } @@ -416,8 +409,6 @@ pub fn run_setup_for_mode( SetupMode::NonInteractive(target) => target, }; - // A supplied selection is the exact selection for this run; without one the - // persisted selection is reused so a repeat run does not uninstall it. let selected_optional_workflows = match optional_workflows { Some(selection) => selection.to_vec(), None => persisted_optional_workflows(repository_root), @@ -432,7 +423,6 @@ pub fn run_setup_for_mode( ) })?; - // Persist selected integration targets and optional workflows in repo-local config. persist_integration_targets( repository_root, target, @@ -450,8 +440,6 @@ pub fn run_setup_for_mode( Ok(format_setup_install_success_message(&outcome)) } -/// The optional workflows recorded in repo-local `.sce/config.json`, or an empty -/// selection when the file is absent, unreadable, or records none. pub fn persisted_optional_workflows(repository_root: &Path) -> Vec { use crate::services::config::schema::parse_file_config; use crate::services::config::ConfigPathSource; @@ -713,8 +701,7 @@ pub struct RequiredHookInstallResult { pub hook_name: String, pub hook_path: PathBuf, pub status: RequiredHookInstallStatus, - /// True when the hook's foreign content ends in a zero-indent `exec` or - /// `exit`, so the appended SCE managed block would never run. + pub unreachable_block_advisory: bool, } @@ -770,7 +757,6 @@ pub(crate) fn cleanup_path_if_exists(path: &Path) { fs::remove_file(path) }; - // Best-effort cleanup; log errors but don't fail the operation if let Err(e) = cleanup_result { eprintln!( "Warning: Failed to clean up temporary path '{}': {}", @@ -795,8 +781,6 @@ pub(crate) fn concrete_targets_for(target: SetupTarget) -> &'static [SetupTarget } } -/// Convert a concrete [`SetupTarget`] (not `All`) to its canonical -/// `integrations.target` string representation. fn integration_target_id_str(target: SetupTarget) -> &'static str { match target { SetupTarget::OpenCode => "opencode", @@ -824,7 +808,6 @@ pub fn persist_integration_targets( return Ok(()); } - // Read existing config or start with bootstrap payload. let raw = if config_file.exists() { fs::read_to_string(&config_file) .with_context(|| format!("Failed to read config file '{}'", config_file.display()))? @@ -848,7 +831,6 @@ pub fn persist_integration_targets( ) })?; - // Collect existing integration target values, if any. let mut existing_targets: Vec = config_obj .get("integrations") .and_then(|i| i.get("target")) @@ -860,7 +842,6 @@ pub fn persist_integration_targets( }) .unwrap_or_default(); - // Add new concrete targets (expanding All), deduping as we go. let new_targets = concrete_targets_for(target); for concrete in new_targets { let id_str = integration_target_id_str(*concrete); @@ -870,8 +851,6 @@ pub fn persist_integration_targets( } } - // Write the merged integrations block back. The optional-workflow selection - // resolved for this run replaces any previously recorded selection. config_obj.insert( "integrations".to_string(), json!({ @@ -924,799 +903,7 @@ pub fn persist_integration_targets( Ok(()) } -mod install { - use anyhow::{bail, Context, Result}; - use std::{ - fs, io, - path::{Component, Path, PathBuf}, - process::Command, - time::{SystemTime, UNIX_EPOCH}, - }; - - use crate::services::codex_hook_config; - use crate::services::default_paths::InstallTargetPaths; - use crate::services::security::{ensure_directory_is_writable, redact_sensitive_text}; - - use super::config_merge; - use super::hook_merge; - use super::{ - classify_git_exit, cleanup_path_if_exists, concrete_targets_for, - embedded_assets_for_concrete_target, hook_install_recovery_guidance, - iter_embedded_assets_for_setup_target_with_selection, iter_required_hook_assets, - setup_install_recovery_guidance, EmbeddedAsset, GitExitKind, GitRepositoryResolutionError, - RequiredHookInstallResult, RequiredHookInstallStatus, RequiredHooksInstallOutcome, - SetupInstallOutcome, SetupInstallTargetResult, SetupTarget, - }; - use crate::services::default_paths; - use crate::services::default_paths::claude_asset; - - pub(super) fn prepare_setup_hooks_repository(repository_root: &Path) -> Result { - let normalized_repository_root = normalize_user_repository_path(repository_root)?; - Ok(resolve_git_repository_root(&normalized_repository_root)?) - } - - pub(super) fn ensure_git_repository( - directory: &Path, - ) -> Result { - resolve_git_repository_root(directory) - } - - pub(super) fn install_required_git_hooks( - repository_root: &Path, - ) -> Result { - install_required_git_hooks_with_rename(repository_root, |from, to| fs::rename(from, to)) - } - - pub(super) fn install_required_git_hooks_with_rename( - repository_root: &Path, - rename_fn: F, - ) -> Result - where - F: FnMut(&Path, &Path) -> io::Result<()>, - { - let resolved_repository_root = prepare_setup_hooks_repository(repository_root)?; - install_required_git_hooks_in_resolved_repository(&resolved_repository_root, rename_fn) - } - - pub(super) fn install_embedded_setup_assets( - repository_root: &Path, - target: SetupTarget, - selected_optional_workflows: &[String], - ) -> Result { - install_embedded_setup_assets_with_rename( - repository_root, - target, - selected_optional_workflows, - |from, to| fs::rename(from, to), - ) - } - - pub(super) fn repair_merge_target_asset( - repository_root: &Path, - target: SetupTarget, - relative_path: &str, - ) -> Result<()> { - let asset = embedded_assets_for_concrete_target(target) - .iter() - .find(|asset| asset.relative_path == relative_path) - .with_context(|| { - format!("No embedded asset named '{relative_path}' for target {target:?}") - })?; - - let install_targets = InstallTargetPaths::new(repository_root); - let destination_root = match target { - SetupTarget::OpenCode => install_targets.opencode_target_dir(), - SetupTarget::Claude => install_targets.claude_target_dir(), - SetupTarget::Pi => install_targets.pi_target_dir(), - SetupTarget::Codex => install_targets.codex_target_dir(), - SetupTarget::All => unreachable!("meta targets are expanded into concrete targets"), - }; - - install_single_asset_with_rename(target, &destination_root, asset, &mut |from, to| { - fs::rename(from, to) - }) - } - - fn install_required_git_hooks_in_resolved_repository( - resolved_repository_root: &Path, - mut rename_fn: F, - ) -> Result - where - F: FnMut(&Path, &Path) -> io::Result<()>, - { - ensure_directory_is_writable(resolved_repository_root, "repository root")?; - let hooks_directory = resolve_git_hooks_directory(resolved_repository_root)?; - fs::create_dir_all(&hooks_directory).with_context(|| { - format!( - "Failed to create git hooks directory '{}'", - hooks_directory.display() - ) - })?; - ensure_directory_is_writable(&hooks_directory, "git hooks directory")?; - - let mut hook_results = Vec::new(); - for hook_asset in iter_required_hook_assets() { - let hook_result = install_single_required_hook_with_rename( - &hooks_directory, - hook_asset, - &mut rename_fn, - )?; - hook_results.push(hook_result); - } - - Ok(RequiredHooksInstallOutcome { - repository_root: resolved_repository_root.to_path_buf(), - hooks_directory, - hook_results, - }) - } - - fn install_single_required_hook_with_rename( - hooks_directory: &Path, - hook_asset: &EmbeddedAsset, - rename_fn: &mut F, - ) -> Result - where - F: FnMut(&Path, &Path) -> io::Result<()>, - { - validate_embedded_relative_path(hook_asset.relative_path)?; - - let hook_path = hooks_directory.join(hook_asset.relative_path); - let existing_metadata = fs::metadata(&hook_path).ok(); - - let existing_bytes = if existing_metadata - .as_ref() - .is_some_and(std::fs::Metadata::is_file) - { - Some(fs::read(&hook_path).with_context(|| { - format!("Failed to read existing hook '{}'", hook_path.display()) - })?) - } else if existing_metadata.is_some() { - bail!( - "Existing hook target '{}' is not a file", - hook_path.display() - ); - } else { - None - }; - - let merge = hook_merge::merge_or_create_hook( - existing_bytes.as_deref(), - hook_asset.bytes, - hook_asset.relative_path, - )?; - - if let Some(existing_bytes) = existing_bytes.as_deref() { - let executable = is_executable_file(&hook_path)?; - if merge.bytes == existing_bytes && executable { - return Ok(RequiredHookInstallResult { - hook_name: hook_asset.relative_path.to_string(), - hook_path, - status: RequiredHookInstallStatus::Skipped, - unreachable_block_advisory: merge.unreachable_block_advisory, - }); - } - } - - let had_existing_hook = existing_metadata.is_some(); - - let hook_staging_path = - create_hook_staging_path(hooks_directory, hook_asset.relative_path)?; - if let Err(error) = write_hook_payload_to_staging(&hook_staging_path, &merge.bytes) { - cleanup_path_if_exists(&hook_staging_path); - return Err(error); - } - - let action = if had_existing_hook { - "update" - } else { - "install" - }; - if let Err(error) = rename_fn(&hook_staging_path, &hook_path).with_context(|| { - format!( - "Failed to {action} required hook '{}' at '{}'", - hook_asset.relative_path, - hook_path.display() - ) - }) { - cleanup_path_if_exists(&hook_staging_path); - let error = if had_existing_hook { - error.context(hook_install_recovery_guidance(&hook_path)) - } else { - error - }; - return Err(error); - } - - Ok(RequiredHookInstallResult { - hook_name: hook_asset.relative_path.to_string(), - hook_path, - status: if had_existing_hook { - RequiredHookInstallStatus::Updated - } else { - RequiredHookInstallStatus::Installed - }, - unreachable_block_advisory: merge.unreachable_block_advisory, - }) - } - - fn write_hook_payload_to_staging(staging_path: &Path, bytes: &[u8]) -> Result<()> { - fs::write(staging_path, bytes).with_context(|| { - format!( - "Failed to write staged hook payload '{}'", - staging_path.display() - ) - })?; - ensure_executable_permissions(staging_path)?; - Ok(()) - } - - fn create_hook_staging_path(hooks_directory: &Path, hook_name: &str) -> Result { - let epoch_nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .context("System clock is before UNIX_EPOCH")? - .as_nanos(); - let sanitized_hook_name = hook_name.replace('/', "-"); - - for attempt in 0..1000_u16 { - let candidate = hooks_directory.join(format!( - ".sce-hook-staging-{sanitized_hook_name}-{epoch_nanos}-{}-{attempt}", - std::process::id() - )); - - match fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(&candidate) - { - Ok(_) => return Ok(candidate), - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} - Err(error) => { - return Err(error).with_context(|| { - format!( - "Failed to allocate hook staging file '{}'", - candidate.display() - ) - }); - } - } - } - - bail!( - "Could not allocate a unique hook staging file under '{}'", - hooks_directory.display() - ) - } - - fn normalize_user_repository_path(repository_root: &Path) -> Result { - if repository_root.as_os_str().is_empty() { - bail!( - "Option '--repo' must not be empty. Try: pass a path to an existing git repository." - ); - } - - let canonical_repository_root = fs::canonicalize(repository_root).with_context(|| { - format!( - "Failed to resolve repository path '{}'. Try: pass a path to an existing git repository.", - repository_root.display() - ) - })?; - - let metadata = fs::metadata(&canonical_repository_root).with_context(|| { - format!( - "Failed to inspect repository path '{}'.", - canonical_repository_root.display() - ) - })?; - - if !metadata.is_dir() { - bail!( - "Repository path '{}' is not a directory. Try: pass a path to an existing git repository.", - canonical_repository_root.display() - ); - } - - Ok(canonical_repository_root) - } - - fn resolve_git_repository_root( - repository_root: &Path, - ) -> Result { - let repository_root_output = run_git_command_in_directory( - repository_root, - &["rev-parse", "--show-toplevel"], - "Failed to resolve repository root. Ensure '--repo' points to an accessible git repository.", - ) - .map_err(map_setup_repository_resolution_error)?; - Ok(PathBuf::from(repository_root_output)) - } - - fn map_setup_repository_resolution_error( - error: GitCommandError, - ) -> GitRepositoryResolutionError { - let is_not_repository = matches!( - &error, - GitCommandError::NonZeroExit { - kind: GitExitKind::NotRepository, - .. - } - ); - let source = anyhow::Error::new(error); - - if is_not_repository { - GitRepositoryResolutionError::NotGitRepository(source) - } else { - GitRepositoryResolutionError::Unexpected(source) - } - } - - fn resolve_git_hooks_directory(repository_root: &Path) -> Result { - let hooks_directory_output = run_git_command_in_directory( - repository_root, - &["rev-parse", "--git-path", "hooks"], - "Failed to resolve effective git hooks path.", - )?; - - let hooks_directory = PathBuf::from(&hooks_directory_output); - if hooks_directory.is_absolute() { - return Ok(hooks_directory); - } - - Ok(repository_root.join(hooks_directory)) - } - - #[derive(Debug)] - enum GitCommandError { - Spawn { - context: String, - directory: PathBuf, - source: std::io::Error, - }, - NonZeroExit { - context: String, - directory: PathBuf, - status: std::process::ExitStatus, - kind: GitExitKind, - diagnostic: String, - }, - InvalidUtf8 { - context: String, - source: std::string::FromUtf8Error, - }, - EmptyOutput { - context: String, - directory: PathBuf, - }, - } - - impl std::fmt::Display for GitCommandError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Spawn { - context, - directory, - source, - } => write!( - f, - "{context} (directory: '{}'): {source}", - directory.display() - ), - Self::NonZeroExit { - context, - directory, - status, - diagnostic, - .. - } => write!( - f, - "{context} (directory: '{}', status: {status:?}) {diagnostic}", - directory.display() - ), - Self::InvalidUtf8 { context, source } => { - write!( - f, - "{context}: git command output contained invalid UTF-8: {source}" - ) - } - Self::EmptyOutput { context, directory } => write!( - f, - "{context} (directory: '{}'): git command returned empty output", - directory.display() - ), - } - } - } - - impl std::error::Error for GitCommandError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Spawn { source, .. } => Some(source), - Self::InvalidUtf8 { source, .. } => Some(source), - Self::NonZeroExit { .. } | Self::EmptyOutput { .. } => None, - } - } - } - - fn run_git_command_in_directory( - repository_root: &Path, - args: &[&str], - context_message: &str, - ) -> std::result::Result { - let output = Command::new("git") - .env("LC_ALL", "C") - .env("LANG", "C") - .env_remove("LANGUAGE") - .args(args) - .current_dir(repository_root) - .output() - .map_err(|source| GitCommandError::Spawn { - context: context_message.to_string(), - directory: repository_root.to_path_buf(), - source, - })?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let kind = classify_git_exit(&stderr); - let diagnostic = if stderr.is_empty() { - String::from("git command exited with a non-zero status") - } else { - redact_sensitive_text(&stderr) - }; - return Err(GitCommandError::NonZeroExit { - context: context_message.to_string(), - directory: repository_root.to_path_buf(), - status: output.status, - kind, - diagnostic, - }); - } - - let stdout = - String::from_utf8(output.stdout).map_err(|source| GitCommandError::InvalidUtf8 { - context: context_message.to_string(), - source, - })?; - let stdout = stdout.trim().to_string(); - if stdout.is_empty() { - return Err(GitCommandError::EmptyOutput { - context: context_message.to_string(), - directory: repository_root.to_path_buf(), - }); - } - - Ok(stdout) - } - - #[cfg(unix)] - fn ensure_executable_permissions(path: &Path) -> Result<()> { - use std::os::unix::fs::PermissionsExt; - - let metadata = fs::metadata(path) - .with_context(|| format!("Failed to read metadata for '{}'", path.display()))?; - let mut permissions = metadata.permissions(); - permissions.set_mode(permissions.mode() | 0o111); - fs::set_permissions(path, permissions).with_context(|| { - format!( - "Failed to set executable permissions for '{}'", - path.display() - ) - })?; - Ok(()) - } - - #[cfg(not(unix))] - fn ensure_executable_permissions(_path: &Path) -> Result<()> { - Ok(()) - } - - #[cfg(unix)] - fn is_executable_file(path: &Path) -> Result { - use std::os::unix::fs::PermissionsExt; - - let metadata = fs::metadata(path) - .with_context(|| format!("Failed to read metadata for '{}'", path.display()))?; - Ok(metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) - } - - #[cfg(not(unix))] - fn is_executable_file(path: &Path) -> Result { - let metadata = fs::metadata(path) - .with_context(|| format!("Failed to read metadata for '{}'", path.display()))?; - Ok(metadata.is_file()) - } - - pub(super) fn install_embedded_setup_assets_with_rename( - repository_root: &Path, - target: SetupTarget, - selected_optional_workflows: &[String], - mut rename_fn: F, - ) -> Result - where - F: FnMut(&Path, &Path) -> io::Result<()>, - { - ensure_directory_is_writable(repository_root, "setup repository root")?; - - let mut target_results = Vec::new(); - - for concrete_target in concrete_targets_for(target) { - let concrete_target = *concrete_target; - let assets: Vec<&'static EmbeddedAsset> = - iter_embedded_assets_for_setup_target_with_selection( - concrete_target, - selected_optional_workflows, - ) - .collect(); - let result = install_assets_for_concrete_target_with_rename( - repository_root, - concrete_target, - &assets, - &mut rename_fn, - )?; - target_results.push(result); - } - - Ok(SetupInstallOutcome { target_results }) - } - - fn install_assets_for_concrete_target_with_rename( - repository_root: &Path, - target: SetupTarget, - assets: &[&'static EmbeddedAsset], - rename_fn: &mut F, - ) -> Result - where - F: FnMut(&Path, &Path) -> io::Result<()>, - { - let install_targets = InstallTargetPaths::new(repository_root); - let destination_root = match target { - SetupTarget::OpenCode => install_targets.opencode_target_dir(), - SetupTarget::Claude => install_targets.claude_target_dir(), - SetupTarget::Pi => install_targets.pi_target_dir(), - SetupTarget::Codex => install_targets.codex_target_dir(), - SetupTarget::All => { - unreachable!("meta targets are expanded into concrete targets") - } - }; - - for asset in assets { - install_single_asset_with_rename(target, &destination_root, asset, rename_fn)?; - } - - prune_stale_assets_for_concrete_target(&destination_root, target, assets)?; - - Ok(SetupInstallTargetResult { - target, - destination_root, - installed_file_count: assets.len(), - }) - } - - fn prune_stale_assets_for_concrete_target( - destination_root: &Path, - target: SetupTarget, - installed_assets: &[&'static EmbeddedAsset], - ) -> Result<()> { - let installed_paths: std::collections::HashSet<&'static str> = installed_assets - .iter() - .map(|asset| asset.relative_path) - .collect(); - - for asset in embedded_assets_for_concrete_target(target) { - if installed_paths.contains(asset.relative_path) { - continue; - } - - let destination = destination_root.join(asset.relative_path); - if !destination.is_file() { - continue; - } - - fs::remove_file(&destination).with_context(|| { - format!( - "Failed to prune unselected setup asset '{}'", - destination.display() - ) - })?; - - remove_empty_ancestor_directories(destination_root, &destination); - } - - Ok(()) - } - - fn remove_empty_ancestor_directories(destination_root: &Path, removed_file: &Path) { - let mut current = removed_file.parent(); - while let Some(directory) = current { - if directory == destination_root || !directory.starts_with(destination_root) { - break; - } - if fs::remove_dir(directory).is_err() { - break; - } - current = directory.parent(); - } - } - - /// True for the one asset the Claude install path merges into an existing - /// document instead of overwriting: `.claude/settings.json`. - fn is_claude_settings_merge_target(target: SetupTarget, relative_path: &str) -> bool { - target == SetupTarget::Claude && relative_path == claude_asset::SETTINGS_FILE - } - - /// True for the one asset the `OpenCode` install path merges into an existing - /// document instead of overwriting: `.opencode/opencode.json`. - fn is_opencode_config_merge_target(target: SetupTarget, relative_path: &str) -> bool { - target == SetupTarget::OpenCode - && relative_path == default_paths::repo_file::OPENCODE_MANIFEST - } - - /// True for Codex's user-owned hook registry, which is merged rather than - /// overwritten so setup preserves unrelated Codex handlers and settings. - fn is_codex_hooks_merge_target(target: SetupTarget, relative_path: &str) -> bool { - target == SetupTarget::Codex && relative_path == ".codex/hooks.json" - } - - fn install_single_asset_with_rename( - target: SetupTarget, - destination_root: &Path, - asset: &'static EmbeddedAsset, - rename_fn: &mut F, - ) -> Result<()> - where - F: FnMut(&Path, &Path) -> io::Result<()>, - { - validate_embedded_relative_path(asset.relative_path)?; - let destination = destination_root.join(asset.relative_path); - let parent = destination - .parent() - .context("Embedded asset destination should have a parent directory")?; - - fs::create_dir_all(parent).with_context(|| { - format!( - "Failed to create parent directory '{}' for setup asset", - parent.display() - ) - })?; - - if destination.is_dir() { - bail!( - "Setup asset destination '{}' is an existing directory, not a file. Try: remove or rename the directory and rerun 'sce setup'.", - destination.display() - ); - } - - let install_bytes: Vec = if is_claude_settings_merge_target(target, asset.relative_path) - { - let existing_bytes = if destination.is_file() { - Some(fs::read(&destination).with_context(|| { - format!( - "Failed to read existing setup asset '{}' for merge", - destination.display() - ) - })?) - } else { - None - }; - config_merge::merge_or_create_claude_settings( - existing_bytes.as_deref(), - asset.bytes, - &destination.display().to_string(), - )? - } else if is_opencode_config_merge_target(target, asset.relative_path) { - let existing_bytes = if destination.is_file() { - Some(fs::read(&destination).with_context(|| { - format!( - "Failed to read existing setup asset '{}' for merge", - destination.display() - ) - })?) - } else { - None - }; - config_merge::merge_or_create_opencode_config( - existing_bytes.as_deref(), - asset.bytes, - &destination.display().to_string(), - )? - } else if is_codex_hooks_merge_target(target, asset.relative_path) { - let existing_bytes = if destination.is_file() { - Some(fs::read(&destination).with_context(|| { - format!( - "Failed to read existing setup asset '{}' for merge", - destination.display() - ) - })?) - } else { - None - }; - codex_hook_config::merge_or_create( - existing_bytes.as_deref(), - asset.bytes, - &destination.display().to_string(), - )? - } else { - asset.bytes.to_vec() - }; - - let staging_path = create_asset_staging_path(parent, asset.relative_path)?; - if let Err(error) = fs::write(&staging_path, &install_bytes).with_context(|| { - format!( - "Failed to write staged embedded asset '{}'", - staging_path.display() - ) - }) { - cleanup_path_if_exists(&staging_path); - return Err(error); - } - - if let Err(error) = rename_fn(&staging_path, &destination).with_context(|| { - format!( - "Failed to install staged asset '{}' into destination '{}'", - staging_path.display(), - destination.display() - ) - }) { - cleanup_path_if_exists(&staging_path); - return Err(error.context(setup_install_recovery_guidance(target, &destination))); - } - - Ok(()) - } - - fn create_asset_staging_path(parent: &Path, relative_path: &str) -> Result { - let epoch_nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .context("System clock is before UNIX_EPOCH")? - .as_nanos(); - let sanitized_name = relative_path.replace(['/', '\\'], "-"); - - for attempt in 0..1000_u16 { - let candidate = parent.join(format!( - ".sce-setup-staging-{sanitized_name}-{epoch_nanos}-{}-{attempt}", - std::process::id() - )); - - match fs::OpenOptions::new() - .create_new(true) - .write(true) - .open(&candidate) - { - Ok(_) => return Ok(candidate), - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} - Err(error) => { - return Err(error).with_context(|| { - format!("Failed to allocate staging file '{}'", candidate.display()) - }); - } - } - } - - bail!( - "Could not allocate a unique staging file under '{}'", - parent.display() - ) - } - - fn validate_embedded_relative_path(relative_path: &str) -> Result<()> { - let path = Path::new(relative_path); - - if path.is_absolute() { - bail!("Embedded asset path '{relative_path}' must be relative, not absolute"); - } - - for component in path.components() { - match component { - Component::Normal(_) => {} - _ => { - bail!("Embedded asset path '{relative_path}' contains disallowed component"); - } - } - } - - Ok(()) - } -} +mod install; pub trait SetupTargetPrompter { fn prompt_target(&self) -> Result; @@ -1778,226 +965,32 @@ fn setup_prompt_title_with_color_policy(color_enabled: bool) -> String { prompt::setup_prompt_title_with_color_policy(color_enabled) } -mod prompt { - use anyhow::{bail, Result}; - use inquire::{Confirm, InquireError, MultiSelect, Select}; +mod prompt; - use crate::services::style::{ - prompt_label, prompt_label_with_color_policy, prompt_value_with_color_policy, - }; +pub fn resolve_setup_dispatch

( + mode: SetupMode, + prompter: &P, + optional_workflow_defaults: &[String], +) -> Result +where + P: SetupTargetPrompter, +{ + match mode { + SetupMode::Interactive => { + let target_dispatch = prompter.prompt_target()?; + let SetupDispatch::Proceed { mode, .. } = target_dispatch else { + return Ok(SetupDispatch::Cancelled); + }; - use super::{OptionalWorkflow, SetupDispatch, SetupMode, SetupPromptTarget, SetupTarget}; + let Some(optional_workflows) = + prompter.prompt_optional_workflows(optional_workflow_defaults)? + else { + return Ok(SetupDispatch::Cancelled); + }; - fn proceed(target: SetupTarget) -> SetupDispatch { - SetupDispatch::Proceed { - mode: SetupMode::NonInteractive(target), - optional_workflows: None, - agent_trace_auto_sync: None, - attribution_hooks_enabled: None, - } - } - - pub(super) fn prompt_target() -> Result { - let options = vec![ - SetupPromptTarget::OpenCode, - SetupPromptTarget::Claude, - SetupPromptTarget::Pi, - SetupPromptTarget::Codex, - SetupPromptTarget::All, - ]; - - let selection = Select::new(&setup_prompt_title(), options).prompt(); - - match selection { - Ok(SetupPromptTarget::OpenCode) => Ok(proceed(SetupTarget::OpenCode)), - Ok(SetupPromptTarget::Claude) => Ok(proceed(SetupTarget::Claude)), - Ok(SetupPromptTarget::Pi) => Ok(proceed(SetupTarget::Pi)), - Ok(SetupPromptTarget::Codex) => Ok(proceed(SetupTarget::Codex)), - Ok(SetupPromptTarget::All) => Ok(proceed(SetupTarget::All)), - Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => { - Ok(SetupDispatch::Cancelled) - } - Err(InquireError::NotTTY) => bail!( - "Interactive setup requires a TTY. Re-run with '--non-interactive' and one of '--opencode', '--claude', '--pi', '--codex', or '--all'." - ), - Err(error) => Err(error.into()), - } - } - - pub(super) fn prompt_optional_workflows(defaults: &[String]) -> Result>> { - let Some((rows, default_indices)) = - optional_workflow_prompt_inputs(super::OPTIONAL_WORKFLOWS, defaults) - else { - return Ok(Some(Vec::new())); - }; - - let selection = MultiSelect::new(&optional_workflow_prompt_title(), rows) - .with_default(&default_indices) - .prompt(); - - match selection { - Ok(selected) => Ok(Some( - selected - .into_iter() - .map(|row| row.workflow.id.to_string()) - .collect(), - )), - Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => Ok(None), - Err(InquireError::NotTTY) => bail!( - "Interactive setup requires a TTY. Re-run with '--non-interactive' and one of '--opencode', '--claude', '--pi', '--codex', or '--all', adding '--workflow ' for each optional workflow to install." - ), - Err(error) => Err(error.into()), - } - } - - pub(super) fn prompt_agent_trace_auto_sync() -> Result> { - prompt_confirmation( - "Automatically sync Agent Traces?\n(Requires an SCE account. Sends Agent Traces from supported AI coding tools\nto SCE servers so they can be stored and viewed in your account.)", - ) - } - - pub(super) fn prompt_attribution_hooks_enabled() -> Result> { - prompt_confirmation( - "Record SCE involvement in Git commits?\n(Adds SCE metadata to commits created or assisted by SCE.)", - ) - } - - fn prompt_confirmation(label: &str) -> Result> { - match Confirm::new(label).with_default(true).prompt() { - Ok(value) => Ok(Some(value)), - Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => Ok(None), - Err(InquireError::NotTTY) => bail!( - "Interactive setup requires a TTY. Re-run with '--non-interactive' and one of '--opencode', '--claude', '--pi', '--codex', or '--all'." - ), - Err(error) => Err(error.into()), - } - } - - /// One selectable row per optional workflow, in catalog order. - #[derive(Clone, Copy, Debug, Eq, PartialEq)] - pub(super) struct OptionalWorkflowRow { - pub(super) workflow: &'static OptionalWorkflow, - } - - impl std::fmt::Display for OptionalWorkflowRow { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", optional_workflow_row_label(self.workflow)) - } - } - - pub(super) fn optional_workflow_prompt_inputs( - catalog: &'static [OptionalWorkflow], - defaults: &[String], - ) -> Option<(Vec, Vec)> { - if catalog.is_empty() { - return None; - } - - Some(( - optional_workflow_rows(catalog), - optional_workflow_default_indices(catalog, defaults), - )) - } - - pub(super) fn optional_workflow_rows( - catalog: &'static [OptionalWorkflow], - ) -> Vec { - catalog - .iter() - .map(|workflow| OptionalWorkflowRow { workflow }) - .collect() - } - - pub(super) fn optional_workflow_default_indices( - catalog: &'static [OptionalWorkflow], - defaults: &[String], - ) -> Vec { - catalog - .iter() - .enumerate() - .filter(|(_, workflow)| defaults.iter().any(|id| id == workflow.id)) - .map(|(index, _)| index) - .collect() - } - - pub(super) fn optional_workflow_prompt_title() -> String { - prompt_label("Select optional workflows") - } - - pub(super) fn optional_workflow_row_label(workflow: &OptionalWorkflow) -> String { - optional_workflow_row_label_with_color_policy( - workflow, - crate::services::style::supports_color(), - ) - } - - pub(super) fn optional_workflow_row_label_with_color_policy( - workflow: &OptionalWorkflow, - color_enabled: bool, - ) -> String { - format!( - "{} — {}", - prompt_value_with_color_policy(workflow.title, color_enabled), - workflow.description - ) - } - - pub(super) fn setup_prompt_title() -> String { - prompt_label("Select setup target") - } - - pub(super) fn setup_prompt_target_label(target: SetupPromptTarget) -> String { - setup_prompt_target_label_with_color_policy( - target, - crate::services::style::supports_color(), - ) - } - - pub(super) fn setup_prompt_target_label_with_color_policy( - target: SetupPromptTarget, - color_enabled: bool, - ) -> String { - let label = match target { - SetupPromptTarget::OpenCode => "OpenCode", - SetupPromptTarget::Claude => "Claude", - SetupPromptTarget::Pi => "Pi", - SetupPromptTarget::Codex => "Codex", - SetupPromptTarget::All => "All (OpenCode + Claude + Pi + Codex)", - }; - - prompt_value_with_color_policy(label, color_enabled) - } - - #[allow(dead_code)] - pub(super) fn setup_prompt_title_with_color_policy(color_enabled: bool) -> String { - prompt_label_with_color_policy("Select setup target", color_enabled) - } -} - -pub fn resolve_setup_dispatch

( - mode: SetupMode, - prompter: &P, - optional_workflow_defaults: &[String], -) -> Result -where - P: SetupTargetPrompter, -{ - match mode { - SetupMode::Interactive => { - let target_dispatch = prompter.prompt_target()?; - let SetupDispatch::Proceed { mode, .. } = target_dispatch else { - return Ok(SetupDispatch::Cancelled); - }; - - let Some(optional_workflows) = - prompter.prompt_optional_workflows(optional_workflow_defaults)? - else { - return Ok(SetupDispatch::Cancelled); - }; - - let Some(agent_trace_auto_sync) = prompter.prompt_agent_trace_auto_sync()? else { - return Ok(SetupDispatch::Cancelled); - }; + let Some(agent_trace_auto_sync) = prompter.prompt_agent_trace_auto_sync()? else { + return Ok(SetupDispatch::Cancelled); + }; let Some(attribution_hooks_enabled) = prompter.prompt_attribution_hooks_enabled()? else { @@ -2025,1131 +1018,4 @@ pub fn setup_cancelled_text() -> String { } #[cfg(test)] -mod tests { - use super::*; - use std::process::Command; - use std::time::{SystemTime, UNIX_EPOCH}; - - use crate::command_surface; - use crate::services::command_registry::CommandRegistry; - use crate::services::command_registry::RuntimeCommand; - use crate::services::parse::command_runtime::parse_runtime_command; - - fn options_with(mutate: impl FnOnce(&mut SetupCliOptions)) -> SetupCliOptions { - let mut options = SetupCliOptions::default(); - mutate(&mut options); - options - } - - fn unique_temp_dir(label: &str) -> PathBuf { - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time should be after Unix epoch") - .as_nanos(); - let dir = std::env::temp_dir().join(format!( - "sce-setup-context-{label}-{}-{nonce}", - std::process::id() - )); - fs::create_dir_all(&dir).expect("create temp dir"); - dir - } - - fn init_git_repo(label: &str) -> PathBuf { - let repo = unique_temp_dir(label); - let output = Command::new("git") - .args(["init", "-q"]) - .current_dir(&repo) - .output() - .expect("git init should spawn"); - assert!( - output.status.success(), - "git init failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - repo - } - - fn assert_baseline_paths_exist(repo: &Path) { - let paths = RepoPaths::new(repo); - for path in [ - paths.context_overview_file(), - paths.context_architecture_file(), - paths.context_patterns_file(), - paths.context_glossary_file(), - paths.context_map_file(), - paths.context_plans_dir(), - paths.context_handovers_dir(), - paths.context_decisions_dir(), - paths.context_tmp_dir(), - paths.context_tmp_gitignore_file(), - ] { - assert!(path.exists(), "expected baseline path {}", path.display()); - } - } - - #[test] - fn repo_local_config_bootstrap_payload_uses_versioned_schema_url() { - let payload = repo_local_config_bootstrap_payload(); - - assert_eq!( - payload, - format!( - "{{\n \"$schema\": \"https://sce.crocoder.dev/v{}/config.json\",\n \"agent_trace\": {{\n \"auto_sync\": true\n }},\n \"policies\": {{\n \"attribution_hooks\": {{\n \"enabled\": true\n }}\n }}\n}}\n", - env!("CARGO_PKG_VERSION") - ) - ); - } - - #[test] - fn resolve_setup_request_accepts_pi_target() { - let request = resolve_setup_request(options_with(|options| { - options.pi = true; - options.non_interactive = true; - })) - .expect("pi target should resolve"); - - assert_eq!( - request.config_mode, - Some(SetupMode::NonInteractive(SetupTarget::Pi)) - ); - assert!(!request.context_only); - } - - #[test] - fn resolve_setup_request_accepts_codex_target() { - let request = resolve_setup_request(options_with(|options| { - options.codex = true; - options.non_interactive = true; - })) - .expect("codex target should resolve"); - - assert_eq!( - request.config_mode, - Some(SetupMode::NonInteractive(SetupTarget::Codex)) - ); - assert!(!request.context_only); - } - - #[test] - fn resolve_setup_request_accepts_all_target() { - let request = resolve_setup_request(options_with(|options| { - options.all = true; - options.non_interactive = true; - })) - .expect("all target should resolve"); - - assert_eq!( - request.config_mode, - Some(SetupMode::NonInteractive(SetupTarget::All)) - ); - assert!(!request.context_only); - } - - #[test] - fn resolve_setup_request_accepts_bootstrap_context_alone() { - let request = resolve_setup_request(options_with(|options| { - options.bootstrap_context = true; - })) - .expect("bootstrap-context alone should resolve"); - - assert!(request.context_only); - assert_eq!(request.config_mode, None); - assert!(!request.install_hooks); - assert_eq!(request.hooks_repo_path, None); - } - - #[test] - fn resolve_setup_request_rejects_bootstrap_context_with_target() { - let error = resolve_setup_request(options_with(|options| { - options.bootstrap_context = true; - options.opencode = true; - })) - .expect_err("bootstrap-context with target must be rejected"); - - assert!(error.to_string().contains("--bootstrap-context")); - assert!(error.to_string().contains("alone")); - } - - #[test] - fn resolve_setup_request_rejects_combined_target_flags() { - let error = resolve_setup_request(options_with(|options| { - options.pi = true; - options.all = true; - })) - .expect_err("combined target flags must be rejected"); - - assert!(error.to_string().contains("mutually exclusive")); - } - - #[test] - fn resolve_setup_request_non_interactive_error_lists_pi_and_all() { - let error = resolve_setup_request(options_with(|options| { - options.non_interactive = true; - })) - .expect_err("non-interactive without target must be rejected"); - - let message = error.to_string(); - assert!(message.contains("--pi")); - assert!(message.contains("--all")); - } - - #[test] - fn parser_routes_bootstrap_context_to_context_only_request() { - let registry = CommandRegistry::default(); - let command = parse_runtime_command( - [ - "sce".to_string(), - "setup".to_string(), - "--bootstrap-context".to_string(), - ], - ®istry, - None, - ) - .expect("bootstrap-context should parse"); - - match command { - RuntimeCommand::Setup(setup_command) => { - assert!(setup_command.request.context_only); - assert_eq!(setup_command.request.config_mode, None); - assert!(!setup_command.request.install_hooks); - } - _ => panic!("expected Setup command for --bootstrap-context"), - } - } - - #[test] - fn help_documents_bootstrap_context_flag() { - let top_level_help = command_surface::help_text(); - assert!( - top_level_help.contains("--bootstrap-context"), - "top-level help should document --bootstrap-context" - ); - - let registry = CommandRegistry::default(); - let command = parse_runtime_command( - ["sce".to_string(), "setup".to_string(), "--help".to_string()], - ®istry, - None, - ) - .expect("setup --help should parse"); - - match command { - RuntimeCommand::HelpText(help) => { - assert!( - help.text.contains("--bootstrap-context"), - "setup --help should document --bootstrap-context:\n{}", - help.text - ); - } - _ => panic!("expected HelpText for setup --help"), - } - } - - #[test] - fn bootstrap_context_baseline_creates_expected_paths() { - let repo = init_git_repo("create-baseline"); - let message = bootstrap_context_baseline(&repo).expect("bootstrap should create baseline"); - assert!(message.contains("Context baseline ensured.")); - assert_baseline_paths_exist(&repo); - - let paths = RepoPaths::new(&repo); - assert!(!paths.opencode_dir().exists()); - assert!(!paths.claude_dir().exists()); - assert!(!paths.pi_dir().exists()); - - let gitignore = fs::read_to_string(paths.context_tmp_gitignore_file()) - .expect("tmp gitignore should be readable"); - assert_eq!(gitignore, CONTEXT_TMP_GITIGNORE_CONTENT); - - let _ = fs::remove_dir_all(&repo); - } - - #[test] - fn bootstrap_context_baseline_is_additive_and_idempotent() { - let repo = init_git_repo("idempotent-baseline"); - bootstrap_context_baseline(&repo).expect("initial bootstrap"); - - let paths = RepoPaths::new(&repo); - let sentinel = "SENTINEL_OVERVIEW_CONTENT\n"; - fs::write(paths.context_overview_file(), sentinel).expect("seed overview sentinel"); - fs::write(paths.context_map_file(), "SENTINEL_CONTEXT_MAP\n") - .expect("seed context-map sentinel"); - fs::write(paths.context_tmp_gitignore_file(), "SENTINEL_GITIGNORE\n") - .expect("seed gitignore sentinel"); - - fs::remove_file(paths.context_architecture_file()).expect("remove architecture"); - fs::remove_dir_all(paths.context_plans_dir()).expect("remove plans"); - - bootstrap_context_baseline(&repo).expect("rerun bootstrap"); - - assert_eq!( - fs::read_to_string(paths.context_overview_file()).expect("read overview"), - sentinel - ); - assert_eq!( - fs::read_to_string(paths.context_map_file()).expect("read context-map"), - "SENTINEL_CONTEXT_MAP\n" - ); - assert_eq!( - fs::read_to_string(paths.context_tmp_gitignore_file()).expect("read gitignore"), - "SENTINEL_GITIGNORE\n" - ); - assert!(paths.context_architecture_file().exists()); - assert!(paths.context_plans_dir().is_dir()); - - let _ = fs::remove_dir_all(&repo); - } - - #[test] - fn concrete_targets_for_all_expands_to_four_targets() { - assert_eq!( - concrete_targets_for(SetupTarget::All), - &[ - SetupTarget::OpenCode, - SetupTarget::Claude, - SetupTarget::Pi, - SetupTarget::Codex - ] - ); - } - - #[test] - fn integration_target_id_str_maps_pi() { - assert_eq!(integration_target_id_str(SetupTarget::Pi), "pi"); - } - - #[test] - fn integration_target_id_str_maps_codex() { - assert_eq!(integration_target_id_str(SetupTarget::Codex), "codex"); - } - - /// Every optional workflow selected, so filtering drops nothing. - fn every_optional_workflow() -> Vec<&'static str> { - super::OPTIONAL_WORKFLOWS - .iter() - .map(|workflow| workflow.id) - .collect() - } - - #[test] - fn iter_embedded_assets_for_all_covers_each_concrete_target() { - let selection = every_optional_workflow(); - let count = |target| { - iter_embedded_assets_for_setup_target_with_selection(target, &selection).count() - }; - - let concrete_sum = count(SetupTarget::OpenCode) - + count(SetupTarget::Claude) - + count(SetupTarget::Pi) - + count(SetupTarget::Codex); - - assert!(count(SetupTarget::Pi) > 0); - assert!(count(SetupTarget::Codex) > 0); - assert_eq!(count(SetupTarget::All), concrete_sum); - } - - #[test] - fn embedded_build_payload_contains_generated_targets_and_static_hooks() { - let selection = every_optional_workflow(); - let contains = |target, path| { - iter_embedded_assets_for_setup_target_with_selection(target, &selection) - .any(|asset| asset.relative_path == path && !asset.bytes.is_empty()) - }; - - assert!(contains(SetupTarget::OpenCode, "command/next-task.md")); - assert!(contains( - SetupTarget::OpenCode, - "lib/bash-policy-presets.json" - )); - assert!(contains(SetupTarget::Claude, "commands/next-task.md")); - assert!(contains(SetupTarget::Pi, "prompts/next-task.md")); - assert!(contains(SetupTarget::Pi, "extensions/sce/index.ts")); - assert!(iter_required_hook_assets().all(|asset| !asset.bytes.is_empty())); - } - - #[test] - fn codex_embedded_assets_cover_both_output_roots_with_no_command_dir() { - let has = |path: &str| { - CODEX_EMBEDDED_ASSETS - .iter() - .any(|asset| asset.relative_path == path && !asset.bytes.is_empty()) - }; - - assert!(has(".agents/skills/sce-next-task/SKILL.md")); - assert!(has(".codex/hooks.json")); - assert!(has(".codex/hooks/run-sce-or-show-install-guidance.sh")); - assert!(!CODEX_EMBEDDED_ASSETS - .iter() - .any(|asset| asset.relative_path.starts_with(".agents/commands/"))); - } - - #[test] - fn install_writes_codex_assets_directly_under_repo_root() { - let repo = init_git_repo("install-codex-dual-roots"); - let selection: Vec = every_optional_workflow() - .into_iter() - .map(str::to_string) - .collect(); - - install_embedded_setup_assets(&repo, SetupTarget::Codex, &selection) - .expect("codex install should succeed"); - - assert!(repo.join(".agents/skills/sce-next-task/SKILL.md").is_file()); - assert!(repo.join(".codex/hooks.json").is_file()); - assert!(repo - .join(".codex/hooks/run-sce-or-show-install-guidance.sh") - .is_file()); - assert!(!repo.join(".codex/.agents").exists()); - assert!(!repo.join(".agents/.codex").exists()); - - let _ = fs::remove_dir_all(&repo); - } - - #[test] - fn install_merges_codex_hooks_and_replaces_stale_owned_handlers_idempotently() { - let repo = init_git_repo("install-merges-codex-hooks"); - let hooks_path = repo.join(".codex/hooks.json"); - fs::create_dir_all(hooks_path.parent().unwrap()).expect("create Codex directory"); - let stale_command = "bash .codex/hooks/run-sce-or-show-install-guidance.sh sce hooks codex"; - let existing = json!({ - "description": "user hooks", - "hooks": { - "UserPromptSubmit": [{"hooks": [ - {"type": "command", "command": "echo user"}, - {"type": "command", "command": stale_command} - ]}], - "SessionStart": [{"hooks": [{"type": "command", "command": "echo session"}]}] - } - }); - fs::write(&hooks_path, serde_json::to_vec(&existing).unwrap()).expect("seed hooks config"); - let selection: Vec = every_optional_workflow() - .into_iter() - .map(str::to_string) - .collect(); - - install_embedded_setup_assets(&repo, SetupTarget::Codex, &selection) - .expect("first Codex install should succeed"); - let first = fs::read(&hooks_path).expect("read merged hooks config"); - install_embedded_setup_assets(&repo, SetupTarget::Codex, &selection) - .expect("second Codex install should succeed"); - let second = fs::read(&hooks_path).expect("read merged hooks config again"); - assert_eq!(first, second); - - let merged: serde_json::Value = serde_json::from_slice(&second).unwrap(); - assert_eq!(merged["description"], "user hooks"); - assert_eq!( - merged["hooks"]["SessionStart"][0]["hooks"][0]["command"], - "echo session" - ); - assert_eq!( - merged["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"], - "echo user" - ); - assert_eq!(merged["hooks"].as_object().unwrap().len(), 8); - for event in ["Interrupt", "SubagentStop", "SessionEnd"] { - assert!( - merged["hooks"][event][0]["hooks"][0]["command"] - .as_str() - .unwrap() - .ends_with("sce hooks codex-mutation-scope"), - "{event} must route to the mutation-scope command" - ); - } - assert!(merged["hooks"]["PreToolUse"][1]["hooks"][0]["command"] - .as_str() - .unwrap() - .ends_with("sce hooks codex-mutation-scope")); - - let _ = fs::remove_dir_all(&repo); - } - - #[test] - fn invalid_codex_hooks_are_not_modified() { - let invalid_documents = [ - br#"{\"hooks\":{"#.to_vec(), - serde_json::to_vec(&json!({"custom": true})).unwrap(), - serde_json::to_vec(&json!({"hooks": {"Stop": [{"matcher": 42}]}})).unwrap(), - serde_json::to_vec(&json!({"hooks": {"Stop": [{"hooks": "invalid"}]}})).unwrap(), - serde_json::to_vec(&json!({"hooks": {"Stop": [{"hooks": [{"nonsense": true}]}]}})) - .unwrap(), - serde_json::to_vec(&json!({"hooks": {"Stop": [{"hooks": [{"type": "unknown"}]}]}})) - .unwrap(), - ]; - let selection: Vec = every_optional_workflow() - .into_iter() - .map(str::to_string) - .collect(); - - for (index, original) in invalid_documents.iter().enumerate() { - let repo = init_git_repo(&format!("install-rejects-malformed-codex-hooks-{index}")); - let hooks_path = repo.join(".codex/hooks.json"); - fs::create_dir_all(hooks_path.parent().unwrap()).expect("create Codex directory"); - fs::write(&hooks_path, original).expect("seed malformed hooks config"); - - let error = install_embedded_setup_assets(&repo, SetupTarget::Codex, &selection) - .expect_err("malformed Codex hooks should fail setup"); - assert!(error.to_string().contains(".codex/hooks.json")); - assert_eq!(fs::read(&hooks_path).unwrap(), original.as_slice()); - - let _ = fs::remove_dir_all(&repo); - } - } - - #[test] - fn install_preserves_user_owned_files_and_writes_sce_assets() { - let repo = init_git_repo("install-preserves-user-files"); - let claude_dir = default_paths::InstallTargetPaths::new(&repo).claude_target_dir(); - - fs::create_dir_all(claude_dir.join("skills/my-own-skill")).expect("create user skill dir"); - fs::create_dir_all(claude_dir.join("commands")).expect("create commands dir"); - - fs::write(claude_dir.join("MY_NOTES.md"), "top level user notes\n") - .expect("seed top-level user file"); - fs::write( - claude_dir.join("skills/my-own-skill/SKILL.md"), - "user skill content\n", - ) - .expect("seed user skill file"); - fs::write( - claude_dir.join("commands/my-command.md"), - "user command content\n", - ) - .expect("seed user command file"); - - let selection: Vec = every_optional_workflow() - .into_iter() - .map(str::to_string) - .collect(); - - install_embedded_setup_assets(&repo, SetupTarget::Claude, &selection) - .expect("install should succeed"); - - assert_eq!( - fs::read_to_string(claude_dir.join("MY_NOTES.md")).expect("read top-level user file"), - "top level user notes\n" - ); - assert_eq!( - fs::read_to_string(claude_dir.join("skills/my-own-skill/SKILL.md")) - .expect("read user skill file"), - "user skill content\n" - ); - assert_eq!( - fs::read_to_string(claude_dir.join("commands/my-command.md")) - .expect("read user command file"), - "user command content\n" - ); - - let expected_next_task_bytes = - iter_embedded_assets_for_setup_target_with_selection(SetupTarget::Claude, &selection) - .find(|asset| asset.relative_path == "commands/next-task.md") - .expect("next-task asset should be in the catalog") - .bytes; - assert_eq!( - fs::read(claude_dir.join("commands/next-task.md")).expect("read installed sce asset"), - expected_next_task_bytes - ); - - let _ = fs::remove_dir_all(&repo); - } - - #[test] - fn install_merges_into_existing_claude_settings_json_and_stays_idempotent() { - let repo = init_git_repo("install-merges-claude-settings"); - let claude_dir = default_paths::InstallTargetPaths::new(&repo).claude_target_dir(); - - fs::create_dir_all(&claude_dir).expect("create claude dir"); - fs::write( - claude_dir.join("settings.json"), - serde_json::to_string_pretty(&json!({ - "permissions": {"allow": ["Bash(git *)"]}, - "env": {"FOO": "bar"}, - "hooks": { - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [{"type": "command", "command": "echo user-hook"}] - } - ] - } - })) - .expect("serialize seeded settings"), - ) - .expect("seed existing settings.json"); - - let selection: Vec = every_optional_workflow() - .into_iter() - .map(str::to_string) - .collect(); - - install_embedded_setup_assets(&repo, SetupTarget::Claude, &selection) - .expect("first install should succeed"); - - let after_first = - fs::read_to_string(claude_dir.join("settings.json")).expect("read merged settings"); - let merged: serde_json::Value = - serde_json::from_str(&after_first).expect("merged settings should be valid JSON"); - - assert_eq!(merged["permissions"]["allow"][0], "Bash(git *)"); - assert_eq!(merged["env"]["FOO"], "bar"); - let pre_tool_use = merged["hooks"]["PreToolUse"] - .as_array() - .expect("PreToolUse should be an array"); - assert!(pre_tool_use - .iter() - .any(|entry| entry["hooks"][0]["command"] == "echo user-hook")); - assert!(pre_tool_use - .iter() - .any(|entry| entry["hooks"] - .as_array() - .unwrap() - .iter() - .any(|hook| hook["command"] - .as_str() - .unwrap() - .contains("run-sce-or-show-install-guidance.sh")))); - - install_embedded_setup_assets(&repo, SetupTarget::Claude, &selection) - .expect("second install should succeed"); - - let after_second = - fs::read_to_string(claude_dir.join("settings.json")).expect("read re-merged settings"); - assert_eq!( - after_first, after_second, - "two consecutive installs should merge to byte-identical output" - ); - - let _ = fs::remove_dir_all(&repo); - } - - #[test] - fn install_merges_into_existing_opencode_config_json_and_stays_idempotent() { - let repo = init_git_repo("install-merges-opencode-config"); - let opencode_dir = default_paths::InstallTargetPaths::new(&repo).opencode_target_dir(); - - fs::create_dir_all(&opencode_dir).expect("create opencode dir"); - fs::write( - opencode_dir.join("opencode.json"), - serde_json::to_string_pretty(&json!({ - "model": "anthropic/claude", - "mcp": {"my-server": {"command": "my-server"}}, - "plugin": ["./plugins/my-plugin.ts", "./plugins/sce-old-feature.ts"] - })) - .expect("serialize seeded opencode config"), - ) - .expect("seed existing opencode.json"); - - let selection: Vec = every_optional_workflow() - .into_iter() - .map(str::to_string) - .collect(); - - install_embedded_setup_assets(&repo, SetupTarget::OpenCode, &selection) - .expect("first install should succeed"); - - let after_first = fs::read_to_string(opencode_dir.join("opencode.json")) - .expect("read merged opencode config"); - let merged: serde_json::Value = serde_json::from_str(&after_first) - .expect("merged opencode config should be valid JSON"); - - assert_eq!(merged["model"], "anthropic/claude"); - assert_eq!(merged["mcp"]["my-server"]["command"], "my-server"); - - let plugin = merged["plugin"] - .as_array() - .expect("plugin should be an array"); - assert!(plugin.contains(&json!("./plugins/my-plugin.ts"))); - assert!(plugin.contains(&json!("./plugins/sce-bash-policy.ts"))); - assert!(plugin.contains(&json!("./plugins/sce-agent-trace.ts"))); - assert!(!plugin.contains(&json!("./plugins/sce-old-feature.ts"))); - assert_eq!( - plugin.last().and_then(serde_json::Value::as_str), - Some("./plugins/sce-mutation-scope.ts"), - "the mutation-scope plugin must be installed as the final plugin" - ); - - install_embedded_setup_assets(&repo, SetupTarget::OpenCode, &selection) - .expect("second install should succeed"); - - let after_second = fs::read_to_string(opencode_dir.join("opencode.json")) - .expect("read re-merged opencode config"); - assert_eq!( - after_first, after_second, - "two consecutive installs should merge to byte-identical output" - ); - - let _ = fs::remove_dir_all(&repo); - } - - #[test] - fn reinstall_with_empty_selection_prunes_deselected_workflow_without_touching_sibling_skill() { - let repo = init_git_repo("install-prunes-deselected-workflow"); - let claude_dir = default_paths::InstallTargetPaths::new(&repo).claude_target_dir(); - - let brownfield_selection = vec!["brownfield".to_string()]; - install_embedded_setup_assets(&repo, SetupTarget::Claude, &brownfield_selection) - .expect("initial install with brownfield selected should succeed"); - - let brownfield_command = claude_dir.join("commands/brownfield.md"); - let brownfield_skill_dir = claude_dir.join("skills/sce-brownfield"); - assert!( - brownfield_command.is_file(), - "brownfield command should be installed" - ); - assert!( - brownfield_skill_dir.is_dir(), - "brownfield skill dir should be installed" - ); - - fs::create_dir_all(claude_dir.join("skills/my-skill")).expect("create user skill dir"); - fs::write( - claude_dir.join("skills/my-skill/SKILL.md"), - "sibling user skill\n", - ) - .expect("seed sibling user skill file"); - - install_embedded_setup_assets(&repo, SetupTarget::Claude, &[]) - .expect("reinstall with empty selection should succeed"); - - assert!( - !brownfield_command.exists(), - "deselected workflow command should be pruned" - ); - assert!( - !brownfield_skill_dir.exists(), - "deselected workflow skill dir should be pruned entirely once empty" - ); - assert_eq!( - fs::read_to_string(claude_dir.join("skills/my-skill/SKILL.md")) - .expect("read sibling user skill file"), - "sibling user skill\n" - ); - - let _ = fs::remove_dir_all(&repo); - } - - #[test] - fn reinstall_with_empty_selection_keeps_pruned_skill_dir_holding_a_user_file() { - let repo = init_git_repo("install-prunes-but-keeps-user-file"); - let claude_dir = default_paths::InstallTargetPaths::new(&repo).claude_target_dir(); - - let brownfield_selection = vec!["brownfield".to_string()]; - install_embedded_setup_assets(&repo, SetupTarget::Claude, &brownfield_selection) - .expect("initial install with brownfield selected should succeed"); - - let brownfield_skill_dir = claude_dir.join("skills/sce-brownfield"); - fs::write( - brownfield_skill_dir.join("MY_OVERRIDE.md"), - "user file inside sce skill dir\n", - ) - .expect("seed user file inside sce-owned skill dir"); - - install_embedded_setup_assets(&repo, SetupTarget::Claude, &[]) - .expect("reinstall with empty selection should succeed"); - - assert!( - !brownfield_skill_dir.join("SKILL.md").exists(), - "deselected workflow skill file should be pruned" - ); - assert!( - brownfield_skill_dir.is_dir(), - "sce-owned skill dir should survive because it still holds a user file" - ); - assert_eq!( - fs::read_to_string(brownfield_skill_dir.join("MY_OVERRIDE.md")) - .expect("read user file inside pruned skill dir"), - "user file inside sce skill dir\n" - ); - - let _ = fs::remove_dir_all(&repo); - } - - #[test] - fn install_cleans_up_staging_and_reports_asset_path_on_rename_failure() { - let repo = init_git_repo("install-rename-failure"); - let selection: Vec = every_optional_workflow() - .into_iter() - .map(str::to_string) - .collect(); - - let claude_dir = default_paths::InstallTargetPaths::new(&repo).claude_target_dir(); - let failing_destination = claude_dir.join("commands/next-task.md"); - - fs::create_dir_all(claude_dir.join("commands")).expect("create commands dir"); - let prior_content = b"prior next-task content\n"; - fs::write(&failing_destination, prior_content).expect("seed prior next-task content"); - - let result = install::install_embedded_setup_assets_with_rename( - &repo, - SetupTarget::Claude, - &selection, - |from, to| { - if to == failing_destination { - Err(std::io::Error::other("simulated rename failure")) - } else { - fs::rename(from, to) - } - }, - ); - - let error = result.expect_err("rename failure should surface as an error"); - let message = format!("{error:#}"); - assert!( - message.contains(&failing_destination.display().to_string()), - "error should name the failing asset path: {message}" - ); - assert!( - message.contains("does not create backups"), - "error should include recovery guidance: {message}" - ); - - assert_eq!( - fs::read(&failing_destination).expect("read failing destination after rename failure"), - prior_content, - "prior content at the failing destination should survive a rename failure" - ); - - let commands_staging_dir = claude_dir.join("commands"); - if commands_staging_dir.exists() { - let leftover_staging_files = fs::read_dir(&commands_staging_dir) - .expect("read commands staging dir") - .filter_map(Result::ok) - .any(|entry| { - entry - .file_name() - .to_string_lossy() - .starts_with(".sce-setup-staging-") - }); - assert!( - !leftover_staging_files, - "staging artifact for the failed asset should be cleaned up" - ); - } - - let _ = fs::remove_dir_all(&repo); - } - - #[test] - fn hook_install_leaves_prior_hook_intact_on_rename_failure() { - let repo = init_git_repo("hook-install-rename-failure"); - - let initial_outcome = install::install_required_git_hooks(&repo) - .expect("initial hook install should succeed"); - let pre_commit_result = initial_outcome - .hook_results - .iter() - .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) - .expect("pre-commit hook should be installed"); - let pre_commit_path = pre_commit_result.hook_path.clone(); - - let prior_hook_bytes = b"#!/bin/sh\necho prior pre-commit\n".to_vec(); - fs::write(&pre_commit_path, &prior_hook_bytes).expect("seed prior pre-commit hook"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&pre_commit_path, fs::Permissions::from_mode(0o755)) - .expect("mark prior pre-commit hook executable"); - } - let prior_mode = fs::metadata(&pre_commit_path) - .expect("stat prior pre-commit hook") - .permissions(); - - let result = install::install_required_git_hooks_with_rename(&repo, |from, to| { - if to == pre_commit_path { - Err(std::io::Error::other("simulated rename failure")) - } else { - fs::rename(from, to) - } - }); - - let error = result.expect_err("rename failure should surface as an error"); - let message = format!("{error:#}"); - assert!( - message.contains(&pre_commit_path.display().to_string()), - "error should name the failing hook path: {message}" - ); - - assert_eq!( - fs::read(&pre_commit_path).expect("read pre-commit hook after rename failure"), - prior_hook_bytes, - "prior hook content should survive a rename failure" - ); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode_after = fs::metadata(&pre_commit_path) - .expect("stat pre-commit hook after rename failure") - .permissions(); - assert_eq!( - mode_after.mode() & 0o777, - prior_mode.mode() & 0o777, - "prior hook executable mode should survive a rename failure" - ); - } - - let hooks_staging_dir = pre_commit_path - .parent() - .expect("pre-commit hook should have a parent directory"); - let leftover_staging_files = fs::read_dir(hooks_staging_dir) - .expect("read hooks staging dir") - .filter_map(Result::ok) - .any(|entry| { - entry - .file_name() - .to_string_lossy() - .starts_with(".sce-hook-staging-") - }); - assert!( - !leftover_staging_files, - "staging artifact for the failed hook should be cleaned up" - ); - - let _ = fs::remove_dir_all(&repo); - } - - #[test] - fn foreign_pre_commit_hook_keeps_its_content_and_gains_the_sce_block() { - let repo = init_git_repo("hook-install-foreign-append"); - - let initial_outcome = install::install_required_git_hooks(&repo) - .expect("initial hook install should succeed"); - let pre_commit_path = initial_outcome - .hook_results - .iter() - .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) - .expect("pre-commit hook should be installed") - .hook_path - .clone(); - - let foreign_bytes = b"#!/bin/sh\necho husky-style-guard\n".to_vec(); - fs::write(&pre_commit_path, &foreign_bytes).expect("seed foreign pre-commit hook"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&pre_commit_path, fs::Permissions::from_mode(0o755)) - .expect("mark foreign pre-commit hook executable"); - } - - let outcome = install::install_required_git_hooks(&repo) - .expect("hook install over a foreign hook should succeed"); - let result = outcome - .hook_results - .iter() - .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) - .expect("pre-commit hook result should be present"); - - assert_eq!(result.status, RequiredHookInstallStatus::Updated); - assert!(!result.unreachable_block_advisory); - - let installed_bytes = fs::read(&pre_commit_path).expect("read installed pre-commit hook"); - assert!( - installed_bytes.starts_with(&foreign_bytes), - "foreign hook content should survive as an exact prefix" - ); - let installed_text = String::from_utf8(installed_bytes).expect("hook should be utf8"); - assert!(installed_text.contains(hook_merge::MANAGED_BLOCK_START)); - assert!(installed_text.contains(hook_merge::MANAGED_BLOCK_END)); - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = fs::metadata(&pre_commit_path) - .expect("stat installed pre-commit hook") - .permissions() - .mode(); - assert_ne!(mode & 0o111, 0, "installed hook should remain executable"); - } - - let _ = fs::remove_dir_all(&repo); - } - - #[test] - fn rerunning_hook_install_is_idempotent_for_block_only_and_foreign_plus_block_shapes() { - let repo = init_git_repo("hook-install-idempotent"); - - let first_outcome = - install::install_required_git_hooks(&repo).expect("first hook install should succeed"); - let pre_commit_result = first_outcome - .hook_results - .iter() - .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) - .expect("pre-commit hook should be installed"); - assert_eq!( - pre_commit_result.status, - RequiredHookInstallStatus::Installed - ); - - let second_outcome = - install::install_required_git_hooks(&repo).expect("second hook install should succeed"); - let second_pre_commit = second_outcome - .hook_results - .iter() - .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) - .expect("pre-commit hook result should be present"); - assert_eq!(second_pre_commit.status, RequiredHookInstallStatus::Skipped); - assert_eq!( - fs::read(&second_pre_commit.hook_path).expect("read block-only pre-commit hook"), - fs::read(&pre_commit_result.hook_path).expect("read initial pre-commit hook"), - "block-only hook bytes should be unchanged across reruns" - ); - - let commit_msg_result = first_outcome - .hook_results - .iter() - .find(|result| result.hook_name == default_paths::hook_dir::COMMIT_MSG) - .expect("commit-msg hook should be installed"); - let commit_msg_path = commit_msg_result.hook_path.clone(); - let foreign_prefix = b"#!/bin/sh\necho foreign-commit-msg-guard\n".to_vec(); - fs::write(&commit_msg_path, &foreign_prefix).expect("seed foreign commit-msg hook"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&commit_msg_path, fs::Permissions::from_mode(0o755)) - .expect("mark foreign commit-msg hook executable"); - } - - let appended_outcome = install::install_required_git_hooks(&repo) - .expect("hook install appending to foreign commit-msg hook should succeed"); - let appended_result = appended_outcome - .hook_results - .iter() - .find(|result| result.hook_name == default_paths::hook_dir::COMMIT_MSG) - .expect("commit-msg hook result should be present"); - assert_eq!(appended_result.status, RequiredHookInstallStatus::Updated); - let appended_bytes = fs::read(&commit_msg_path).expect("read appended commit-msg hook"); - - let rerun_outcome = install::install_required_git_hooks(&repo) - .expect("rerunning hook install over foreign-plus-block hook should succeed"); - let rerun_result = rerun_outcome - .hook_results - .iter() - .find(|result| result.hook_name == default_paths::hook_dir::COMMIT_MSG) - .expect("commit-msg hook result should be present"); - assert_eq!(rerun_result.status, RequiredHookInstallStatus::Skipped); - assert_eq!( - fs::read(&commit_msg_path).expect("read commit-msg hook after rerun"), - appended_bytes, - "foreign-plus-block hook bytes should be unchanged across reruns" - ); - - let _ = fs::remove_dir_all(&repo); - } - - #[test] - fn legacy_pre_marker_hook_upgrades_to_the_managed_block_form() { - let repo = init_git_repo("hook-install-legacy-upgrade"); - - let initial_outcome = install::install_required_git_hooks(&repo) - .expect("initial hook install should succeed"); - let pre_commit_path = initial_outcome - .hook_results - .iter() - .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) - .expect("pre-commit hook should be installed") - .hook_path - .clone(); - let canonical_bytes = fs::read(&pre_commit_path).expect("read canonical pre-commit hook"); - - let legacy_bytes = b"#!/bin/sh\nset -eu\nif ! command -v sce >/dev/null 2>&1; then\n echo 'Install: https://sce.crocoder.dev/docs/getting-started#install-cli'\n exit 0\nfi\nexec sce hooks pre-commit \"$@\"\n".to_vec(); - fs::write(&pre_commit_path, &legacy_bytes).expect("seed legacy pre-commit hook"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&pre_commit_path, fs::Permissions::from_mode(0o755)) - .expect("mark legacy pre-commit hook executable"); - } - - let outcome = install::install_required_git_hooks(&repo) - .expect("hook install upgrading a legacy hook should succeed"); - let result = outcome - .hook_results - .iter() - .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) - .expect("pre-commit hook result should be present"); - - assert_eq!(result.status, RequiredHookInstallStatus::Updated); - assert_eq!( - fs::read(&pre_commit_path).expect("read upgraded pre-commit hook"), - canonical_bytes, - "a legacy pre-marker hook should upgrade to the canonical marker form" - ); - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = fs::metadata(&pre_commit_path) - .expect("stat upgraded pre-commit hook") - .permissions() - .mode(); - assert_ne!(mode & 0o111, 0, "upgraded hook should remain executable"); - } - - let _ = fs::remove_dir_all(&repo); - } - - #[test] - fn foreign_hook_ending_in_exec_installs_the_block_and_reports_the_advisory() { - let repo = init_git_repo("hook-install-unreachable-advisory"); - - let initial_outcome = install::install_required_git_hooks(&repo) - .expect("initial hook install should succeed"); - let pre_commit_path = initial_outcome - .hook_results - .iter() - .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) - .expect("pre-commit hook should be installed") - .hook_path - .clone(); - let commit_msg_path = initial_outcome - .hook_results - .iter() - .find(|result| result.hook_name == default_paths::hook_dir::COMMIT_MSG) - .expect("commit-msg hook should be installed") - .hook_path - .clone(); - - let unreachable_foreign = b"#!/bin/sh\nexec some-other-tool \"$@\"\n".to_vec(); - fs::write(&pre_commit_path, &unreachable_foreign).expect("seed unreachable foreign hook"); - let ordinary_foreign = b"#!/bin/sh\necho foreign-commit-msg-guard\n".to_vec(); - fs::write(&commit_msg_path, &ordinary_foreign).expect("seed ordinary foreign hook"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(&pre_commit_path, fs::Permissions::from_mode(0o755)) - .expect("mark unreachable foreign hook executable"); - fs::set_permissions(&commit_msg_path, fs::Permissions::from_mode(0o755)) - .expect("mark ordinary foreign hook executable"); - } - - let outcome = install::install_required_git_hooks(&repo) - .expect("hook install over foreign hooks should succeed"); - - let pre_commit_result = outcome - .hook_results - .iter() - .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) - .expect("pre-commit hook result should be present"); - assert_eq!(pre_commit_result.status, RequiredHookInstallStatus::Updated); - assert!( - pre_commit_result.unreachable_block_advisory, - "a hook ending in a zero-indent exec should report the advisory" - ); - assert!( - fs::read(&pre_commit_path) - .expect("read pre-commit hook") - .starts_with(&unreachable_foreign), - "the block should still be installed even though it is unreachable" - ); - - let commit_msg_result = outcome - .hook_results - .iter() - .find(|result| result.hook_name == default_paths::hook_dir::COMMIT_MSG) - .expect("commit-msg hook result should be present"); - assert!( - !commit_msg_result.unreachable_block_advisory, - "a hook ending in an ordinary command should not report the advisory" - ); - - let _ = fs::remove_dir_all(&repo); - } -} +mod tests; diff --git a/cli/src/services/setup/prompt.rs b/cli/src/services/setup/prompt.rs new file mode 100644 index 000000000..cf92217b4 --- /dev/null +++ b/cli/src/services/setup/prompt.rs @@ -0,0 +1,189 @@ +use anyhow::{bail, Result}; +use inquire::{Confirm, InquireError, MultiSelect, Select}; + +use crate::services::style::{ + prompt_label, prompt_label_with_color_policy, prompt_value_with_color_policy, +}; + +use super::{OptionalWorkflow, SetupDispatch, SetupMode, SetupPromptTarget, SetupTarget}; + +fn proceed(target: SetupTarget) -> SetupDispatch { + SetupDispatch::Proceed { + mode: SetupMode::NonInteractive(target), + optional_workflows: None, + agent_trace_auto_sync: None, + attribution_hooks_enabled: None, + } +} + +pub(super) fn prompt_target() -> Result { + let options = vec![ + SetupPromptTarget::OpenCode, + SetupPromptTarget::Claude, + SetupPromptTarget::Pi, + SetupPromptTarget::Codex, + SetupPromptTarget::All, + ]; + + let selection = Select::new(&setup_prompt_title(), options).prompt(); + + match selection { + Ok(SetupPromptTarget::OpenCode) => Ok(proceed(SetupTarget::OpenCode)), + Ok(SetupPromptTarget::Claude) => Ok(proceed(SetupTarget::Claude)), + Ok(SetupPromptTarget::Pi) => Ok(proceed(SetupTarget::Pi)), + Ok(SetupPromptTarget::Codex) => Ok(proceed(SetupTarget::Codex)), + Ok(SetupPromptTarget::All) => Ok(proceed(SetupTarget::All)), + Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => { + Ok(SetupDispatch::Cancelled) + } + Err(InquireError::NotTTY) => bail!( + "Interactive setup requires a TTY. Re-run with '--non-interactive' and one of '--opencode', '--claude', '--pi', '--codex', or '--all'." + ), + Err(error) => Err(error.into()), + } +} + +pub(super) fn prompt_optional_workflows(defaults: &[String]) -> Result>> { + let Some((rows, default_indices)) = + optional_workflow_prompt_inputs(super::OPTIONAL_WORKFLOWS, defaults) + else { + return Ok(Some(Vec::new())); + }; + + let selection = MultiSelect::new(&optional_workflow_prompt_title(), rows) + .with_default(&default_indices) + .prompt(); + + match selection { + Ok(selected) => Ok(Some( + selected + .into_iter() + .map(|row| row.workflow.id.to_string()) + .collect(), + )), + Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => Ok(None), + Err(InquireError::NotTTY) => bail!( + "Interactive setup requires a TTY. Re-run with '--non-interactive' and one of '--opencode', '--claude', '--pi', '--codex', or '--all', adding '--workflow ' for each optional workflow to install." + ), + Err(error) => Err(error.into()), + } +} + +pub(super) fn prompt_agent_trace_auto_sync() -> Result> { + prompt_confirmation( + "Automatically sync Agent Traces?\n(Requires an SCE account. Sends Agent Traces from supported AI coding tools\nto SCE servers so they can be stored and viewed in your account.)", + ) +} + +pub(super) fn prompt_attribution_hooks_enabled() -> Result> { + prompt_confirmation( + "Record SCE involvement in Git commits?\n(Adds SCE metadata to commits created or assisted by SCE.)", + ) +} + +fn prompt_confirmation(label: &str) -> Result> { + match Confirm::new(label).with_default(true).prompt() { + Ok(value) => Ok(Some(value)), + Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => Ok(None), + Err(InquireError::NotTTY) => bail!( + "Interactive setup requires a TTY. Re-run with '--non-interactive' and one of '--opencode', '--claude', '--pi', '--codex', or '--all'." + ), + Err(error) => Err(error.into()), + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct OptionalWorkflowRow { + pub(super) workflow: &'static OptionalWorkflow, +} + +impl std::fmt::Display for OptionalWorkflowRow { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", optional_workflow_row_label(self.workflow)) + } +} + +pub(super) fn optional_workflow_prompt_inputs( + catalog: &'static [OptionalWorkflow], + defaults: &[String], +) -> Option<(Vec, Vec)> { + if catalog.is_empty() { + return None; + } + + Some(( + optional_workflow_rows(catalog), + optional_workflow_default_indices(catalog, defaults), + )) +} + +pub(super) fn optional_workflow_rows( + catalog: &'static [OptionalWorkflow], +) -> Vec { + catalog + .iter() + .map(|workflow| OptionalWorkflowRow { workflow }) + .collect() +} + +pub(super) fn optional_workflow_default_indices( + catalog: &'static [OptionalWorkflow], + defaults: &[String], +) -> Vec { + catalog + .iter() + .enumerate() + .filter(|(_, workflow)| defaults.iter().any(|id| id == workflow.id)) + .map(|(index, _)| index) + .collect() +} + +pub(super) fn optional_workflow_prompt_title() -> String { + prompt_label("Select optional workflows") +} + +pub(super) fn optional_workflow_row_label(workflow: &OptionalWorkflow) -> String { + optional_workflow_row_label_with_color_policy( + workflow, + crate::services::style::supports_color(), + ) +} + +pub(super) fn optional_workflow_row_label_with_color_policy( + workflow: &OptionalWorkflow, + color_enabled: bool, +) -> String { + format!( + "{} — {}", + prompt_value_with_color_policy(workflow.title, color_enabled), + workflow.description + ) +} + +pub(super) fn setup_prompt_title() -> String { + prompt_label("Select setup target") +} + +pub(super) fn setup_prompt_target_label(target: SetupPromptTarget) -> String { + setup_prompt_target_label_with_color_policy(target, crate::services::style::supports_color()) +} + +pub(super) fn setup_prompt_target_label_with_color_policy( + target: SetupPromptTarget, + color_enabled: bool, +) -> String { + let label = match target { + SetupPromptTarget::OpenCode => "OpenCode", + SetupPromptTarget::Claude => "Claude", + SetupPromptTarget::Pi => "Pi", + SetupPromptTarget::Codex => "Codex", + SetupPromptTarget::All => "All (OpenCode + Claude + Pi + Codex)", + }; + + prompt_value_with_color_policy(label, color_enabled) +} + +#[allow(dead_code)] +pub(super) fn setup_prompt_title_with_color_policy(color_enabled: bool) -> String { + prompt_label_with_color_policy("Select setup target", color_enabled) +} diff --git a/cli/src/services/setup/tests.rs b/cli/src/services/setup/tests.rs new file mode 100644 index 000000000..7757ce092 --- /dev/null +++ b/cli/src/services/setup/tests.rs @@ -0,0 +1,1123 @@ +use super::*; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::command_surface; +use crate::services::command_registry::CommandRegistry; +use crate::services::command_registry::RuntimeCommand; +use crate::services::parse::command_runtime::parse_runtime_command; + +fn options_with(mutate: impl FnOnce(&mut SetupCliOptions)) -> SetupCliOptions { + let mut options = SetupCliOptions::default(); + mutate(&mut options); + options +} + +fn unique_temp_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after Unix epoch") + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "sce-setup-context-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&dir).expect("create temp dir"); + dir +} + +fn init_git_repo(label: &str) -> PathBuf { + let repo = unique_temp_dir(label); + let output = Command::new("git") + .args(["init", "-q"]) + .current_dir(&repo) + .output() + .expect("git init should spawn"); + assert!( + output.status.success(), + "git init failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + repo +} + +fn assert_baseline_paths_exist(repo: &Path) { + let paths = RepoPaths::new(repo); + for path in [ + paths.context_overview_file(), + paths.context_architecture_file(), + paths.context_patterns_file(), + paths.context_glossary_file(), + paths.context_map_file(), + paths.context_plans_dir(), + paths.context_handovers_dir(), + paths.context_decisions_dir(), + paths.context_tmp_dir(), + paths.context_tmp_gitignore_file(), + ] { + assert!(path.exists(), "expected baseline path {}", path.display()); + } +} + +#[test] +fn repo_local_config_bootstrap_payload_uses_versioned_schema_url() { + let payload = repo_local_config_bootstrap_payload(); + + assert_eq!( + payload, + format!( + "{{\n \"$schema\": \"https://sce.crocoder.dev/v{}/config.json\",\n \"agent_trace\": {{\n \"auto_sync\": true\n }},\n \"policies\": {{\n \"attribution_hooks\": {{\n \"enabled\": true\n }}\n }}\n}}\n", + env!("CARGO_PKG_VERSION") + ) + ); +} + +#[test] +fn resolve_setup_request_accepts_pi_target() { + let request = resolve_setup_request(options_with(|options| { + options.pi = true; + options.non_interactive = true; + })) + .expect("pi target should resolve"); + + assert_eq!( + request.config_mode, + Some(SetupMode::NonInteractive(SetupTarget::Pi)) + ); + assert!(!request.context_only); +} + +#[test] +fn resolve_setup_request_accepts_codex_target() { + let request = resolve_setup_request(options_with(|options| { + options.codex = true; + options.non_interactive = true; + })) + .expect("codex target should resolve"); + + assert_eq!( + request.config_mode, + Some(SetupMode::NonInteractive(SetupTarget::Codex)) + ); + assert!(!request.context_only); +} + +#[test] +fn resolve_setup_request_accepts_all_target() { + let request = resolve_setup_request(options_with(|options| { + options.all = true; + options.non_interactive = true; + })) + .expect("all target should resolve"); + + assert_eq!( + request.config_mode, + Some(SetupMode::NonInteractive(SetupTarget::All)) + ); + assert!(!request.context_only); +} + +#[test] +fn resolve_setup_request_accepts_bootstrap_context_alone() { + let request = resolve_setup_request(options_with(|options| { + options.bootstrap_context = true; + })) + .expect("bootstrap-context alone should resolve"); + + assert!(request.context_only); + assert_eq!(request.config_mode, None); + assert!(!request.install_hooks); + assert_eq!(request.hooks_repo_path, None); +} + +#[test] +fn resolve_setup_request_rejects_bootstrap_context_with_target() { + let error = resolve_setup_request(options_with(|options| { + options.bootstrap_context = true; + options.opencode = true; + })) + .expect_err("bootstrap-context with target must be rejected"); + + assert!(error.to_string().contains("--bootstrap-context")); + assert!(error.to_string().contains("alone")); +} + +#[test] +fn resolve_setup_request_rejects_combined_target_flags() { + let error = resolve_setup_request(options_with(|options| { + options.pi = true; + options.all = true; + })) + .expect_err("combined target flags must be rejected"); + + assert!(error.to_string().contains("mutually exclusive")); +} + +#[test] +fn resolve_setup_request_non_interactive_error_lists_pi_and_all() { + let error = resolve_setup_request(options_with(|options| { + options.non_interactive = true; + })) + .expect_err("non-interactive without target must be rejected"); + + let message = error.to_string(); + assert!(message.contains("--pi")); + assert!(message.contains("--all")); +} + +#[test] +fn parser_routes_bootstrap_context_to_context_only_request() { + let registry = CommandRegistry::default(); + let command = parse_runtime_command( + [ + "sce".to_string(), + "setup".to_string(), + "--bootstrap-context".to_string(), + ], + ®istry, + None, + ) + .expect("bootstrap-context should parse"); + + match command { + RuntimeCommand::Setup(setup_command) => { + assert!(setup_command.request.context_only); + assert_eq!(setup_command.request.config_mode, None); + assert!(!setup_command.request.install_hooks); + } + _ => panic!("expected Setup command for --bootstrap-context"), + } +} + +#[test] +fn help_documents_bootstrap_context_flag() { + let top_level_help = command_surface::help_text(); + assert!( + top_level_help.contains("--bootstrap-context"), + "top-level help should document --bootstrap-context" + ); + + let registry = CommandRegistry::default(); + let command = parse_runtime_command( + ["sce".to_string(), "setup".to_string(), "--help".to_string()], + ®istry, + None, + ) + .expect("setup --help should parse"); + + match command { + RuntimeCommand::HelpText(help) => { + assert!( + help.text.contains("--bootstrap-context"), + "setup --help should document --bootstrap-context:\n{}", + help.text + ); + } + _ => panic!("expected HelpText for setup --help"), + } +} + +#[test] +fn bootstrap_context_baseline_creates_expected_paths() { + let repo = init_git_repo("create-baseline"); + let message = bootstrap_context_baseline(&repo).expect("bootstrap should create baseline"); + assert!(message.contains("Context baseline ensured.")); + assert_baseline_paths_exist(&repo); + + let paths = RepoPaths::new(&repo); + assert!(!paths.opencode_dir().exists()); + assert!(!paths.claude_dir().exists()); + assert!(!paths.pi_dir().exists()); + + let gitignore = fs::read_to_string(paths.context_tmp_gitignore_file()) + .expect("tmp gitignore should be readable"); + assert_eq!(gitignore, CONTEXT_TMP_GITIGNORE_CONTENT); + + let _ = fs::remove_dir_all(&repo); +} + +#[test] +fn bootstrap_context_baseline_is_additive_and_idempotent() { + let repo = init_git_repo("idempotent-baseline"); + bootstrap_context_baseline(&repo).expect("initial bootstrap"); + + let paths = RepoPaths::new(&repo); + let sentinel = "SENTINEL_OVERVIEW_CONTENT\n"; + fs::write(paths.context_overview_file(), sentinel).expect("seed overview sentinel"); + fs::write(paths.context_map_file(), "SENTINEL_CONTEXT_MAP\n") + .expect("seed context-map sentinel"); + fs::write(paths.context_tmp_gitignore_file(), "SENTINEL_GITIGNORE\n") + .expect("seed gitignore sentinel"); + + fs::remove_file(paths.context_architecture_file()).expect("remove architecture"); + fs::remove_dir_all(paths.context_plans_dir()).expect("remove plans"); + + bootstrap_context_baseline(&repo).expect("rerun bootstrap"); + + assert_eq!( + fs::read_to_string(paths.context_overview_file()).expect("read overview"), + sentinel + ); + assert_eq!( + fs::read_to_string(paths.context_map_file()).expect("read context-map"), + "SENTINEL_CONTEXT_MAP\n" + ); + assert_eq!( + fs::read_to_string(paths.context_tmp_gitignore_file()).expect("read gitignore"), + "SENTINEL_GITIGNORE\n" + ); + assert!(paths.context_architecture_file().exists()); + assert!(paths.context_plans_dir().is_dir()); + + let _ = fs::remove_dir_all(&repo); +} + +#[test] +fn concrete_targets_for_all_expands_to_four_targets() { + assert_eq!( + concrete_targets_for(SetupTarget::All), + &[ + SetupTarget::OpenCode, + SetupTarget::Claude, + SetupTarget::Pi, + SetupTarget::Codex + ] + ); +} + +#[test] +fn integration_target_id_str_maps_pi() { + assert_eq!(integration_target_id_str(SetupTarget::Pi), "pi"); +} + +#[test] +fn integration_target_id_str_maps_codex() { + assert_eq!(integration_target_id_str(SetupTarget::Codex), "codex"); +} + +fn every_optional_workflow() -> Vec<&'static str> { + super::OPTIONAL_WORKFLOWS + .iter() + .map(|workflow| workflow.id) + .collect() +} + +#[test] +fn iter_embedded_assets_for_all_covers_each_concrete_target() { + let selection = every_optional_workflow(); + let count = + |target| iter_embedded_assets_for_setup_target_with_selection(target, &selection).count(); + + let concrete_sum = count(SetupTarget::OpenCode) + + count(SetupTarget::Claude) + + count(SetupTarget::Pi) + + count(SetupTarget::Codex); + + assert!(count(SetupTarget::Pi) > 0); + assert!(count(SetupTarget::Codex) > 0); + assert_eq!(count(SetupTarget::All), concrete_sum); +} + +#[test] +fn embedded_build_payload_contains_generated_targets_and_static_hooks() { + let selection = every_optional_workflow(); + let contains = |target, path| { + iter_embedded_assets_for_setup_target_with_selection(target, &selection) + .any(|asset| asset.relative_path == path && !asset.bytes.is_empty()) + }; + + assert!(contains(SetupTarget::OpenCode, "command/next-task.md")); + assert!(contains( + SetupTarget::OpenCode, + "lib/bash-policy-presets.json" + )); + assert!(contains(SetupTarget::Claude, "commands/next-task.md")); + assert!(contains(SetupTarget::Pi, "prompts/next-task.md")); + assert!(contains(SetupTarget::Pi, "extensions/sce/index.ts")); + assert!(iter_required_hook_assets().all(|asset| !asset.bytes.is_empty())); +} + +#[test] +fn codex_embedded_assets_cover_both_output_roots_with_no_command_dir() { + let has = |path: &str| { + CODEX_EMBEDDED_ASSETS + .iter() + .any(|asset| asset.relative_path == path && !asset.bytes.is_empty()) + }; + + assert!(has(".agents/skills/sce-next-task/SKILL.md")); + assert!(has(".codex/hooks.json")); + assert!(has(".codex/hooks/run-sce-or-show-install-guidance.sh")); + assert!(!CODEX_EMBEDDED_ASSETS + .iter() + .any(|asset| asset.relative_path.starts_with(".agents/commands/"))); +} + +#[test] +fn install_writes_codex_assets_directly_under_repo_root() { + let repo = init_git_repo("install-codex-dual-roots"); + let selection: Vec = every_optional_workflow() + .into_iter() + .map(str::to_string) + .collect(); + + install_embedded_setup_assets(&repo, SetupTarget::Codex, &selection) + .expect("codex install should succeed"); + + assert!(repo.join(".agents/skills/sce-next-task/SKILL.md").is_file()); + assert!(repo.join(".codex/hooks.json").is_file()); + assert!(repo + .join(".codex/hooks/run-sce-or-show-install-guidance.sh") + .is_file()); + assert!(!repo.join(".codex/.agents").exists()); + assert!(!repo.join(".agents/.codex").exists()); + + let _ = fs::remove_dir_all(&repo); +} + +#[test] +fn install_merges_codex_hooks_and_replaces_stale_owned_handlers_idempotently() { + let repo = init_git_repo("install-merges-codex-hooks"); + let hooks_path = repo.join(".codex/hooks.json"); + fs::create_dir_all(hooks_path.parent().unwrap()).expect("create Codex directory"); + let stale_command = "bash .codex/hooks/run-sce-or-show-install-guidance.sh sce hooks codex"; + let existing = json!({ + "description": "user hooks", + "hooks": { + "UserPromptSubmit": [{"hooks": [ + {"type": "command", "command": "echo user"}, + {"type": "command", "command": stale_command} + ]}], + "SessionStart": [{"hooks": [{"type": "command", "command": "echo session"}]}] + } + }); + fs::write(&hooks_path, serde_json::to_vec(&existing).unwrap()).expect("seed hooks config"); + let selection: Vec = every_optional_workflow() + .into_iter() + .map(str::to_string) + .collect(); + + install_embedded_setup_assets(&repo, SetupTarget::Codex, &selection) + .expect("first Codex install should succeed"); + let first = fs::read(&hooks_path).expect("read merged hooks config"); + install_embedded_setup_assets(&repo, SetupTarget::Codex, &selection) + .expect("second Codex install should succeed"); + let second = fs::read(&hooks_path).expect("read merged hooks config again"); + assert_eq!(first, second); + + let merged: serde_json::Value = serde_json::from_slice(&second).unwrap(); + assert_eq!(merged["description"], "user hooks"); + assert_eq!( + merged["hooks"]["SessionStart"][0]["hooks"][0]["command"], + "echo session" + ); + assert_eq!( + merged["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"], + "echo user" + ); + assert_eq!(merged["hooks"].as_object().unwrap().len(), 8); + for event in ["Interrupt", "SubagentStop", "SessionEnd"] { + assert!( + merged["hooks"][event][0]["hooks"][0]["command"] + .as_str() + .unwrap() + .ends_with("sce hooks codex-mutation-scope"), + "{event} must route to the mutation-scope command" + ); + } + assert!(merged["hooks"]["PreToolUse"][1]["hooks"][0]["command"] + .as_str() + .unwrap() + .ends_with("sce hooks codex-mutation-scope")); + + let _ = fs::remove_dir_all(&repo); +} + +#[test] +fn invalid_codex_hooks_are_not_modified() { + let invalid_documents = [ + br#"{\"hooks\":{"#.to_vec(), + serde_json::to_vec(&json!({"custom": true})).unwrap(), + serde_json::to_vec(&json!({"hooks": {"Stop": [{"matcher": 42}]}})).unwrap(), + serde_json::to_vec(&json!({"hooks": {"Stop": [{"hooks": "invalid"}]}})).unwrap(), + serde_json::to_vec(&json!({"hooks": {"Stop": [{"hooks": [{"nonsense": true}]}]}})).unwrap(), + serde_json::to_vec(&json!({"hooks": {"Stop": [{"hooks": [{"type": "unknown"}]}]}})) + .unwrap(), + ]; + let selection: Vec = every_optional_workflow() + .into_iter() + .map(str::to_string) + .collect(); + + for (index, original) in invalid_documents.iter().enumerate() { + let repo = init_git_repo(&format!("install-rejects-malformed-codex-hooks-{index}")); + let hooks_path = repo.join(".codex/hooks.json"); + fs::create_dir_all(hooks_path.parent().unwrap()).expect("create Codex directory"); + fs::write(&hooks_path, original).expect("seed malformed hooks config"); + + let error = install_embedded_setup_assets(&repo, SetupTarget::Codex, &selection) + .expect_err("malformed Codex hooks should fail setup"); + assert!(error.to_string().contains(".codex/hooks.json")); + assert_eq!(fs::read(&hooks_path).unwrap(), original.as_slice()); + + let _ = fs::remove_dir_all(&repo); + } +} + +#[test] +fn install_preserves_user_owned_files_and_writes_sce_assets() { + let repo = init_git_repo("install-preserves-user-files"); + let claude_dir = default_paths::InstallTargetPaths::new(&repo).claude_target_dir(); + + fs::create_dir_all(claude_dir.join("skills/my-own-skill")).expect("create user skill dir"); + fs::create_dir_all(claude_dir.join("commands")).expect("create commands dir"); + + fs::write(claude_dir.join("MY_NOTES.md"), "top level user notes\n") + .expect("seed top-level user file"); + fs::write( + claude_dir.join("skills/my-own-skill/SKILL.md"), + "user skill content\n", + ) + .expect("seed user skill file"); + fs::write( + claude_dir.join("commands/my-command.md"), + "user command content\n", + ) + .expect("seed user command file"); + + let selection: Vec = every_optional_workflow() + .into_iter() + .map(str::to_string) + .collect(); + + install_embedded_setup_assets(&repo, SetupTarget::Claude, &selection) + .expect("install should succeed"); + + assert_eq!( + fs::read_to_string(claude_dir.join("MY_NOTES.md")).expect("read top-level user file"), + "top level user notes\n" + ); + assert_eq!( + fs::read_to_string(claude_dir.join("skills/my-own-skill/SKILL.md")) + .expect("read user skill file"), + "user skill content\n" + ); + assert_eq!( + fs::read_to_string(claude_dir.join("commands/my-command.md")) + .expect("read user command file"), + "user command content\n" + ); + + let expected_next_task_bytes = + iter_embedded_assets_for_setup_target_with_selection(SetupTarget::Claude, &selection) + .find(|asset| asset.relative_path == "commands/next-task.md") + .expect("next-task asset should be in the catalog") + .bytes; + assert_eq!( + fs::read(claude_dir.join("commands/next-task.md")).expect("read installed sce asset"), + expected_next_task_bytes + ); + + let _ = fs::remove_dir_all(&repo); +} + +#[test] +fn install_merges_into_existing_claude_settings_json_and_stays_idempotent() { + let repo = init_git_repo("install-merges-claude-settings"); + let claude_dir = default_paths::InstallTargetPaths::new(&repo).claude_target_dir(); + + fs::create_dir_all(&claude_dir).expect("create claude dir"); + fs::write( + claude_dir.join("settings.json"), + serde_json::to_string_pretty(&json!({ + "permissions": {"allow": ["Bash(git *)"]}, + "env": {"FOO": "bar"}, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [{"type": "command", "command": "echo user-hook"}] + } + ] + } + })) + .expect("serialize seeded settings"), + ) + .expect("seed existing settings.json"); + + let selection: Vec = every_optional_workflow() + .into_iter() + .map(str::to_string) + .collect(); + + install_embedded_setup_assets(&repo, SetupTarget::Claude, &selection) + .expect("first install should succeed"); + + let after_first = + fs::read_to_string(claude_dir.join("settings.json")).expect("read merged settings"); + let merged: serde_json::Value = + serde_json::from_str(&after_first).expect("merged settings should be valid JSON"); + + assert_eq!(merged["permissions"]["allow"][0], "Bash(git *)"); + assert_eq!(merged["env"]["FOO"], "bar"); + let pre_tool_use = merged["hooks"]["PreToolUse"] + .as_array() + .expect("PreToolUse should be an array"); + assert!(pre_tool_use + .iter() + .any(|entry| entry["hooks"][0]["command"] == "echo user-hook")); + assert!(pre_tool_use + .iter() + .any(|entry| entry["hooks"] + .as_array() + .unwrap() + .iter() + .any(|hook| hook["command"] + .as_str() + .unwrap() + .contains("run-sce-or-show-install-guidance.sh")))); + + install_embedded_setup_assets(&repo, SetupTarget::Claude, &selection) + .expect("second install should succeed"); + + let after_second = + fs::read_to_string(claude_dir.join("settings.json")).expect("read re-merged settings"); + assert_eq!( + after_first, after_second, + "two consecutive installs should merge to byte-identical output" + ); + + let _ = fs::remove_dir_all(&repo); +} + +#[test] +fn install_merges_into_existing_opencode_config_json_and_stays_idempotent() { + let repo = init_git_repo("install-merges-opencode-config"); + let opencode_dir = default_paths::InstallTargetPaths::new(&repo).opencode_target_dir(); + + fs::create_dir_all(&opencode_dir).expect("create opencode dir"); + fs::write( + opencode_dir.join("opencode.json"), + serde_json::to_string_pretty(&json!({ + "model": "anthropic/claude", + "mcp": {"my-server": {"command": "my-server"}}, + "plugin": ["./plugins/my-plugin.ts", "./plugins/sce-old-feature.ts"] + })) + .expect("serialize seeded opencode config"), + ) + .expect("seed existing opencode.json"); + + let selection: Vec = every_optional_workflow() + .into_iter() + .map(str::to_string) + .collect(); + + install_embedded_setup_assets(&repo, SetupTarget::OpenCode, &selection) + .expect("first install should succeed"); + + let after_first = fs::read_to_string(opencode_dir.join("opencode.json")) + .expect("read merged opencode config"); + let merged: serde_json::Value = + serde_json::from_str(&after_first).expect("merged opencode config should be valid JSON"); + + assert_eq!(merged["model"], "anthropic/claude"); + assert_eq!(merged["mcp"]["my-server"]["command"], "my-server"); + + let plugin = merged["plugin"] + .as_array() + .expect("plugin should be an array"); + assert!(plugin.contains(&json!("./plugins/my-plugin.ts"))); + assert!(plugin.contains(&json!("./plugins/sce-bash-policy.ts"))); + assert!(plugin.contains(&json!("./plugins/sce-agent-trace.ts"))); + assert!(!plugin.contains(&json!("./plugins/sce-old-feature.ts"))); + assert_eq!( + plugin.last().and_then(serde_json::Value::as_str), + Some("./plugins/sce-mutation-scope.ts"), + "the mutation-scope plugin must be installed as the final plugin" + ); + + install_embedded_setup_assets(&repo, SetupTarget::OpenCode, &selection) + .expect("second install should succeed"); + + let after_second = fs::read_to_string(opencode_dir.join("opencode.json")) + .expect("read re-merged opencode config"); + assert_eq!( + after_first, after_second, + "two consecutive installs should merge to byte-identical output" + ); + + let _ = fs::remove_dir_all(&repo); +} + +#[test] +fn reinstall_with_empty_selection_prunes_deselected_workflow_without_touching_sibling_skill() { + let repo = init_git_repo("install-prunes-deselected-workflow"); + let claude_dir = default_paths::InstallTargetPaths::new(&repo).claude_target_dir(); + + let brownfield_selection = vec!["brownfield".to_string()]; + install_embedded_setup_assets(&repo, SetupTarget::Claude, &brownfield_selection) + .expect("initial install with brownfield selected should succeed"); + + let brownfield_command = claude_dir.join("commands/brownfield.md"); + let brownfield_skill_dir = claude_dir.join("skills/sce-brownfield"); + assert!( + brownfield_command.is_file(), + "brownfield command should be installed" + ); + assert!( + brownfield_skill_dir.is_dir(), + "brownfield skill dir should be installed" + ); + + fs::create_dir_all(claude_dir.join("skills/my-skill")).expect("create user skill dir"); + fs::write( + claude_dir.join("skills/my-skill/SKILL.md"), + "sibling user skill\n", + ) + .expect("seed sibling user skill file"); + + install_embedded_setup_assets(&repo, SetupTarget::Claude, &[]) + .expect("reinstall with empty selection should succeed"); + + assert!( + !brownfield_command.exists(), + "deselected workflow command should be pruned" + ); + assert!( + !brownfield_skill_dir.exists(), + "deselected workflow skill dir should be pruned entirely once empty" + ); + assert_eq!( + fs::read_to_string(claude_dir.join("skills/my-skill/SKILL.md")) + .expect("read sibling user skill file"), + "sibling user skill\n" + ); + + let _ = fs::remove_dir_all(&repo); +} + +#[test] +fn reinstall_with_empty_selection_keeps_pruned_skill_dir_holding_a_user_file() { + let repo = init_git_repo("install-prunes-but-keeps-user-file"); + let claude_dir = default_paths::InstallTargetPaths::new(&repo).claude_target_dir(); + + let brownfield_selection = vec!["brownfield".to_string()]; + install_embedded_setup_assets(&repo, SetupTarget::Claude, &brownfield_selection) + .expect("initial install with brownfield selected should succeed"); + + let brownfield_skill_dir = claude_dir.join("skills/sce-brownfield"); + fs::write( + brownfield_skill_dir.join("MY_OVERRIDE.md"), + "user file inside sce skill dir\n", + ) + .expect("seed user file inside sce-owned skill dir"); + + install_embedded_setup_assets(&repo, SetupTarget::Claude, &[]) + .expect("reinstall with empty selection should succeed"); + + assert!( + !brownfield_skill_dir.join("SKILL.md").exists(), + "deselected workflow skill file should be pruned" + ); + assert!( + brownfield_skill_dir.is_dir(), + "sce-owned skill dir should survive because it still holds a user file" + ); + assert_eq!( + fs::read_to_string(brownfield_skill_dir.join("MY_OVERRIDE.md")) + .expect("read user file inside pruned skill dir"), + "user file inside sce skill dir\n" + ); + + let _ = fs::remove_dir_all(&repo); +} + +#[test] +fn install_cleans_up_staging_and_reports_asset_path_on_rename_failure() { + let repo = init_git_repo("install-rename-failure"); + let selection: Vec = every_optional_workflow() + .into_iter() + .map(str::to_string) + .collect(); + + let claude_dir = default_paths::InstallTargetPaths::new(&repo).claude_target_dir(); + let failing_destination = claude_dir.join("commands/next-task.md"); + + fs::create_dir_all(claude_dir.join("commands")).expect("create commands dir"); + let prior_content = b"prior next-task content\n"; + fs::write(&failing_destination, prior_content).expect("seed prior next-task content"); + + let result = install::install_embedded_setup_assets_with_rename( + &repo, + SetupTarget::Claude, + &selection, + |from, to| { + if to == failing_destination { + Err(std::io::Error::other("simulated rename failure")) + } else { + fs::rename(from, to) + } + }, + ); + + let error = result.expect_err("rename failure should surface as an error"); + let message = format!("{error:#}"); + assert!( + message.contains(&failing_destination.display().to_string()), + "error should name the failing asset path: {message}" + ); + assert!( + message.contains("does not create backups"), + "error should include recovery guidance: {message}" + ); + + assert_eq!( + fs::read(&failing_destination).expect("read failing destination after rename failure"), + prior_content, + "prior content at the failing destination should survive a rename failure" + ); + + let commands_staging_dir = claude_dir.join("commands"); + if commands_staging_dir.exists() { + let leftover_staging_files = fs::read_dir(&commands_staging_dir) + .expect("read commands staging dir") + .filter_map(Result::ok) + .any(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with(".sce-setup-staging-") + }); + assert!( + !leftover_staging_files, + "staging artifact for the failed asset should be cleaned up" + ); + } + + let _ = fs::remove_dir_all(&repo); +} + +#[test] +fn hook_install_leaves_prior_hook_intact_on_rename_failure() { + let repo = init_git_repo("hook-install-rename-failure"); + + let initial_outcome = + install::install_required_git_hooks(&repo).expect("initial hook install should succeed"); + let pre_commit_result = initial_outcome + .hook_results + .iter() + .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) + .expect("pre-commit hook should be installed"); + let pre_commit_path = pre_commit_result.hook_path.clone(); + + let prior_hook_bytes = b"#!/bin/sh\necho prior pre-commit\n".to_vec(); + fs::write(&pre_commit_path, &prior_hook_bytes).expect("seed prior pre-commit hook"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&pre_commit_path, fs::Permissions::from_mode(0o755)) + .expect("mark prior pre-commit hook executable"); + } + let prior_mode = fs::metadata(&pre_commit_path) + .expect("stat prior pre-commit hook") + .permissions(); + + let result = install::install_required_git_hooks_with_rename(&repo, |from, to| { + if to == pre_commit_path { + Err(std::io::Error::other("simulated rename failure")) + } else { + fs::rename(from, to) + } + }); + + let error = result.expect_err("rename failure should surface as an error"); + let message = format!("{error:#}"); + assert!( + message.contains(&pre_commit_path.display().to_string()), + "error should name the failing hook path: {message}" + ); + + assert_eq!( + fs::read(&pre_commit_path).expect("read pre-commit hook after rename failure"), + prior_hook_bytes, + "prior hook content should survive a rename failure" + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode_after = fs::metadata(&pre_commit_path) + .expect("stat pre-commit hook after rename failure") + .permissions(); + assert_eq!( + mode_after.mode() & 0o777, + prior_mode.mode() & 0o777, + "prior hook executable mode should survive a rename failure" + ); + } + + let hooks_staging_dir = pre_commit_path + .parent() + .expect("pre-commit hook should have a parent directory"); + let leftover_staging_files = fs::read_dir(hooks_staging_dir) + .expect("read hooks staging dir") + .filter_map(Result::ok) + .any(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with(".sce-hook-staging-") + }); + assert!( + !leftover_staging_files, + "staging artifact for the failed hook should be cleaned up" + ); + + let _ = fs::remove_dir_all(&repo); +} + +#[test] +fn foreign_pre_commit_hook_keeps_its_content_and_gains_the_sce_block() { + let repo = init_git_repo("hook-install-foreign-append"); + + let initial_outcome = + install::install_required_git_hooks(&repo).expect("initial hook install should succeed"); + let pre_commit_path = initial_outcome + .hook_results + .iter() + .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) + .expect("pre-commit hook should be installed") + .hook_path + .clone(); + + let foreign_bytes = b"#!/bin/sh\necho husky-style-guard\n".to_vec(); + fs::write(&pre_commit_path, &foreign_bytes).expect("seed foreign pre-commit hook"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&pre_commit_path, fs::Permissions::from_mode(0o755)) + .expect("mark foreign pre-commit hook executable"); + } + + let outcome = install::install_required_git_hooks(&repo) + .expect("hook install over a foreign hook should succeed"); + let result = outcome + .hook_results + .iter() + .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) + .expect("pre-commit hook result should be present"); + + assert_eq!(result.status, RequiredHookInstallStatus::Updated); + assert!(!result.unreachable_block_advisory); + + let installed_bytes = fs::read(&pre_commit_path).expect("read installed pre-commit hook"); + assert!( + installed_bytes.starts_with(&foreign_bytes), + "foreign hook content should survive as an exact prefix" + ); + let installed_text = String::from_utf8(installed_bytes).expect("hook should be utf8"); + assert!(installed_text.contains(hook_merge::MANAGED_BLOCK_START)); + assert!(installed_text.contains(hook_merge::MANAGED_BLOCK_END)); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(&pre_commit_path) + .expect("stat installed pre-commit hook") + .permissions() + .mode(); + assert_ne!(mode & 0o111, 0, "installed hook should remain executable"); + } + + let _ = fs::remove_dir_all(&repo); +} + +#[test] +fn rerunning_hook_install_is_idempotent_for_block_only_and_foreign_plus_block_shapes() { + let repo = init_git_repo("hook-install-idempotent"); + + let first_outcome = + install::install_required_git_hooks(&repo).expect("first hook install should succeed"); + let pre_commit_result = first_outcome + .hook_results + .iter() + .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) + .expect("pre-commit hook should be installed"); + assert_eq!( + pre_commit_result.status, + RequiredHookInstallStatus::Installed + ); + + let second_outcome = + install::install_required_git_hooks(&repo).expect("second hook install should succeed"); + let second_pre_commit = second_outcome + .hook_results + .iter() + .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) + .expect("pre-commit hook result should be present"); + assert_eq!(second_pre_commit.status, RequiredHookInstallStatus::Skipped); + assert_eq!( + fs::read(&second_pre_commit.hook_path).expect("read block-only pre-commit hook"), + fs::read(&pre_commit_result.hook_path).expect("read initial pre-commit hook"), + "block-only hook bytes should be unchanged across reruns" + ); + + let commit_msg_result = first_outcome + .hook_results + .iter() + .find(|result| result.hook_name == default_paths::hook_dir::COMMIT_MSG) + .expect("commit-msg hook should be installed"); + let commit_msg_path = commit_msg_result.hook_path.clone(); + let foreign_prefix = b"#!/bin/sh\necho foreign-commit-msg-guard\n".to_vec(); + fs::write(&commit_msg_path, &foreign_prefix).expect("seed foreign commit-msg hook"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&commit_msg_path, fs::Permissions::from_mode(0o755)) + .expect("mark foreign commit-msg hook executable"); + } + + let appended_outcome = install::install_required_git_hooks(&repo) + .expect("hook install appending to foreign commit-msg hook should succeed"); + let appended_result = appended_outcome + .hook_results + .iter() + .find(|result| result.hook_name == default_paths::hook_dir::COMMIT_MSG) + .expect("commit-msg hook result should be present"); + assert_eq!(appended_result.status, RequiredHookInstallStatus::Updated); + let appended_bytes = fs::read(&commit_msg_path).expect("read appended commit-msg hook"); + + let rerun_outcome = install::install_required_git_hooks(&repo) + .expect("rerunning hook install over foreign-plus-block hook should succeed"); + let rerun_result = rerun_outcome + .hook_results + .iter() + .find(|result| result.hook_name == default_paths::hook_dir::COMMIT_MSG) + .expect("commit-msg hook result should be present"); + assert_eq!(rerun_result.status, RequiredHookInstallStatus::Skipped); + assert_eq!( + fs::read(&commit_msg_path).expect("read commit-msg hook after rerun"), + appended_bytes, + "foreign-plus-block hook bytes should be unchanged across reruns" + ); + + let _ = fs::remove_dir_all(&repo); +} + +#[test] +fn legacy_pre_marker_hook_upgrades_to_the_managed_block_form() { + let repo = init_git_repo("hook-install-legacy-upgrade"); + + let initial_outcome = + install::install_required_git_hooks(&repo).expect("initial hook install should succeed"); + let pre_commit_path = initial_outcome + .hook_results + .iter() + .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) + .expect("pre-commit hook should be installed") + .hook_path + .clone(); + let canonical_bytes = fs::read(&pre_commit_path).expect("read canonical pre-commit hook"); + + let legacy_bytes = b"#!/bin/sh\nset -eu\nif ! command -v sce >/dev/null 2>&1; then\n echo 'Install: https://sce.crocoder.dev/docs/getting-started#install-cli'\n exit 0\nfi\nexec sce hooks pre-commit \"$@\"\n".to_vec(); + fs::write(&pre_commit_path, &legacy_bytes).expect("seed legacy pre-commit hook"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&pre_commit_path, fs::Permissions::from_mode(0o755)) + .expect("mark legacy pre-commit hook executable"); + } + + let outcome = install::install_required_git_hooks(&repo) + .expect("hook install upgrading a legacy hook should succeed"); + let result = outcome + .hook_results + .iter() + .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) + .expect("pre-commit hook result should be present"); + + assert_eq!(result.status, RequiredHookInstallStatus::Updated); + assert_eq!( + fs::read(&pre_commit_path).expect("read upgraded pre-commit hook"), + canonical_bytes, + "a legacy pre-marker hook should upgrade to the canonical marker form" + ); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(&pre_commit_path) + .expect("stat upgraded pre-commit hook") + .permissions() + .mode(); + assert_ne!(mode & 0o111, 0, "upgraded hook should remain executable"); + } + + let _ = fs::remove_dir_all(&repo); +} + +#[test] +fn foreign_hook_ending_in_exec_installs_the_block_and_reports_the_advisory() { + let repo = init_git_repo("hook-install-unreachable-advisory"); + + let initial_outcome = + install::install_required_git_hooks(&repo).expect("initial hook install should succeed"); + let pre_commit_path = initial_outcome + .hook_results + .iter() + .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) + .expect("pre-commit hook should be installed") + .hook_path + .clone(); + let commit_msg_path = initial_outcome + .hook_results + .iter() + .find(|result| result.hook_name == default_paths::hook_dir::COMMIT_MSG) + .expect("commit-msg hook should be installed") + .hook_path + .clone(); + + let unreachable_foreign = b"#!/bin/sh\nexec some-other-tool \"$@\"\n".to_vec(); + fs::write(&pre_commit_path, &unreachable_foreign).expect("seed unreachable foreign hook"); + let ordinary_foreign = b"#!/bin/sh\necho foreign-commit-msg-guard\n".to_vec(); + fs::write(&commit_msg_path, &ordinary_foreign).expect("seed ordinary foreign hook"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&pre_commit_path, fs::Permissions::from_mode(0o755)) + .expect("mark unreachable foreign hook executable"); + fs::set_permissions(&commit_msg_path, fs::Permissions::from_mode(0o755)) + .expect("mark ordinary foreign hook executable"); + } + + let outcome = install::install_required_git_hooks(&repo) + .expect("hook install over foreign hooks should succeed"); + + let pre_commit_result = outcome + .hook_results + .iter() + .find(|result| result.hook_name == default_paths::hook_dir::PRE_COMMIT) + .expect("pre-commit hook result should be present"); + assert_eq!(pre_commit_result.status, RequiredHookInstallStatus::Updated); + assert!( + pre_commit_result.unreachable_block_advisory, + "a hook ending in a zero-indent exec should report the advisory" + ); + assert!( + fs::read(&pre_commit_path) + .expect("read pre-commit hook") + .starts_with(&unreachable_foreign), + "the block should still be installed even though it is unreachable" + ); + + let commit_msg_result = outcome + .hook_results + .iter() + .find(|result| result.hook_name == default_paths::hook_dir::COMMIT_MSG) + .expect("commit-msg hook result should be present"); + assert!( + !commit_msg_result.unreachable_block_advisory, + "a hook ending in an ordinary command should not report the advisory" + ); + + let _ = fs::remove_dir_all(&repo); +} diff --git a/context/cli/claude-mutation-scope-background-execution.md b/context/cli/claude-mutation-scope-background-execution.md new file mode 100644 index 000000000..c936a5991 --- /dev/null +++ b/context/cli/claude-mutation-scope-background-execution.md @@ -0,0 +1,37 @@ +# Claude mutation-scope background and detached execution boundaries + +Detail split out of +[claude-mutation-scope-integration.md](claude-mutation-scope-integration.md) +for the repository's per-file line budget. + +## Background shell is unsupported + +An explicit `Bash.run_in_background = true` / `PowerShell.run_in_background = +true` is denied in `PreToolUse` (fail-closed shape) with: + +```text +SCE mutation attribution does not yet support detached background shell execution. Run this command in the foreground. +``` + +A detached shell can keep mutating the repository after `PostToolUse` returns and +can outlive a session; the generic contract has no process supervisor or stable +background-execution terminal signal. This is a deliberate correctness boundary, +not a Bash security policy. Background **subagents** are not excluded — their +internal mutation-capable tool calls still establish their own scopes. + +**Self-detaching descendants are a separate, explicit unsupported boundary +(D20).** A `run_in_background = false` call can still leave a repository-mutating +descendant running after `PostToolUse` returns when the invoked command detaches +a child (`command &`, `nohup`, `setsid`, double-fork, `start_new_session=True`). +T04 proved this live against Claude Code `2.1.258`: a foreground `setsid` +command returned `PostToolUse` in `duration_ms: 13` and its descendant's write +landed ~3s later, changing the Git tree an SCE snapshot would capture — outside +the tool's closed scope. This is not solvable by inspecting the command string; +the integration adds no detection, supervision, or static scan, and simply does +not treat `PostToolUse` as proof that every descendant has stopped mutating. See +the T04 addendum and `probe17-*` fixtures under +`cli/src/services/hooks/claude_mutation_scope/fixtures/`. + +## Related context + +- [Claude mutation-scope integration](claude-mutation-scope-integration.md) diff --git a/context/cli/claude-mutation-scope-health.md b/context/cli/claude-mutation-scope-health.md new file mode 100644 index 000000000..2dcf0a350 --- /dev/null +++ b/context/cli/claude-mutation-scope-health.md @@ -0,0 +1,78 @@ +# Claude mutation-scope health classification + +`claude_mutation_scope::health::classify_health` is a read-only diagnostic +classifier over the checkout-local adapter state described in +[Checkout-local adapter state](claude-mutation-scope-integration.md#checkout-local-adapter-state). +It returns the shared `healthy | recovering | blocked | invalid` health +vocabulary that `sce doctor` already consumes (see +[`doctor-human-text-contract.md`](../sce/doctor-human-text-contract.md)). It +reads the same `state::read_state` result the adapter itself uses and maps it +as: + +| Persisted state | Status | Reason | +| --- | --- | --- | +| `recovery_pending == false` | `Healthy` | no persisted recovery barrier is armed | +| `recovery_pending == true && attempts.is_empty()` | `Recovering` | the recovery barrier's own flush path can clear this automatically | +| `recovery_pending == true && attempts` non-empty | `Blocked` | the recovery barrier's flush path never runs from this shape, and nothing else advances it | +| `read_state` fails (malformed JSON, unsupported version, read error) | `Invalid` | the state cannot be safely interpreted | + +**Healthy** covers the absence of a state file (`read_state`'s default) as +well as an explicit `recovery_pending == false`. This is recovery health +specifically, not "no active tool calls" — the adapter may still have live +`attempts` in `pending_start`/`active`/`pending_abandon` phase; live attempts +alone, with `recovery_pending == false`, are still `Healthy`. + +**Recovering** (`recovery_pending == true && attempts.is_empty()`) reflects +actual adapter behavior, not the `recovery_pending` name alone: the next +mutation-capable `PreToolUse` reaches +[the recovery barrier](claude-mutation-scope-integration.md#abandonment-cleanup-signals), +finds `attempts` empty, runs `{"operation":"flush"}` through the seam, and — +on success — calls `clear_recovery_pending` before proceeding. This is a +normal, self-healing admission path with no manual intervention. + +**Blocked** (`recovery_pending == true && attempts` non-empty) is the exact +shape of the incident that motivated this classifier and the wider +`doctor-mutation-scope-health` plan. `apply_recovery_barrier()` sees +`recovery_pending == true` with non-empty `attempts` and returns `Deny`. From +the ordinary hook lifecycle alone, nothing retries the abandon that left +those attempts stale, removes them, flushes, or clears `recovery_pending` — +the barrier's only self-healing transition (flush) is gated on +`attempts.is_empty()`, which this shape never satisfies. So once the hook +invocation that produced this persisted state has returned, every subsequent +ordinary mutation-capable `PreToolUse` continues to deny without advancing +recovery through that path alone — a durable repository-wide lockout, not +merely "currently denied." The `doctor-mutation-scope-fix` plan adds a +separate, `doctor`-invoked repair path (`assess_repairability`/ +`repair_blocked`) for exactly this shape once every outstanding attempt +already carries durable `pending_abandon` evidence; that repair path is not +part of the ordinary hook lifecycle this classifier observes, and is not yet +wired into `sce doctor --fix` as of this classifier's own read-only +behavior. + +**Invalid** applies when `state::read_state` cannot safely read or parse the +state file — malformed JSON or an unsupported/invalid persisted version, per +the existing state reader. A fail-closed but structurally valid state (i.e. +`Blocked`) is never reported as `Invalid`. + +**Read-only boundary.** This classifier is diagnostic only: it reads the +existing checkout-local adapter state and nothing else. It never modifies +`attempts`, never clears `recovery_pending`, never calls `flush` or +`abandon`, and never alters mutation attribution. Recovery/repair is a +separate concern this classifier does not perform. + +**Observation semantics.** Classification is a snapshot of persisted state, +read the same way `state::read_state` reads it for the adapter's own use. +There is a narrow window in which a currently executing hook has already +persisted `recovery_pending = true` with non-empty `attempts` but is still +about to complete its abandon/remove sequence — the classifier does not +attempt to prove global process liveness across that window. `Blocked` means +that, if the operation that produced the observed durable state has stopped +progressing, future ordinary adapter lifecycle events have no self-healing +path from that state — not a claim that no process anywhere could possibly +still be mid-write. + +## Related context + +- [Claude mutation-scope integration](claude-mutation-scope-integration.md) +- [OpenCode mutation-scope health classification](opencode-mutation-scope-health.md) +- [Pi mutation-scope health classification](pi-mutation-scope-health.md) diff --git a/context/cli/claude-mutation-scope-integration.md b/context/cli/claude-mutation-scope-integration.md index 995c2f502..1c42f1258 100644 --- a/context/cli/claude-mutation-scope-integration.md +++ b/context/cli/claude-mutation-scope-integration.md @@ -90,7 +90,9 @@ new `ScopeId`; otherwise-identical tool IDs under main / `agent_id=A` / `checkout::resolve_git_dir(cwd)` — worktree-specific for linked worktrees): a versioned `{version, next_attempt_seq, recovery_pending, attempts[]}`, each attempt carrying `attempt_seq`, `scope_id`, the identity fields, `tool_name`, and -`phase` (`pending_start | active`). This is **adapter bookkeeping, never +`phase` (`pending_start | active | pending_abandon`, the last set durably before +any abandon seam call — see [Abandonment cleanup signals](#abandonment-cleanup-signals) +below). This is **adapter bookkeeping, never attribution evidence** — not exported, synced, or authoritative; its only job is knowing which Claude-created scopes may still need a terminal action. A malformed or wrong-version file is rejected, never fabricated. @@ -155,10 +157,17 @@ rules: ## Abandonment cleanup signals -Every abandonment shares one helper: arm `recovery_pending`, call the seam -`abandon` operation, then remove the attempt on success. A failed abandon leaves -`recovery_pending = true` and the attempt tracked, so the next mutation-capable -`PreToolUse` is denied by the barrier. +Every abandonment shares one helper: arm `recovery_pending`, durably persist the +attempt's phase as `pending_abandon` (proof that abandonment has already been +decided, distinct from "may still be running"), then call the seam `abandon` +operation and remove the attempt only on success. A failed abandon leaves +`recovery_pending = true` and the attempt tracked in `pending_abandon`, so the +next mutation-capable `PreToolUse` is denied by the barrier. When a broad +cleanup signal (`Stop`/`UserPromptSubmit`/`SubagentStop`/`SessionEnd`/ +`WorktreeRemove`) matches more than one outstanding attempt, every matched +attempt is durably marked `pending_abandon` in one write before any of their +seam `abandon` calls run, so one attempt's seam failure can never leave a +sibling attempt in the same batch without its own retryable evidence. | Event | Retires | | --- | --- | @@ -183,64 +192,12 @@ recovery/rebaseline boundary. Only a successful `flush` clears ## Mutation-scope health `claude_mutation_scope::health::classify_health` is a read-only diagnostic -classifier over the checkout-local adapter state described above. It returns -the shared `healthy | recovering | blocked | invalid` health vocabulary that -the planned doctor integration will consume. It reads the same -`state::read_state` result the adapter itself uses and maps it as: - -| Persisted state | Status | Reason | -| --- | --- | --- | -| `recovery_pending == false` | `Healthy` | no persisted recovery barrier is armed | -| `recovery_pending == true && attempts.is_empty()` | `Recovering` | the recovery barrier's own flush path can clear this automatically | -| `recovery_pending == true && attempts` non-empty | `Blocked` | the recovery barrier's flush path never runs from this shape, and nothing else advances it | -| `read_state` fails (malformed JSON, unsupported version, read error) | `Invalid` | the state cannot be safely interpreted | - -**Healthy** covers the absence of a state file (`read_state`'s default) as -well as an explicit `recovery_pending == false`. This is recovery health -specifically, not "no active tool calls" — the adapter may still have live -`attempts` in `pending_start`/`active` phase; live attempts alone, with -`recovery_pending == false`, are still `Healthy`. - -**Recovering** (`recovery_pending == true && attempts.is_empty()`) reflects -actual adapter behavior, not the `recovery_pending` name alone: the next -mutation-capable `PreToolUse` reaches [the recovery barrier](#the-recovery-barrier) -above, finds `attempts` empty, runs `{"operation":"flush"}` through the seam, -and — on success — calls `clear_recovery_pending` before proceeding. This is -a normal, self-healing admission path with no manual intervention. - -**Blocked** (`recovery_pending == true && attempts` non-empty) is the exact -shape of the incident that motivated this classifier and the wider -`doctor-mutation-scope-health` plan. `apply_recovery_barrier()` sees -`recovery_pending == true` with non-empty `attempts` and returns `Deny`. It -does not retry the abandon that left those attempts stale, does not remove -them, does not flush, and does not clear `recovery_pending` — the barrier's -only self-healing transition (flush) is gated on `attempts.is_empty()`, which -this shape never satisfies. So once the hook invocation that produced this -persisted state has returned, every subsequent ordinary mutation-capable -`PreToolUse` continues to deny without advancing recovery — a durable -repository-wide lockout, not merely "currently denied." - -**Invalid** applies when `state::read_state` cannot safely read or parse the -state file — malformed JSON or an unsupported/invalid persisted version, per -the existing state reader. A fail-closed but structurally valid state (i.e. -`Blocked`) is never reported as `Invalid`. - -**Read-only boundary.** This classifier is diagnostic only: it reads the -existing checkout-local adapter state and nothing else. It never modifies -`attempts`, never clears `recovery_pending`, never calls `flush` or -`abandon`, and never alters mutation attribution. Recovery/repair is a -separate concern this classifier does not perform. - -**Observation semantics.** Classification is a snapshot of persisted state, -read the same way `state::read_state` reads it for the adapter's own use. -There is a narrow window in which a currently executing hook has already -persisted `recovery_pending = true` with non-empty `attempts` but is still -about to complete its abandon/remove sequence — the classifier does not -attempt to prove global process liveness across that window. `Blocked` means -that, if the operation that produced the observed durable state has stopped -progressing, future ordinary adapter lifecycle events have no self-healing -path from that state — not a claim that no process anywhere could possibly -still be mid-write. +classifier over the checkout-local adapter state described above, mapping it +onto the shared `healthy | recovering | blocked | invalid` vocabulary that +`sce doctor` consumes. See +[Claude mutation-scope health classification](claude-mutation-scope-health.md) +for the full mapping, the `Blocked` incident this classifier exists to +surface, and its read-only/observation-semantics boundaries. ## Raw cwd is authoritative @@ -255,31 +212,7 @@ to the seam. ## Background shell is unsupported -An explicit `Bash.run_in_background = true` / `PowerShell.run_in_background = -true` is denied in `PreToolUse` (fail-closed shape) with: - -```text -SCE mutation attribution does not yet support detached background shell execution. Run this command in the foreground. -``` - -A detached shell can keep mutating the repository after `PostToolUse` returns and -can outlive a session; the generic contract has no process supervisor or stable -background-execution terminal signal. This is a deliberate correctness boundary, -not a Bash security policy. Background **subagents** are not excluded — their -internal mutation-capable tool calls still establish their own scopes. - -**Self-detaching descendants are a separate, explicit unsupported boundary -(D20).** A `run_in_background = false` call can still leave a repository-mutating -descendant running after `PostToolUse` returns when the invoked command detaches -a child (`command &`, `nohup`, `setsid`, double-fork, `start_new_session=True`). -T04 proved this live against Claude Code `2.1.258`: a foreground `setsid` -command returned `PostToolUse` in `duration_ms: 13` and its descendant's write -landed ~3s later, changing the Git tree an SCE snapshot would capture — outside -the tool's closed scope. This is not solvable by inspecting the command string; -the integration adds no detection, supervision, or static scan, and simply does -not treat `PostToolUse` as proof that every descendant has stopped mutating. See -the T04 addendum and `probe17-*` fixtures under -`cli/src/services/hooks/claude_mutation_scope/fixtures/`. +An explicit `Bash.run_in_background = true` / `PowerShell.run_in_background = true` is denied in `PreToolUse` (fail-closed shape); self-detaching descendants are a separate, explicit unsupported boundary (D20). See [Claude mutation-scope background and detached execution boundaries](claude-mutation-scope-background-execution.md) for the full denial text and both boundaries. ## Generated settings @@ -307,6 +240,8 @@ durable-completion classification, and empty-stdout semantics. ## Related context +- [Claude mutation-scope health classification](claude-mutation-scope-health.md) +- [Claude mutation-scope background and detached execution boundaries](claude-mutation-scope-background-execution.md) - [Mutation-scope hook ingress: the harness-neutral transport seam](mutation-scope-hook-ingress.md) - [Mutation-scope runtime: the harness-adapter contract](mutation-scope-runtime.md) - [Agent Trace hooks command routing](../sce/agent-trace-hooks-command-routing.md) diff --git a/context/cli/doctor-recovery-safety-model.md b/context/cli/doctor-recovery-safety-model.md new file mode 100644 index 000000000..f68551503 --- /dev/null +++ b/context/cli/doctor-recovery-safety-model.md @@ -0,0 +1,110 @@ +# Doctor-repair safety model (`spec/doctor_recovery.qnt`) + +Standalone, verified Quint model of the safety pattern behind `sce doctor +--fix`'s repair of a `Blocked` Agent-tracing state (introduced by the +`doctor-mutation-scope-fix` plan's T01; consumed by T03's OpenCode repair, +T04's Claude repair, and T05's doctor orchestration). It does not extend +`spec/mutation_cursor.qnt` — doctor-repair bookkeeping (attempt phases, +adapter-wide recovery/fix state, an abstract owner-liveness oracle, and two +racing actors) is a separate, focused concern from the verified core +mutation-cursor protocol that file owns. + +## What it models + +Two actors race over one adapter's durable state: the adapter's own hook +process (which allocates, advances, and can itself decide to abandon an +attempt) and `sce doctor --fix` (which may additionally attempt a repair, but +only under positive owner-liveness evidence). A `StateTxnHolder` +(`StateTxnFree`/`DoctorStateTxn`) models the short durable-transaction lock +each real adapter releases before every mutation-scope seam call. + +| Abstract concept | Real Claude/OpenCode mapping | +| --- | --- | +| `AttemptPhase = NotAllocated \| PendingStart \| Active \| PendingAbandon \| Removed` | OpenCode's `AdapterAttempt` phase field; Claude's attempt phase, including the `PendingAbandon` phase this plan's T04 added | +| `RecoveryState = Clear \| Pending \| Flushing` (one adapter-wide value, not per-attempt) | OpenCode's `AdapterState.recovery`; Claude's whole-file `recovery_pending` flag/barrier | +| `Health = Healthy \| Recovering \| Blocked \| Invalid` via `adapterHealth(phase, recovery)` | each adapter's own `classify_health`, computed from every attempt's phase plus the one shared recovery fact — never from a single attempt in isolation | +| `FixState = NotAttempted \| RepairAttempted \| RepairCompleted \| ReportedFixed \| ReportedManual` (adapter-wide) | doctor's one fix-result lifecycle per adapter target (`assess_repairability` -> `repair_blocked` -> re-diagnose -> one `DoctorFixResultRecord`) | +| `OwnerReading = SeenAlive \| SeenDead \| SeenUnknown` | `mutation_scope_owner::is_definitely_dead`'s `Alive`/`Dead`/`Unknown` result (PID + `/proc` start-time liveness, never time-based) | +| `stateTxn: StateTxnHolder` | OpenCode's `AdapterStateLock` transaction (and Claude's equivalent short state-file lock) around one re-read/re-prove/durable-transition step — **not** OpenCode's coarser `AdapterBoundaryLock`, which this model has no dedicated variable for (see below) | + +`RecoveryState`/`FixState` are single adapter-wide variables, not +per-attempt: both real adapters report one health value and one fix result +per target, and `begin_terminal_cleanup`/`resolve_recovery`-shaped code +processes every `PendingAbandon` attempt in one shared pipeline, not one at a +time. `completeAbandon` only clears `recovery` to `Clear` once no attempt +remains `PendingAbandon`, so a second obligation can join an in-flight +recovery pipeline without disturbing it. + +**Scope boundary:** `stateTxn` verifies only the short per-transaction lock. +OpenCode's `AdapterBoundaryLock` — which serializes a repair's whole +lifecycle, seam calls included — is a separate, coarser concern outside this +model's variables; T03 is responsible for acquiring it around the whole +`repair_blocked` call, wrapping (not replacing) the `stateTxn`-shaped +transactions this model verifies. Claude has no equivalent boundary lock in +this plan: its repair safety rests on the durable `PendingAbandon` evidence +itself plus its own state-lock transactions. + +## The eight `Safety` invariants + +Each is proven, at minimum, by the Rust regression test(s) named — driving +each adapter's real dispatch/recovery functions, not reading enum names. + +| Invariant | Rust regression coverage | +| --- | --- | +| `DoctorNeverAbandonsALiveOwner` — doctor cannot abandon a potentially live attempt | `opencode_mutation_scope::health::tests::repair_blocked_is_a_safe_no_op_when_the_pending_start_owner_is_live`, `opencode_mutation_scope::health::tests::assess_repairability_is_manual_only_when_the_pending_start_owner_is_live` | +| `UnknownOwnerNeverProvesDeath` — an unprovable/unknown owner is never proof of death | `opencode_mutation_scope::health::tests::assess_repairability_is_manual_only_for_a_legacy_pending_start_attempt_with_no_recorded_owner`; `doctor::inspect::tests::full_report_multi_adapter_diagnose_names_doctor_fix_for_one_target_and_the_real_path_for_the_other` (an unowned OpenCode `PendingStart` stays `ManualOnly` in the same report where Claude's proven-dead-equivalent state is `AutoFixable`) | +| `RecoveryNeverClearedWithUnresolvedAbandon` — doctor cannot clear recovery while unresolved lifecycle evidence exists | `claude_mutation_scope::health::tests::repair_blocked_removes_only_successfully_abandoned_attempts_and_keeps_recovery_pending_when_one_fails` | +| `NoOrdinaryTransitionProducesInvalidHealth` — `Invalid` is reserved for structurally impossible states, never an ordinary transition's output | `claude_mutation_scope::health::tests::clear_recovery_with_pending_abandon_is_invalid`, `claude_mutation_scope::health::tests::invalid_takes_priority_over_blocked_in_mixed_impossible_state`, `opencode_mutation_scope::health::tests::clear_recovery_with_a_pending_abandon_attempt_is_a_structurally_impossible_state_classified_invalid` | +| `DoctorRepairProducesOnlyOrdinaryLifecycleShapes` — a doctor repair cannot produce a state outside the adapter's own reachable transition set | `opencode_mutation_scope::health::tests::repair_blocked_clears_a_dead_owner_pending_start_end_to_end`; `claude_mutation_scope::health::tests::repair_blocked_removes_only_successfully_abandoned_attempts_and_keeps_recovery_pending_when_one_fails` | +| `RemovedAttemptsAreNeverResurrected` — a terminal/removed attempt is never resurrected | `claude_mutation_scope::state::tests::removing_an_already_removed_attempt_is_a_safe_no_op`, `opencode_mutation_scope::state::tests::removing_an_already_removed_attempt_is_a_safe_no_op`, `opencode_mutation_scope::tests::regression_f_start_replay_for_a_pending_abandon_identity_never_reactivates` | +| `InterruptedRecoveryStaysInOrdinaryRetryableState` — an interrupted repair remains fail-closed and retryable | `claude_mutation_scope::health::tests::repair_blocked_interrupted_by_a_failing_seam_leaves_state_a_later_repair_completes_without_duplication`, `opencode_mutation_scope::health::tests::repair_blocked_interrupted_before_the_seam_resolves_leaves_state_the_ordinary_recovery_path_completes_without_duplication` | +| `ReportedFixedExcludesBlockedOrInvalid` — a reported `fixed` repair cannot coincide with a final `Blocked`/`Invalid` health | `doctor::inspect::tests::finalize_mutation_scope_repair_results_ignores_an_immediate_post_repair_read_that_the_final_report_contradicts`; `doctor::inspect::tests::full_report_multi_adapter_fix_mode_resolves_one_target_and_leaves_the_other_manual` (Claude's `Fixed` report is derived only from the final, freshly recomputed row, at the same moment the adjacent OpenCode row in that identical final report is still `Blocked`) | + +## Why no Quint-Connect harness + +Unlike [`mutation-trace-quint-connect.md`](mutation-trace-quint-connect.md), +which continuously replays generated Quint traces through a pure Rust +refinement (`mutation_trace::protocol`) of `spec/mutation_cursor.qnt`, no +equivalent trace-replay harness connects `spec/doctor_recovery.qnt` to Rust. +`doctor_recovery.qnt`'s actions (`hookAllocate`, `doctorAttemptRepairWith`, +`recoveryProgress`, `completeAbandon`, ...) correspond to real adapter I/O — +durable state files, OS locks, `/proc` owner liveness, the real +mutation-scope seam — with no equivalent pure core to trace against; +extracting one purely to enable a trace-replay harness would be new +production behavior outside any task's declared scope in this plan. The +table above is the documented-mapping alternative instead, connecting every +`Safety` invariant to at least one real regression test, per the +`doctor-mutation-scope-fix` plan's T07. + +## No comments in the model file itself + +`spec/doctor_recovery.qnt` carries no header comment, per this repository's +standing no-comments-in-code convention. This file's concept-mapping table +and invariant list are the durable substitute for that header, mirroring the +same choice `spec/mutation_cursor.qnt`'s own authors made. + +## Verification + +- `nix run .#quint -- typecheck spec/doctor_recovery.qnt` +- `nix run .#quint -- test spec/doctor_recovery.qnt --match '^test.*'` +- `nix run .#quint -- run spec/doctor_recovery.qnt --invariant=Safety --max-samples=20000 --max-steps=50` + +No dedicated Nix check currently runs `doctor_recovery.qnt` the way +`checks.mutation-trace-quint-connect` runs the mutation-cursor MBT harness — +the commands above are run manually and their results recorded in the +`doctor-mutation-scope-fix` plan's T01/T07 task records. `spec/` is already +included in the Nix build's fileset (see +[mutation-trace-quint-connect.md](mutation-trace-quint-connect.md)'s "CI: two +Nix checks" section), so `nix flake check`'s existing checks build +successfully alongside this file without needing further Nix wiring. + +## Authoritative source + +`spec/doctor_recovery.qnt` remains authoritative for the model itself. See +[mutation-scope-health-status.md](../sce/mutation-scope-health-status.md), +[claude-mutation-scope-health.md](claude-mutation-scope-health.md), and +[opencode-mutation-scope-health.md](opencode-mutation-scope-health.md) for +the concrete `classify_health`/`assess_repairability`/`repair_blocked` +implementations this model verifies the safety pattern of. See +`context/plans/doctor-mutation-scope-fix.md` (T01, T07) for build-out status +and verification-run evidence. diff --git a/context/cli/opencode-mutation-scope-adapter-lifecycle.md b/context/cli/opencode-mutation-scope-adapter-lifecycle.md index 26395a26b..8fd03e27e 100644 --- a/context/cli/opencode-mutation-scope-adapter-lifecycle.md +++ b/context/cli/opencode-mutation-scope-adapter-lifecycle.md @@ -111,9 +111,52 @@ concurrent OpenCode processes. (idempotent), confirmation-required attribution, and `needs_rebaseline`; the existing Quint checks are re-run only as regression verification. +## Owner evidence and the doctor-repair path + +Each `AdapterAttempt` now carries an optional `owner: Option` +(`#[serde(default)]`), stamped with the shared +[`mutation_scope_owner::current_process_owner()`](pi-mutation-scope-integration.md) +when a `PendingStart` attempt is allocated. A state file written before this +field existed deserializes its absence as `owner: None`, never inferred — +`assess_repairability`/`repair_blocked` below treat a missing owner exactly +like a live/unprovable one. + +This evidence exists only to back a `doctor`-invoked repair for the +`blocked` `PendingStart` shape described in +[opencode-mutation-scope-health.md](opencode-mutation-scope-health.md#why-a-stale-pendingstart-is-blocked-not-recovering--even-alongside-a-pendingflushing-recovery-generation); +it changes none of the ordinary hook lifecycle above and none of +`classify_health`'s reachable classifications. + +- `assess_repairability(git_dir)` reports `AutoFixable` only when every + currently `PendingStart` attempt has a recorded owner the shared + `mutation_scope_owner::is_definitely_dead` proves dead; otherwise + `ManualOnly` (no owner recorded, a live owner, or unprovable liveness). +- `repair_blocked(git_dir, repository_root, logger, seam)` acquires the + `AdapterBoundaryLock`, normalizes an orphaned `Flushing`, then — in one + `AdapterStateLock` transaction — re-reads state fresh and re-evaluates + liveness against the *current* state rather than trusting any earlier + read. Only attempts still `PendingStart` with a still-dead owner at that + moment transition to `PendingAbandon`, using the same durable + `begin_terminal_cleanup` shape the ordinary `ToolError` path already uses. + The state lock is released before the unmodified `flush`/`abandon`/`flush` + seam sequence (`resolve_recovery`) runs, so the lock is never held across a + seam call. A losing re-proof (the attempt is no longer `PendingStart`, or + no attempt is currently dead-owned) is a safe no-op: no seam call, no + write. If a seam call fails mid-repair, the resulting `PendingAbandon`/ + `Pending` state is left exactly as an ordinary failed `ToolError` cleanup + would leave it, so the existing self-healing retry (any later tracked + admission) resumes and completes it without duplicating or resurrecting + the attempt. +- Neither function is wired into `doctor` yet — `sce doctor --fix` cannot + reach `repair_blocked` until a later task dispatches to it from + `execute_doctor_with_lifecycle_providers`. + ## Related context - [OpenCode mutation-scope integration](opencode-mutation-scope-integration.md) +- [OpenCode mutation-scope health classification](opencode-mutation-scope-health.md) - [Mutation-scope hook ingress](mutation-scope-hook-ingress.md) - [Mutation-scope runtime: the harness-adapter contract](mutation-scope-runtime.md) - [Mutation-scope provenance](mutation-scope-provenance.md) +- [Pi mutation-scope integration](pi-mutation-scope-integration.md) (the shared + `mutation_scope_owner` liveness primitive this task reuses) diff --git a/context/cli/opencode-mutation-scope-health.md b/context/cli/opencode-mutation-scope-health.md index df91473a5..82174cde0 100644 --- a/context/cli/opencode-mutation-scope-health.md +++ b/context/cli/opencode-mutation-scope-health.md @@ -158,14 +158,21 @@ attempt on behalf of an unrelated call. The only two paths that retire a - that same key's own `ToolExecuteAfter`/`ToolError`, which drives `abandon_and_consume` for that specific attempt. -If the process that owns that call has died before either of those arrives — -there is no equivalent of Pi's `ProcessOwner`/`is_definitely_dead()` liveness -check in OpenCode, and `server_disposed_cannot_sweep_another_processes_attempt` -proves `ServerDisposed` is deliberately inert for this — no future ordinary -lifecycle event from any other call can ever clear it. This is the same +If the process that owns that call has died before either of those arrives, no +future *ordinary* lifecycle event from any other call can ever clear it — +`server_disposed_cannot_sweep_another_processes_attempt` proves `ServerDisposed` +is deliberately inert for this, and (unlike Pi's D10 sweep) nothing in the +`ToolExecuteBefore`/`ToolExecuteAfter`/`ToolError`/`ShellEnv` dispatch path ever +inspects owner liveness on behalf of an unrelated call. This is the same "valid persisted state + fail-closed admission + no reachable self-healing transition" shape as the Claude incident, just triggered by `PendingStart` -instead of a non-empty `attempts` list under `recovery_pending`. +instead of a non-empty `attempts` list under `recovery_pending`. OpenCode does +now record the same positive owner evidence as Pi (via the shared +`mutation_scope_owner` module) and can prove a `PendingStart` attempt's owner +positively dead — see +[Owner evidence and the doctor-repair path](opencode-mutation-scope-adapter-lifecycle.md#owner-evidence-and-the-doctor-repair-path) +— but that liveness check is not wired into this ordinary hook lifecycle at +all; it backs a separate `doctor`-invoked repair path only. **This holds even when recovery is simultaneously `Pending` or `Flushing` for an unrelated `PendingAbandon` attempt.** Recovery reaching `Clear` retires diff --git a/context/cli/pi-mutation-scope-health.md b/context/cli/pi-mutation-scope-health.md index b44729c72..8c9f03bb0 100644 --- a/context/cli/pi-mutation-scope-health.md +++ b/context/cli/pi-mutation-scope-health.md @@ -49,7 +49,7 @@ production-reachable state. A live-owner or uncertain-owner attempt (a live pid whose exact process-instance identity cannot be positively established, per -`is_definitely_dead` in `process_owner.rs`) is therefore ordinary in-flight +`is_definitely_dead` in the shared `hooks/mutation_scope_owner.rs`) is therefore ordinary in-flight state: it never blocks any other admission, and this adapter's D10 stale-owner sweep leaves it completely untouched (proven by `clear_recovery_is_healthy_with_an_uncertain_owner_pending_start_attempt_never_swept_by_an_unrelated_start` diff --git a/context/cli/pi-mutation-scope-integration.md b/context/cli/pi-mutation-scope-integration.md index 21d08eaac..acb541402 100644 --- a/context/cli/pi-mutation-scope-integration.md +++ b/context/cli/pi-mutation-scope-integration.md @@ -182,8 +182,12 @@ captured via `getppid()` at admission time: because `sce hooks pi-mutation-scope` is invoked synchronously as a direct child of the Pi/Node process for that exact call (`tool_call` is a blocking pre-execution gate), the OS-reported parent pid at that moment *is* the owning Pi process, with no -wire-protocol or TypeScript-extension change needed. `is_definitely_dead` -(`pi_mutation_scope/process_owner.rs`) proves death via `kill(pid, 0)` == +wire-protocol or TypeScript-extension change needed. `ProcessOwner` and +`is_definitely_dead` now live in the shared `cli/src/services/hooks/mutation_scope_owner.rs` +module (extracted from Pi's own `process_owner.rs` so other adapters can +reuse the same positive-evidence primitive; Pi's own `health.rs`/`state.rs` +import it from there with identical behavior). `is_definitely_dead` proves +death via `kill(pid, 0)` == `ESRCH` on Unix, and additionally guards against PID reuse on Linux by comparing the parent's `/proc//stat` start-time field against the recorded value; a live pid whose instance identity can't be established this diff --git a/context/context-map.md b/context/context-map.md index 6e66de8b8..3b65cc836 100644 --- a/context/context-map.md +++ b/context/context-map.md @@ -34,7 +34,9 @@ Feature/domain context: - `context/cli/mutation-trace-scope-abandonment.md` (the mutation-cursor runtime's second protected entrypoint in `cli/src/services/mutation_trace/runtime/scope_runtime.rs`, built by the `mutation-scope-runtime-integration` plan and the first production call site for `protocol::abandon`: `abandon_scope(repository_root, scope, open_db) -> Result` retires a scope whose final worktree boundary was never observed, sharing `coordinate()`'s `ProtectedWorktree` prefix but deliberately capturing **no** Git snapshot, pin, diff, reconciliation, scope registration, or worktree initialization — so abandonment is not a `RuntimeBoundary` and needs no Quint change; the classify-before-transition order that recovers what `protocol::abandon`'s uniform guarded no-op cannot report (`load_scope` first for the missing-row and cross-worktree cases the projection seam treats as errors, then `load_worktree` for the `NeverSeen` / terminal / `Active` split); the `Abandoned` / `AlreadyTerminal` / `RecoveryRequired` outcomes and the `InheritedExternalTaint` | `MissingScope` | `NeverSeenScope` | `MissingWorktreeState` recovery reasons; the inherited-marker short-circuit that returns before the DB provider is ever invoked; the fence-completion rule that clears the marker only for a settled abandonment or proven-terminal no-op and leaves it armed for every recovery-required outcome and every error, with `MarkerClearAfterCompletion` carrying the already-settled outcome; the CAS retry bounded by the coordinator's shared `MAX_CAS_RETRY_ATTEMPTS`, settling on a competitor's terminal status rather than overwriting it; and the deliberate false-negative tradeoff whereby a missing or `NeverSeen` target forces conservative strong recovery that may abandon unrelated live scopes, because attribution safety outranks preserving potentially valid evidence) - `context/cli/mutation-scope-runtime.md` (the crate-visible mutation-trace runtime seam and the lifecycle contract every current or future harness adapter must uphold, recorded by the `mutation-scope-runtime-integration` plan: the ten `pub(crate)` re-exports in `runtime/mod.rs` (`coordinate`, `RuntimeBoundary`, `StartProvenance`, `CoordinateOutcome`, `CoordinateError`, `ExternalTaintOperation`, `abandon_scope`, `AbandonScopeOutcome`, `AbandonRecoveryReason`, `AbandonScopeError`), with `ExternalTaintOperation` riding through `coordinator.rs`'s own `pub use` because `CoordinateError::ExternalTaintMarker` carries it — so the type becomes crate-visible without `protected_worktree` becoming a public module — while every `mod` declaration stays private and `ProtectedWorktree`/`ProtectedWorktreeError`/`WORKTREE_LOCK_TIMEOUT`/`reconcile_worktree` stay internal, kept clippy-clean by `#[allow(unused_imports)]` on the re-exports per the `services/style.rs` precedent rather than a placeholder consumer; and the adapter obligations themselves — a scope is one independently mutation-capable execution so a concurrent main agent and subagent need distinct `ScopeId`s, `Start`/`Advance`/`Close` semantics including that only `Start` may carry the optional `StartProvenance`, registered after scope registration and before the protocol commits and never inside `ProtocolState` or the CAS transition, with that registration conditional on the `ScopeState` `register_scope` returns so a provenance row may only be created while the scope is `NeverSeen` (an admission-time snapshot that a post-admission replay cannot backfill) while an existing row is still validated on every provenance-carrying `Start`, that a failed tool still requires `Advance` (the boundary is an observation, not a successful edit) and that a `ScopeId` is never reused after a terminal status (the `NeverSeen` guard silently refuses to reactivate it), `abandon_scope()` requiring positive staleness evidence and never inferring it from `ActorKind`, the `abandon` → `coordinate(Start(successor))` sequence with what each outcome implies for it including that a failed abandonment must never be treated as a safely started successor, that abandonment is not a `RuntimeBoundary` and needs no Quint change, the D1 tradeoff whereby a missing or `NeverSeen` target deliberately forces conservative strong recovery that may invalidate unrelated live scopes because attribution safety outranks preserving potentially valid evidence, and the attribution boundary that `AiExclusive(scope)` means scope exclusivity only and is not standalone proof that no human edited the worktree; a generic `sce hooks mutation-scope` ingress now drives the seam, joined by current Claude Code and Codex adapter drivers (both registered by `sce setup` and reachable) and an OpenCode adapter driver (full lifecycle + recovery, now generated as the final `sce-mutation-scope.ts` plugin and registered by `sce setup`, so reachable by a real session) — a Pi adapter driver now also exists (`cli/src/services/hooks/pi_mutation_scope/`, hidden `sce hooks pi-mutation-scope`) and is driven by the canonical generated Pi extension in ordinary Pi sessions; its human `user_bash` path uses the separate guarded external-mutation supervisor) - `context/cli/mutation-scope-hook-ingress.md` (the one harness-neutral CLI ingress that drives the mutation-scope runtime, in `cli/src/services/hooks/mutation_scope.rs`, built by the `mutation-scope-hook-ingress` plan: the hidden `sce hooks mutation-scope` command routing through the normal `cli_schema::HooksSubcommand::MutationScope` → `convert_hooks_subcommand_request` → `services::hooks::HookSubcommand::MutationScope` → `run_hooks_subcommand_in_repo` stack, reading one normalized JSON lifecycle object from STDIN; the strict `parse_mutation_scope_payload` contract supporting exactly `start`/`advance`/`close`/`flush`/`abandon` with a local `MutationScopePayload` transport enum, `claude_code`/`codex`/`opencode`/`pi` actor mapping, non-blank `scope_id`/`event_id`, exact per-operation key sets, an optional `provenance` object accepted on `start` only (a required non-blank `session_id`, an optional `model_id` where an absent key and an explicit `null` both mean no model, and no other key), and a dedicated hard rejection for any `worktree_id` key; the operation mapping to `RuntimeBoundary::Start`/`Advance`/`Close`/`Flush` through `coordinate()` or a direct `abandon_scope()` call, forwarding `ScopeId`/`EventId`/`ActorKind` and `start`'s optional `StartProvenance` verbatim because `EventId` equality is the runtime replay/idempotency key and provenance values arrive already canonical; identity ownership — the adapter owns `scope_id`/`event_id`/`actor_kind`, SCE owns `worktree_id`/Git tree identities/revisions/attempt IDs, and worktree identity is derived only by the runtime from the invoking checkout; the lazy `FnOnce` DB provider reusing `open_agent_trace_db_for_hook_runtime` so DB acquisition stays inside the runtime's protected-worktree ordering; the non-fail-open error classification by durable completion — malformed payload or any pre-completion `CoordinateError`/`AbandonScopeError` → `CliError`/non-zero, while `CoordinateError::MarkerClearAfterCommit` and `AbandonScopeError::MarkerClearAfterCompletion` are treated as durable success with empty stdout, the marker-cleanup failure logged via `sce.hooks.mutation_scope.marker_clear_after_durable_completion`, and the transition not retried; the empty-stdout success contract; abandonment ownership keeping no-snapshot semantics; and the generic-ingress vs harness-adapter boundary — a Claude Code adapter driver (`cli/src/services/hooks/claude_mutation_scope/`) and a Codex adapter driver (`cli/src/services/hooks/codex_mutation_scope/`, hidden `sce hooks codex-mutation-scope`) now exist and are registered by `sce setup`, both consuming this seam's own `pub(crate)` in-process entrypoint; an OpenCode adapter driver (`cli/src/services/hooks/opencode_mutation_scope/`, hidden `sce hooks opencode-mutation-scope`) also consumes it with a full `Start`/`Close`/`Abandon` lifecycle and checkout-local recovery state, and is now driven in production by the generated `sce-mutation-scope.ts` plugin installed last by `sce setup`; a Pi adapter driver (`cli/src/services/hooks/pi_mutation_scope/`, hidden `sce hooks pi-mutation-scope`) also consumes it now, with `(session-id, tool-call-id)`→`ScopeId` derivation and getppid()/`/proc`-start-time-based stale-process recovery; the canonical generated Pi extension drives it in ordinary sessions — see `context/cli/pi-mutation-scope-integration.md`) -- `context/cli/claude-mutation-scope-integration.md` (the first concrete harness lifecycle adapter, `cli/src/services/hooks/claude_mutation_scope/`, hidden command `sce hooks claude-mutation-scope`, built by the `claude-mutation-scope-integration` plan: one independently mutation-capable Claude tool execution = one SCE mutation `ScopeId` (a session/prompt/main agent/subagent is never a scope); the `classify_tool` table (mutation-capable including unknown names, read-only `Read`/`Glob`/`Grep`/`WebFetch`/`WebSearch`/`AskUserQuestion`, `Agent` = delegation) plus the model-only `is_explicit_background_shell` predicate; the length-prefixed hash-free `cc-tool-v1|n=|s=..|a=..|t=..` `ScopeId` keyed on a monotonic checkout-local `attempt_seq` (never reused after terminal) with deterministic `|start` / `|close` `EventId`s; the `/sce/claude-mutation-scope-state.json` bookkeeping store (never attribution evidence, never synced) with its own separate lock never held across a seam call; `PreToolUse` write-ahead `pending_start` → seam `start` → `active` and its fail-closed Claude `permissionDecision: "deny"` on any failure (never `allow`, detail logged via `sce.hooks.claude_mutation_scope.pre_tool_use_fail_closed`); `PostToolUse`/`PostToolUseFailure` → `close`, with `pending_start`+terminal → abandon-not-late-start (D11) and failed-`close` → abandon-not-replay (D12); the abandonment cleanup signals (`PermissionDenied`, `Stop`/`StopFailure`, `UserPromptSubmit`, `SubagentStop`, `SessionEnd`, best-effort `WorktreeRemove` — the last two not observed to fire on Claude Code `2.1.258`); the `recovery_pending` barrier that denies new mutation-capable `PreToolUse` until quiescent then runs one seam `flush`; raw hook `cwd` (or `worktree_path` for `WorktreeRemove`) as authoritative repository root with no adapter-constructed `WorktreeId`; the `run_in_background = true` denial and the separate self-detaching-descendant unsupported boundary (D20, with T04's Git-observable evidence); the ten unmatched `sce setup` registrations; the strict `claude_mutation_scope → hooks::mutation_scope → mutation_trace::runtime` dependency direction through the single `run_mutation_scope_from_payload` seam import (T05); admission-time exact model-state snapshot into `ScopeProvenance`; and real Git/Agent Trace persistence coverage shared with the Codex path) +- `context/cli/claude-mutation-scope-integration.md` (the first concrete harness lifecycle adapter, `cli/src/services/hooks/claude_mutation_scope/`, hidden command `sce hooks claude-mutation-scope`, built by the `claude-mutation-scope-integration` plan: one independently mutation-capable Claude tool execution = one SCE mutation `ScopeId` (a session/prompt/main agent/subagent is never a scope); the `classify_tool` table (mutation-capable including unknown names, read-only `Read`/`Glob`/`Grep`/`WebFetch`/`WebSearch`/`AskUserQuestion`, `Agent` = delegation) plus the model-only `is_explicit_background_shell` predicate; the length-prefixed hash-free `cc-tool-v1|n=|s=..|a=..|t=..` `ScopeId` keyed on a monotonic checkout-local `attempt_seq` (never reused after terminal) with deterministic `|start` / `|close` `EventId`s; the `/sce/claude-mutation-scope-state.json` bookkeeping store (never attribution evidence, never synced) with its own separate lock never held across a seam call; `PreToolUse` write-ahead `pending_start` → seam `start` → `active` and its fail-closed Claude `permissionDecision: "deny"` on any failure (never `allow`, detail logged via `sce.hooks.claude_mutation_scope.pre_tool_use_fail_closed`); `PostToolUse`/`PostToolUseFailure` → `close`, with `pending_start`+terminal → abandon-not-late-start (D11) and failed-`close` → abandon-not-replay (D12); the abandonment cleanup signals (`PermissionDenied`, `Stop`/`StopFailure`, `UserPromptSubmit`, `SubagentStop`, `SessionEnd`, best-effort `WorktreeRemove` — the last two not observed to fire on Claude Code `2.1.258`); the `recovery_pending` barrier that denies new mutation-capable `PreToolUse` until quiescent then runs one seam `flush`; raw hook `cwd` (or `worktree_path` for `WorktreeRemove`) as authoritative repository root with no adapter-constructed `WorktreeId`; the `run_in_background = true` denial and the separate self-detaching-descendant unsupported boundary (D20, with T04's Git-observable evidence — full detail split out to [`claude-mutation-scope-background-execution.md`](cli/claude-mutation-scope-background-execution.md) for the repository's per-file line budget); the ten unmatched `sce setup` registrations; the strict `claude_mutation_scope → hooks::mutation_scope → mutation_trace::runtime` dependency direction through the single `run_mutation_scope_from_payload` seam import (T05); admission-time exact model-state snapshot into `ScopeProvenance`; real Git/Agent Trace persistence coverage shared with the Codex path; and, added by the `doctor-mutation-scope-fix` plan's T04, a third `pending_abandon` attempt phase durably persisted before any abandon seam call — established evidence that abandonment has already been decided, distinct from "may still be running" — with every attempt a broad cleanup signal (`Stop`/`UserPromptSubmit`/`SubagentStop`/`SessionEnd`/`WorktreeRemove`) matches now marked `pending_abandon` in one write before any of their seam calls run, so one attempt's seam failure can never leave a batch sibling without its own retryable evidence; health classification detail split out to [`claude-mutation-scope-health.md`](cli/claude-mutation-scope-health.md)) +- `context/cli/claude-mutation-scope-background-execution.md` (the `run_in_background = true` `PreToolUse` denial and the separate self-detaching-descendant unsupported boundary D20 — Git-observable evidence that a foreground `setsid` command's detached descendant can still mutate the repository after `PostToolUse` returns — detail split out of `claude-mutation-scope-integration.md` for the repository's per-file line budget) +- `context/cli/claude-mutation-scope-health.md` (the `doctor-mutation-scope-health` plan's Claude task, extended by the `doctor-mutation-scope-fix` plan's T04: `classify_health` in `cli/src/services/hooks/claude_mutation_scope/health.rs` maps `recovery_pending`/`attempts` onto `healthy | recovering | blocked | invalid` — `recovery_pending == false` is `healthy` regardless of live attempts; `recovery_pending == true` with `attempts` empty is `recovering` (the barrier's own next-`PreToolUse` flush clears it); `recovery_pending == true` with `attempts` non-empty is `blocked`, the exact incident shape this classifier exists to surface, since nothing in the ordinary hook lifecycle retries, removes, flushes, or clears from that shape; a read/parse failure is `invalid`. T04 adds a separate, `doctor`-invoked repair path (`assess_repairability`/`repair_blocked` in `health.rs`/`lifecycle.rs`, wired into `sce doctor --fix` by the same plan's T05) for the `blocked` shape: `AutoFixable` only when every currently persisted attempt is already `pending_abandon` (no established-abandon-intent `pending_start`/`active` attempt present); `repair_blocked` re-reads and re-proves that same condition inside one lock-protected read-only state transaction before releasing the lock and retrying each attempt's already-established seam `abandon` call independently, clearing the `recovery_pending` barrier only once no attempts remain — this classifier's own read-only behavior is unaffected by that repair path's existence) - `context/cli/mutation-trace-ref-reconciliation.md` (the conservative per-worktree snapshot-ref reconciliation pass in `cli/src/services/mutation_trace/runtime/ref_reconciliation.rs`, built by the `mutation-cursor-ref-reconciliation` plan: `reconcile_worktree(repository_root, open_db)` → `pub(super) reconcile_worktree_inner(.., on_lock_contention)`, both returning `ReconciliationOutcome` (`Reconciled(ReconciliationReport { local_required, retained, deleted })` for a pass that ran | `SkippedNoCheckoutIdentity` — an `Ok`, not an `Err` — when no current checkout identity could be derived), a variant-per-fallible-step `ReconcileError` with no `Other`, and the module-owned `RECONCILIATION_LOCK_TIMEOUT`; the two-invariant model — a strictly per-worktree fail-closed local-consistency check via `load_tree_roots(W)` vs. a repository-wide deletion-safety set via `load_all_tree_roots()` so an `A`-owned ref is retained whenever any worktree still durably needs its tree — run entirely under the same `/sce/mutation-cursor.lock` `WorktreeLock` `coordinate()` holds, with the repository-wide read kept coherent by being one SQL statement / one DB snapshot rather than a repository-global lock; deletes only SCE-owned refs via one atomic `git update-ref --no-deref --stdin`, writes no `mutation_trace_*` row, never arms `ExternalTaintMarker`, runs no `git gc`; imperative durability maintenance below the verified Quint protocol; reclaims orphan/unreferenced refs only for the namespace of a checkout id a current worktree still derives — a namespace no current worktree owns (identity-based: a deleted linked worktree, or checkout-id metadata loss followed by `get_or_create_checkout_id` minting a fresh id on a still-present worktree) is beyond every per-worktree pass and left to a recorded future repository-scoped unowned-namespace operation, so the current pass does not bound all orphan-ref growth; `reconcile_worktree` has no `pub(crate)` re-export and no harness/command wiring yet) - `context/cli/mutation-trace-snapshot-service.md` (the isolated Git snapshot and ref-pinning service `runtime::git_snapshot::GitSnapshotService` in `cli/src/services/mutation_trace/runtime/git_snapshot.rs`: `new` resolving an absolute `git_dir`, `capture_tree` snapshotting staged/unstaged/untracked/deleted worktree state into the repository's normal object database via a throwaway temp index, `pin_tree` protecting a durable tree with a create-only idempotent **direct** `refs/sce/mutation-cursor//` ref, `diff_trees` emitting `patch.rs`-parseable raw diff text; plus the callerless reconciliation substrate — worktree-scoped `list_pins` inventory returning `Result, PinInventoryError>` that rejects a symbolic ref inside the namespace (mutation-cursor pins are direct refs) as `MalformedRef`, matchable separately from a `git for-each-ref` execution failure, and conditional-atomic `delete_pins` running one `git update-ref --no-deref --stdin` transaction of SHA-conditioned deletes — no-dereference so an inventory→delete direct-ref→symref race cannot escape the inventoried namespace, plus a fail-closed pre-check — that aborts whole if any ref changed since inventory; `REF_NAMESPACE` + `pin_ref_prefix` as the single source of truth for the pin path) - `context/cli/mutation-trace-external-taint.md` (the worktree-local mutation-cursor durability boundary in `cli/src/services/mutation_trace/runtime/external_taint.rs`, built by the `mutation-cursor-external-taint` plan: the `ExternalTaintMarker` primitive — `new(git_dir)`/`exists()`/`persist()`/`clear()` over an empty file at `/sce/mutation-cursor-tainted` whose existence is its entire state, `checkout::persist_checkout_id_inner`-style durability (`sync_data` plus best-effort `#[cfg(unix)]` parent-dir `sync_all`), idempotent persist/clear, `NotFound`-on-clear as success, no `Drop` deletion — as the concrete runtime refinement of the abstract `ProtocolState.external_taint`; armed by the reshaped `coordinate()` entrypoint write-ahead after the `WorktreeLock` and before Agent Trace DB acquisition (a caller-supplied DB provider closure), cleared only on a successful `CoordinateOutcome`, with dedicated fail-closed pre-commit `CoordinateError::ExternalTaintMarker` (`Inspect`/`Persist` only)/`AgentTraceDbUnavailable` variants plus a post-commit `MarkerClearAfterCommit { source, committed }` that carries the durable outcome so a failed trailing clear never hides a committed `MutationEvent`; an inherited marker seeds an invocation-local `external_taint_pending` flag that overlays `protocol::database_failure` onto each freshly loaded projection so `recover` runs once against the captured snapshot, held across a losing recovery CAS and cleared once it lands) @@ -67,7 +69,8 @@ Feature/domain context: - `context/sce/agent-trace-post-commit-dual-write.md` (historical post-commit no-op/dual-write reference; current post-commit behavior is documented in `agent-trace-hooks-command-routing.md`) - `context/sce/agent-trace-hook-doctor.md` (approved operator-environment contract for broadening `sce doctor` into the canonical health-and-repair entrypoint, including stable problem taxonomy — now including `mutation_scope_health` — `--fix` semantics, repository-scoped Agent Trace DB reporting, post-commit Agent Trace auto-sync readiness proof and opt-out behavior, setup-to-doctor alignment rules, canonical Git-hook payload restoration, and the approved downstream human text-mode layout/status/integration contract) - `context/sce/doctor-human-text-contract.md` (implemented compact `sce doctor` human text contract: Environment/Repository/Integrations hierarchy, post-commit Agent Trace auto-sync readiness labels, `[PASS]`/`[WARN]`/`[FAIL]`/`[MISS]` status vocabulary, healthy-row metadata suppression, typed Claude Code/OpenCode/Pi/Codex target and area ordering, configured/detected/empty target resolution, selection-scoped optional-workflow inventory, the Codex hook trust/review reminder, the per-target `Mutation-scope health` row, no-installed-integrations guidance, and JSON as the full-detail route) -- `context/sce/mutation-scope-health-status.md` (the `doctor-mutation-scope-health` plan's T06: the shared `healthy | recovering | blocked | invalid` vocabulary `sce doctor` consumes from each adapter's own classifier, the `mutation_scope_health` `DoctorProblem` category and severity/fixability/readiness mapping — `blocked`/`invalid` are `manual_only` with never-delete-the-state-file remediation, `recovering` is the distinct `no_action_required` fixability with adapter-neutral remediation — and the JSON/text output shape — links out to each adapter's own detailed reachability/reasoning doc) +- `context/sce/mutation-scope-health-status.md` (the `doctor-mutation-scope-health` plan's T06, extended by the `doctor-mutation-scope-fix` plan's T05 and T06: the shared `healthy | recovering | blocked | invalid` vocabulary `sce doctor` consumes from each adapter's own classifier, the `mutation_scope_health` `DoctorProblem` category and severity/fixability/readiness mapping — `invalid` is always `manual_only`; `blocked` now carries a second, adapter-owned `Repairability` fact (`AutoFixable`/`ManualOnly`, `cli/src/services/hooks/mutation_scope_health.rs`) that `sce doctor --fix` (`repair_blocked_mutation_scope_targets`) uses to call Claude's or OpenCode's own `assess_repairability`/`repair_blocked` and, only once a freshly recomputed `classify_health` confirms `healthy`/`recovering`, report the row `fixed`, never trusting the repair call's `Ok(())` alone; a `manual_only` `blocked` row still gets the never-delete-the-state-file manual remediation, and `recovering` is still the distinct `no_action_required` fixability with adapter-neutral remediation; the `DoctorProblem`'s own rendered `fixability`/remediation text (both JSON `problems[].remediation` and the human "Agent tracing" tree row's `Remediation:` line, plus `sce doctor --fix`'s `[manual]` fix-result line) now varies by this same repairability fact instead of a static `manual_only` string, per T06 — and the JSON/text output shape — links out to each adapter's own detailed reachability/reasoning doc) +- `context/cli/doctor-recovery-safety-model.md` (the `doctor-mutation-scope-fix` plan's T01/T07: the standalone, verified `spec/doctor_recovery.qnt` model of the safety pattern behind `sce doctor --fix`'s repair of a `Blocked` Agent-tracing state — its abstract phase/recovery/owner-oracle concepts and their real Claude/OpenCode mappings, its eight `Safety` invariants each connected to at least one Rust regression test rather than a Quint-Connect trace-replay harness, and why that harness was not built for this model) - `context/sce/setup-githooks-install-contract.md` (canonical `sce setup --hooks` install contract for target-path resolution, all-hook non-blocking missing-CLI bootstrap behavior, foreign-hook preservation and managed-block merge/idempotent outcomes, atomic-swap replacement behavior, and doctor-readiness alignment) - `context/sce/setup-no-backup-policy-seam.md` (non-destructive per-asset install policy: config install writes/swaps each embedded asset individually by atomic rename over the destination, without ever unlinking it first, and never removes an integration target directory as a whole, then prunes catalog-derived stale/deselected asset paths and any parent directory left empty by that pruning; required-hook install uses the same per-file stage/atomic-swap choreography and, like the two JSON merge targets, computes its staged content ahead of the swap — a foreign hook's bytes are kept as an exact prefix with the SCE managed block appended; `.claude/settings.json` and `.opencode/opencode.json` are merge targets whose staged content is computed by JSON-merging the generated document into the user's existing one before the shared stage/swap step; no backup creation; a swap failure leaves prior destination content untouched, with deterministic recovery guidance naming the failing asset) - `context/sce/setup-githooks-hook-asset-packaging.md` (compile-time `sce setup --hooks` required-hook template packaging contract, including all-hook non-blocking missing-`sce` install guidance, available-CLI argument forwarding, post-commit-only origin remote lookup plus remote-URL forwarding/fallback behavior, setup-service accessor surface, and current validation posture) diff --git a/context/plans/doctor-mutation-scope-fix.md b/context/plans/doctor-mutation-scope-fix.md new file mode 100644 index 000000000..8276a3df8 --- /dev/null +++ b/context/plans/doctor-mutation-scope-fix.md @@ -0,0 +1,1902 @@ +# Plan: doctor-mutation-scope-fix + +## Change summary + +The completed `doctor-mutation-scope-health` plan gave `sce doctor` read-only +visibility into each adapter's persisted mutation-scope runtime state, +reporting `healthy | recovering | blocked | invalid` under the human-facing +"Agent tracing" row (internal contract: `mutation_scope_health`). It +deliberately added no repair path: a `blocked`/`invalid` state is +`manual_only`, and `sce doctor --fix` cannot touch it. The original Claude +incident that motivated that plan — a repository-wide `PreToolUse` lockout +from `recovery_pending = true` with a leftover `attempts` entry after a +failed abandon — is now *visible* in `sce doctor`, but still requires manual +state-file surgery to clear, and doctor gives the operator nowhere useful to +look. + +This plan extends `sce doctor --fix` so that a `Blocked` Agent tracing state +can be repaired automatically, but only when the owning adapter can prove +the repair is safe from positive evidence — never a timestamp, file age, or +generic "clear the state" fallback. Health (`healthy/recovering/blocked/invalid`) +and repairability (`auto_fixable`/`manual_only` once `Blocked`) are modeled +as separate facts, exactly as the existing four-way status vocabulary +already separates "is this blocked" from "can doctor fix it." + +Code inspection (not the two prior plans' documentation) establishes the +actual scope: + +- **Codex** (`codex_mutation_scope::health::classify_health`) can never + reach `Blocked` today — every reachable non-`Healthy` shape is + `Recovering` (same-lane sweep) or `Invalid` (unreachable-flush, malformed). + No adapter change is needed or proposed for Codex. +- **Pi** (`pi_mutation_scope::health::classify_health`) can never reach + `Blocked` either — its D10 dead-owner sweep (`reconcile_stale_owners`) + already retires a dead-owner `PendingStart`/`Executed` attempt on the next + tracked `Start` from any session, so the only non-`Healthy` shapes are + `Recovering`/`Invalid`. No adapter change is needed for Pi's own recovery + behavior. Pi's `process_owner.rs` (PID + `/proc` start-time liveness, + never TTL-based) is the one proven positive-evidence primitive this + repository already has, and this plan extracts it into shared adapter + infrastructure so OpenCode can reuse it rather than re-inventing it. +- **OpenCode** (`opencode_mutation_scope::health::classify_health`) reaches + `Blocked` whenever any attempt is `PendingStart` — no boundary in the + current adapter ever sweeps a stale `PendingStart` on behalf of an + unrelated call (unlike Pi's D10 sweep or Codex's same-lane retry), and + `AdapterAttempt` records no owner evidence today, so nothing can currently + distinguish a crashed owner from a live one. This plan adds + backward-compatible owner evidence and a repair path that only fires when + the owner is positively, re-provably dead. +- **Claude** (`claude_mutation_scope::health::classify_health`) reaches + `Blocked` whenever `recovery_pending == true` with non-empty `attempts` — + proven by `claude_mutation_scope::health::tests::stale_non_empty_attempts_after_a_failed_abandon_stays_blocked_across_repeated_pre_tool_use_ac3`, + which drives a real failed `abandon_attempt` seam call. `abandon_attempt` + already runs only when Claude's own runtime has decided an attempt should + be abandoned (a failed close, `PermissionDenied`, or a broad cleanup + signal); the failure mode is that the seam's `abandon` call itself did not + confirm, not that the decision was unsound. Claude's own persisted state + has no phase-level distinction between "abandonment already decided, + awaiting a retry" and "may still be running" — this plan adds one + (`PendingAbandon`) so a doctor repair can safely retry exactly the + already-established terminal cleanup, without inventing a new decision. + +Both real repair paths reuse each adapter's own existing recovery protocol +operations (OpenCode's `flush`/`abandon`/`flush` sequence; Claude's +`abandon_attempt` seam call), but their synchronization boundaries are +adapter-specific. OpenCode's existing `AdapterBoundaryLock` serializes the +complete adapter lifecycle boundary across concurrent OpenCode processes; +its `AdapterStateLock` protects individual durable state transactions and is +never held across a mutation-scope seam call. The intended OpenCode repair +shape is: acquire the boundary lock, normalize orphaned recovery state, +perform state transactions that re-read and re-prove the dead owner, +persist `PendingStart -> PendingAbandon`, and establish/claim the recovery +generation, +release the state lock, run the normal `flush`/`abandon`/`flush` seam protocol, +record progress/completion in further individual state transactions, then +release the boundary lock. + +Claude has no corresponding boundary lock in this plan. Its durable +`PendingAbandon` state is the safety evidence: a state transaction re-reads +and proves that the attempt is already `PendingAbandon`, releases the state +lock, retries the `abandon` seam, and uses another state transaction to +record completion. In both adapters, a stale lock-free diagnosis never +authorizes mutation; the repair function performs a fresh proof inside the +adapter's existing lifecycle-serialization boundary where one exists, while +every durable transition is protected by the state lock. Neither adapter +holds its state lock while calling a mutation-scope seam operation, and doctor +never performs `remove_attempt()`/direct JSON rewriting itself. + +## Acceptance criteria + +- [x] AC1: For a `Blocked` Agent tracing row an adapter proves `auto_fixable`, + plain `sce doctor` (no `--fix`) states the literal remediation + `Run 'sce doctor --fix' to recover ...` in both human text and the + JSON `problems[]` remediation text, and the `DoctorProblem` carries + `fixability: auto_fixable` / `next_action: doctor_fix`. + - Validate: a doctor test seeds an `AutoFixable`-repairable Blocked + OpenCode or Claude state and asserts the rendered text and + `--format json` payload both contain the literal string + `sce doctor --fix` and `"fixability":"auto_fixable"` / + `"next_action":"doctor_fix"`. +- [x] AC2: For a `Blocked`/`Invalid` row that remains `manual_only`, both + human text and JSON name the exact persisted adapter state-file path + (e.g. `/sce/claude-mutation-scope-state.json`) and never + suggest deleting it. + - Validate: a doctor test seeds a `ManualOnly` Blocked/Invalid state and + asserts the rendered text and JSON both contain the real + `state::state_path(...)` value and that no rendered remediation string + contains `delete`. +- [x] AC3: `sce doctor --fix` never abandons or repairs an attempt without + positive evidence freshly re-read and re-proven inside the adapter's + existing lifecycle-serialization boundary where one exists, and every + durable state transition is performed under the adapter state lock. + OpenCode's boundary is explicitly its `AdapterBoundaryLock`; Claude's + primary repairable proof is the durable `PendingAbandon` state and its + state transition, not a state lock held across the seam. Adapter state + locks are never held across mutation-scope seam calls. No repair path + infers staleness from a timestamp, file modification time, or elapsed + duration. + - Validate: `grep -rn "SystemTime\|Instant::now\|\.elapsed()\|modified()" cli/src/services/hooks/claude_mutation_scope cli/src/services/hooks/opencode_mutation_scope cli/src/services/hooks/mutation_scope_owner.rs` finds no staleness use outside unrelated lock-timeout constants; the concurrent-race regressions in T03/T04 pass. +- [x] AC4: A legacy persisted state file written before this change (no + owner evidence, no `PendingAbandon` phase) remains readable and stays + `manual_only` when `Blocked` — upgrading SCE never makes an + old, ambiguous attempt auto-fixable on its own. + - Validate: T03/T04 legacy-fixture regression tests pass. +- [x] AC5: An interrupted repair (a crash or process kill between any two + durable writes the repair introduces) leaves state that a later + `sce doctor --fix` completes safely, without ever requiring state + deletion and without resurrecting or duplicating a terminal/removed + attempt. + - Validate: T03/T04 crash-mid-repair regression tests pass. +- [x] AC6: `sce doctor --fix` never reports a mutation-scope fix result + `fixed` while the freshly recomputed final `classify_health` result + for that target remains `Blocked`/`Invalid`; a final result of + `Recovering` is accepted as a successful repair (`Blocked` -> + `Recovering` counts as removing the durable wedge). + - Validate: T05 postcondition regression tests, including one whose + repair only reaches `Recovering` (a normal residual recovery step + remains) and one where the repair leaves the target still `Blocked` + (must not report `fixed`). +- [x] AC7: The `mutation_scope_health` JSON array's shape + (`target`/`status`/`reason`/`detail`) and the `healthy`/`recovering`/ + `blocked`/`invalid` status strings are unchanged; no `MutationScope*` + Rust type or the `mutation_scope_health` field name is renamed. + - Validate: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor`; inspect the JSON payload in that test output for the unchanged array shape and status strings. +- [x] AC8: The stated safety invariants (doctor cannot abandon a + potentially live attempt; an unprovable/unknown owner is never + treated as proof of death; doctor cannot clear recovery while + unresolved lifecycle evidence exists; concurrent hook-process and + doctor execution cannot abandon a live attempt; a terminal/removed + attempt is never resurrected; an interrupted repair stays fail-closed + and retryable; a reported successful repair cannot leave + `health == Blocked`) are stated formally and connected to the Rust + implementation. + - Validate: `nix run .#quint -- typecheck spec/doctor_recovery.qnt && nix run .#quint -- test spec/doctor_recovery.qnt`; T07's connection tests pass. + +### Full validation + +- `nix flake check` + +### Context sync + +- `context/sce/mutation-scope-health-status.md` (must describe the new + repairability facet — `auto_fixable` becoming reachable for `Blocked`, + the adapter-owned `assess_repairability`/`repair_blocked` boundary, and + that a `Blocked` problem record may now dynamically change fixability) +- `context/sce/agent-trace-hook-doctor.md` (the `--fix` execution contract + must describe the new adapter-owned mutation-scope repair step and where + it sits in the initial-diagnosis -> existing-repairs -> final-diagnosis + flow) +- `context/sce/doctor-human-text-contract.md` (the new `Remediation:` line + under the "Agent tracing" row) +- `context/cli/claude-mutation-scope-integration.md` (the new + `PendingAbandon` phase and its safety semantics) +- `context/cli/opencode-mutation-scope-adapter-lifecycle.md` and/or + `context/cli/opencode-mutation-scope-integration.md` (the new + backward-compatible owner-evidence field and the dead-owner repair path) +- `context/cli/pi-mutation-scope-integration.md` (the process-owner + primitive is now shared, not Pi-only, if its description changes as a + result) +- `context/context-map.md`, only if navigation actually needs it +- A new shared doc for the extracted process-owner module and/or the + doctor-repair invariants, only if implementation reveals content + substantial enough that folding it into the docs above would be unclear + (mirroring the exception the prior plan took for + `mutation-scope-health-status.md` itself) — do not create one + speculatively. + +## Task context synchronization lifecycle + +Persist this field in every plan; this is durable plan state, not chat state: + +- **Task context synchronization:** every task carries `pending | synced | blocked`. + A completed task must be `synced` before another task can start or the plan can + finish. +- For `blocked`, record **Blocker**, **Required action**, and **Retry condition** + beside the status. Never infer `synced` from conversation history; write every + lifecycle transition to the plan file. + +## Constraints and non-goals + +- **In scope:** `cli/src/services/hooks/claude_mutation_scope/{state,mod,health}.rs`; + `cli/src/services/hooks/opencode_mutation_scope/{state,mod,health}.rs`; + `cli/src/services/hooks/mutation_scope_health.rs` (new `Repairability` + enum); a new shared process-owner module extracted from + `cli/src/services/hooks/pi_mutation_scope/process_owner.rs`; + `cli/src/services/hooks/pi_mutation_scope/{mod,health}.rs` (import-only + change to consume the extracted module); `cli/src/services/doctor/{mod,inspect,render,fixes,types}.rs`; + a new focused Quint model under `spec/`; the context docs listed above. +- **Out of scope:** Codex and Pi adapter *behavior* (their recovery + protocols, state machines, and reachable health shapes are unchanged; + Pi's only change is the pure extraction with identical behavior); + `MutationScopeHealthStatus`'s four-way vocabulary (unchanged, per the + existing `doctor-mutation-scope-health` plan); `Invalid`-state auto-repair + or migration; generic doctor integration-asset repair; DB schema repair; + fixing the existing Claude/OpenCode/Codex asset `ManualOnly` + inconsistencies unrelated to mutation-scope; redesigning `ServiceLifecycle` + providers; general `doctor --fix` cleanup unrelated to mutation-scope + recovery; extending `spec/mutation_cursor.qnt` itself (the new formal + model is a separate, focused file — the doctor-repair concern is not part + of the verified core mutation-cursor protocol that file owns). +- **Constraints:** never delete an adapter state file, clear + `recovery_pending`/`RecoveryState` generically, reset attempts, or + fabricate an abandon outside an adapter's own real protocol operations; + never use a TTL/file-age/elapsed-duration heuristic as proof of staleness; + every repair mutates state only through each adapter's existing recovery + protocol seam (`flush`/`abandon` payloads through the mutation-scope + ingress), never by doctor calling `remove_attempt()` or rewriting JSON + directly. OpenCode repair must acquire its `AdapterBoundaryLock` before + lifecycle recovery, and its `AdapterStateLock` may protect only an + individual read/re-proof/durable-transition transaction; it must be + released before every seam call and reacquired for later durable progress. + Claude must use its existing state lock for each fresh read/proof and + durable transition, but must release it before every mutation-scope seam + call; this plan does not add a Claude boundary lock. Neither adapter may + trust an earlier lock-free classification as authorization, and the + repair function itself must perform the fresh proof. A new persisted field + must deserialize from a state file written before this change + (backward-compatible `#[serde(default)]`, matching the existing precedent + in `opencode_mutation_scope::state::AdapterState`); the JSON + `mutation_scope_health` array's field set and status strings, and every + existing `MutationScope*` Rust type name, are preserved exactly. +- **Non-goal:** this plan does not make `Invalid` states auto-fixable; it + does not unify the four adapters onto one shared repair trait (per the + request's guidance, prefer adapter-owned functions dispatched by doctor's + existing `IntegrationTargetId` match, matching how + `inspect_mutation_scope_health` already dispatches, unless a real + multi-adapter shared abstraction turns out to be unavoidable); it does + not broaden doctor's mutation-scope repair into a general "reset + integration state" feature. + +## Assumptions + +- Codex and Pi need no adapter-behavior change because their current + classifiers (`codex_mutation_scope::health::classify_health`, + `pi_mutation_scope::health::classify_health`) never produce `Blocked`, + confirmed by direct inspection of both functions and their existing + regression tests. If a future adapter change ever makes either reach + `Blocked`, this plan's investigation should be re-run for that adapter + rather than assumed to still hold. +- Claude's dead-owner liveness path (for a `PendingStart`/`Active` attempt + with no established abandon intent) is included only if T04's own + investigation finds a currently-reachable ambiguous shape that needs it; + Claude's primary, currently-proven `Blocked` shape (a failed abandon + seam call) does not need owner evidence at all, since the abandon + decision was already made by Claude's own runtime before the seam call + failed — only the seam confirmation is missing, which + `PendingAbandon`-retry addresses directly. +- The new adapter-owned repairability distinction is modeled as + `assess_repairability`/`repair_blocked` functions on each adapter's + existing module rather than a new Rust trait, per the request's explicit + instruction not to introduce a trait merely for architectural appearance; + doctor dispatches to them the same way `inspect_mutation_scope_health` + already dispatches `classify_health` by `IntegrationTargetId`. + +## Task stack + +- [x] T01: `Formalize the safe doctor-repair protocol and its invariants` (status:done) + - Task ID: T01 + - Scope: In — a new, focused Quint model (e.g. `spec/doctor_recovery.qnt`, + exact name chosen at implementation time) modeling the shared abstract + pattern this plan introduces: attempt phases (a start-pending phase, an + executing/active phase, and a phase meaning "abandonment already + decided"), an abstract three-valued owner-liveness oracle (`Alive` / + `Dead` / `Unknown`, deliberately never time-based), a per-adapter lock, + and two actors — the adapter's own hook process and doctor — racing + over the same durable state. The model must state and check, as + invariants or temporal properties: doctor cannot abandon a potentially + live attempt; `Unknown` owner state is never treated as proof of death; + doctor cannot clear recovery while unresolved lifecycle evidence + exists; a doctor repair action cannot produce a state outside the + adapter's own reachable transition set; concurrent hook-process and + doctor execution cannot cause a live attempt to be abandoned; a + terminal/removed attempt is never resurrected; an interrupted repair + remains fail-closed and retryable (every state it can stop in is + itself a legitimate, already-reachable state); a reported successful + repair cannot coincide with final `health == Blocked`. Out — any Rust + behavior change; any change to `spec/mutation_cursor.qnt` itself (this + is a separate, focused model, not an extension of the verified core + mutation-cursor protocol, since doctor-repair concerns are adapter + bookkeeping the core protocol does not model). + - Dependencies: none + - Done when: the new `.qnt` file typechecks; it has its own `quint test` + example-based sanity tests; each invariant above is expressed as a + Quint `invariant` (or temporal property) and a documented + `quint run --invariant=... [--max-samples=...]` / `quint verify` + invocation (recorded in the file's own header comment) finds no + violation within a stated, documented bound; the file's header states + explicitly which real Claude/OpenCode concepts each abstract phase and + the owner oracle correspond to, so T03/T04 can be checked against it. + - Verify: `nix run .#quint -- typecheck spec/doctor_recovery.qnt`; `nix run .#quint -- test spec/doctor_recovery.qnt`; the invariant-check command recorded in the file's own header. + - Completed: 2026-09-21 (corrected three times on 2026-09-21 — the first + pass's model was directionally useful but several named invariants + were weaker than claimed; the second pass fixed that but still + modeled `recovery`/`health`/`fixState` per attempt where the real + adapters persist one recovery/health/fix-report fact for the whole + adapter, and left `recoveryProgress`/`completeAbandon` runnable while + `DoctorHolds`; the third pass fixed both of those but introduced two + new mismatches of its own: it added a `recovery == Clear` precondition + to `hookDecideAbandon`/`doctorAttemptRepairWith` that wrongly encoded + "at most one attempt can be `PendingAbandon`" as a shared protocol + invariant, and it described the model's `lock: LockHolder` variable as + corresponding primarily to OpenCode's `AdapterBoundaryLock` even though + the model required that variable free during the seam-adjacent + `recoveryProgress`/`completeAbandon` actions — the real + `AdapterBoundaryLock` remains held across seam calls; only the shorter + `AdapterStateLock` is released before them. This record describes the + fourth pass, which removes the single-`PendingAbandon` restriction and + renames the lock abstraction so it stops claiming to be + `AdapterBoundaryLock`) + - Files changed: `spec/doctor_recovery.qnt` (new, then corrected in + place three times — no T01b/T01c/T01d, same task) + - Result: `spec/doctor_recovery.qnt` is a standalone Quint model (does + not extend `spec/mutation_cursor.qnt`) of the safe doctor-repair + pattern. `phase: AttemptId -> AttemptPhase` stays per-attempt, but + `recovery: RecoveryState` and `fixState: FixState` are now single + adapter-wide variables (not `AttemptId -> ...` maps), matching the real + OpenCode `AdapterState { recovery: RecoveryState, attempts: + Vec }` shape and Claude's whole-file + `recovery_pending` flag — both adapters report one health/one fix + result for the adapter, never one per attempt: + - `AttemptPhase = NotAllocated | PendingStart | Active | PendingAbandon + | Removed` (unchanged from both earlier passes). + - `RecoveryState = Clear | Pending | Flushing`, still a single `var + recovery` for the whole adapter (unchanged shape from the third + pass), distinguishing "no terminal recovery obligations remain + anywhere in the adapter" (`Clear`), "one or more terminal recovery + obligations exist and need processing" (`Pending`), and "the + adapter-wide recovery sequence is currently processing one or more + obligations" (`Flushing`). It is not ownership of one particular + attempt: multiple `PendingAbandon` attempts may be covered by the + same shared `recovery` value at once. The third pass had added a + `recovery == Clear` precondition to `hookDecideAbandon`/ + `doctorAttemptRepairWith`'s eligibility specifically to force at + most one attempt to be `PendingAbandon` at a time; this fourth pass + removes that precondition, because the real OpenCode + `begin_terminal_cleanup(scope_ids: &[String])` sets every matching + attempt to `PendingAbandon` in one call and `resolve_recovery()` + filters and processes every currently-`PendingAbandon` attempt, not + at most one, and Claude's broad cleanup signals + (`Stop`/`SessionEnd`/`UserPromptSubmit`/`SubagentStop`, per T04) may + likewise decide to abandon several attempts at once. + `abandonTransition` now takes the current `recovery` value as a + parameter and computes `recovery: if (rec == Clear) Pending else + rec` — establishing a new `PendingAbandon` obligation moves `Clear + -> Pending` but leaves an already-`Pending`/`Flushing` recovery + unchanged, so a second obligation can join an in-flight recovery + pipeline without disturbing it. `recoveryProgress` still advances + `Pending -> Flushing` for the shared pipeline. `completeAbandon` no + longer clears `recovery` unconditionally on removing its attempt — + it now computes `otherObligationsRemain = exists a. (phase with + this attempt set to Removed).get(a) == PendingAbandon` and sets + `recovery' = if (otherObligationsRemain) Pending else Clear`, so + global recovery only clears once every terminal obligation is + resolved; a remaining obligation falls the pipeline back to + `Pending` (an explicit "retry remaining obligations" step, needing a + fresh `recoveryProgress` before the next `completeAbandon`) rather + than staying `Flushing` for an attempt whose own flush already + finished. Both `recoveryProgress` and `completeAbandon` still + require the short state-transaction abstraction free (see the + lock-naming correction below) — the second pass left this + seam/recovery pair runnable while that variable was held, the third + pass fixed that, and this pass preserves it unchanged; every test + already released it before driving + `recoveryProgress`/`completeAbandon`. + - `Health = Healthy | Recovering | Blocked | Invalid` now comes from a + new `pure def adapterHealth(phase: AttemptId -> AttemptPhase, + recovery: RecoveryState): Health` — one health value for the whole + adapter, derived from `hasPendingStart = exists a. phase.get(a) == + PendingStart` and `hasPendingAbandon = exists a. phase.get(a) == + PendingAbandon` in the exact required priority order: `recovery == + Clear and hasPendingAbandon -> Invalid`, else `hasPendingStart -> + Blocked`, else `recovery == Clear -> Healthy`, else `Recovering` + (covering both `Pending` and `Flushing`). The old per-attempt `pure + def health(phase, recovery)` is gone; every caller now calls + `adapterHealth(phase, recovery)` with no attempt argument. + `testInvalidTakesPriorityOverBlockedWhenBothConditionsHold` (new) + proves the ordering directly: one attempt hand-set to `PendingAbandon` + with `recovery == Clear` and a second, different attempt genuinely + `PendingStart` (both conditions live at once) still classifies + `Invalid`, not `Blocked`. + - `FixState = NotAttempted | RepairAttempted | RepairCompleted | + ReportedFixed | ReportedManual`, now a single `var fixState` for the + whole adapter (previously `AttemptId -> FixState`), matching that + `sce doctor` reports one fix result per adapter, never one per + attempt. `doctorAttemptRepairWith` sets it to `RepairAttempted` + (unconditionally, whether or not the repair turns out eligible); + `completeAbandon` promotes `RepairAttempted -> RepairCompleted`; a + `doctorReportResult` action (now nullary — no attempt argument, since + there is one fix result, not one per attempt) reads the fresh + `adapterHealth(phase, recovery)` at report time and decides + `ReportedFixed` only when it is `Healthy`/`Recovering`, else + `ReportedManual`. Making `fixState` adapter-wide surfaced a case + neither earlier pass had to consider: since `adapterHealth` now + depends on *every* attempt's phase, an unrelated attempt allocating + fresh (`hookAllocate`, `NotAllocated -> PendingStart`) after a + `ReportedFixed` report can make `adapterHealth` swing to `Blocked` + purely because of that unrelated attempt, staling the old report. + `hookAllocate` is the only action that can newly introduce + `hasPendingStart` (every other action only removes a `PendingStart` + contributor or moves through `PendingAbandon`/`Recovering`, which + never regresses toward `Blocked`/`Invalid`), so `hookAllocate` now + resets `fixState' = NotAttempted` instead of passing it through + unchanged — the prior report is intentionally treated as stale once + fresh, doctor-relevant lifecycle activity begins. This reset is a + no-op in every existing test (each calls `hookAllocate` exactly once, + at the very start, while `fixState` is still its `init` value of + `NotAttempted`), and it was required for `--invariant=Safety` to find + no violation at the stated bounds — omitting it reproduces a real + counterexample. + - A shared `pure def abandonTransition(phase, everWasPendingAbandon, + rec, attempt)` (this pass restores a `recovery` parameter the third + pass had dropped — the successor `recovery` value can no longer + always be the literal `Pending`, since an already-`Pending`/ + `Flushing` shared recovery must be left unchanged when a second + obligation joins it) returns the `PendingStart/Active -> + PendingAbandon` + `recovery: if (rec == Clear) Pending else rec` + result record. Both `hookDecideAbandon` and `doctorAttemptRepairWith` + (after its own `stateTxn == DoctorStateTxn and phase == PendingStart + and reading == SeenDead` eligibility check — the third pass's + `recovery == Clear` conjunct is removed here) call this same + function, preserving the second pass's "doctor cannot reach a shape + the ordinary hook path could not also reach" property. + - `OwnerReading = SeenAlive | SeenDead | SeenUnknown` and + `soundReadings(alive)` are unchanged. `doctorAttemptRepairWith`'s + `phase.get(attempt) != NotAllocated` precondition (added in the + second pass) is unchanged. + - The same eight named invariants as the third pass (one renamed, per + below), combined into `val Safety`. Their statements are otherwise + unchanged text from the third pass, but two of them are now + meaningfully different in what they prove, because "at most one + attempt is ever mid-`PendingAbandon`" is no longer true and was + never actually required for either to hold: + `RecoveryNeverClearedWithUnresolvedAbandon` (`recovery == Clear + implies (forall a. phase.get(a) != PendingAbandon)`) now genuinely + constrains `completeAbandon`'s new `otherObligationsRemain` logic — + with the third pass's single-`PendingAbandon` restriction in place + this invariant held almost trivially (there was never a second + `PendingAbandon` attempt to protect against); with that restriction + removed, this is the invariant that actually forces + `completeAbandon` to check the whole resulting attempt set before + clearing `recovery`, and `testMultiplePendingAbandonShareOneRecoveryPipeline` + (new) exercises exactly that: completing the first of two + concurrently-`PendingAbandon` attempts must leave `recovery != + Clear`. `DoctorRepairProducesOnlyOrdinaryLifecycleShapes` + (`phase.get(a) == Removed or (phase.get(a) == PendingAbandon and + (recovery == Pending or recovery == Flushing))`, unchanged text from + the second pass's fix) already held for any number of concurrent + `PendingAbandon` attempts — it says nothing about how many other + attempts share the same `recovery` value, so removing the + single-`PendingAbandon` restriction changes nothing about this + invariant's proof. `InterruptedRecoveryStaysInOrdinaryRetryableState` + likewise already generalized to multiple attempts without + modification: each attempt in `everWasPendingAbandon` and not yet in + `everRemoved` independently must be `PendingAbandon` with `recovery + == Pending or Flushing`, which holds per-attempt regardless of how + many other attempts satisfy the same clause simultaneously. + `ReportedFixedRequiresHealthyFinalState` is renamed + `ReportedFixedExcludesBlockedOrInvalid` (same body: + `fixState == ReportedFixed implies (adapterHealth(phase, recovery) + != Blocked and adapterHealth(phase, recovery) != Invalid)`) — the + old name overstated the requirement as "healthy," when `Recovering` + is deliberately still accepted as a successful report (`Blocked` -> + `Recovering` counts as removing the durable wedge, per this plan's + AC6); the new name says exactly what the invariant checks. + - `pendingAbandonFromDoctor` is unchanged — still a per-attempt + diagnostic/test bookkeeping set, not adapter-wide, since it tracks + which specific attempts doctor touched (a real, per-attempt fact), + not the adapter's health or fix-report state. + - Thirteen `run` tests (up from eleven): the third pass's eleven + tests, updated only where the `doctorAcquireLock`/`doctorReleaseLock` + action names changed to `doctorAcquireStateTxn`/ + `doctorReleaseStateTxn` (see the lock-naming correction below; no + test's assertions changed), plus two new tests. + `testMultiplePendingAbandonShareOneRecoveryPipeline` reaches + `Attempt0 = PendingAbandon, Attempt1 = PendingAbandon, recovery == + Pending` through ordinary `hookAllocate`/`hookDecideAbandon` calls on + both attempts (not a hand-constructed state), asserts + `adapterHealth(...) == Recovering` and `Safety` there, then completes + only `Attempt0` (`recoveryProgress` + `completeAbandon`) and asserts + `Attempt0 == Removed`, `Attempt1 == PendingAbandon`, `recovery != + Clear`, and `adapterHealth(...) == Recovering` still — proving + `completeAbandon`'s per-attempt-set recovery-clearing logic. It then + drives `Attempt1` through its own + `recoveryProgress`/`completeAbandon` and only then asserts `recovery + == Clear` and `adapterHealth(...) == Healthy`. + `testRepairingOneDeadBlockerLeavesOtherBlockerBlocked` reaches + `Attempt0 = PendingStart` with a proven-dead owner and `Attempt1 = + PendingStart` with an unknown owner, both contributing to an initial + `Blocked` classification; it repairs only `Attempt0` + (`doctorAcquireStateTxn`/`doctorAttemptRepairWith(Attempt0, + SeenDead)`/`doctorReleaseStateTxn`) and asserts `adapterHealth(...) + == Blocked` still (because `Attempt1` remains `PendingStart`) and + `doctorReportResult` produces `ReportedManual`, not `ReportedFixed`; + it then completes `Attempt0`'s abandonment and asserts + `adapterHealth(...) == Blocked` and `fixState != ReportedFixed` + persist even after that attempt reaches `Removed`, since `Attempt1` + is still an unrepaired `PendingStart` blocker — the core proof that + repairing one repairable blocker does not mean the adapter itself is + repaired; only the freshly recomputed adapter-wide classifier may + authorize `ReportedFixed`. Both new tests are ordinary-transition + regressions (no hand-constructed `all { ... }` state), matching every + other test's convention except the two adversarial ones documented + below. + `testPendingAbandonWithClearRecoveryIsInvalid` and + `testInvalidTakesPriorityOverBlockedWhenBothConditionsHold` are + otherwise unchanged in structure (aside from the `lock' = lock` -> + `stateTxn' = stateTxn` field rename in their hand-constructed `all { + ... }` blocks) and still deliberately omit `.expect(Safety)` for the + same reason as before (the constructed state is intentionally + adversarial/unreachable). + - Deviation: the file still carries no comments (including no header + comment), per the repository's standing "no comments in code" + instruction; the concept mapping and verification results below stand + in for the file's own header comment, as in every earlier pass. + - Concept mapping (abstract -> real), unchanged from the third pass + except the lock abstraction, corrected below: `PendingStart` -> + OpenCode's `AdapterAttempt` `PendingStart` (the phase its `Blocked` + classification keys on) and, for Claude, a `PendingStart`/`Active` + attempt with no established abandon intent; `Active` -> an attempt + progressing under its owner, or a Claude attempt past `PendingStart` + with no abandon decision yet; `PendingAbandon` -> Claude's new + `PendingAbandon` phase (T04) and OpenCode's `PendingStart -> + PendingAbandon` durable transition (T03) once the dead-owner + condition is proven — and, as of this pass, multiple attempts may + independently carry this phase at once, all covered by the one + shared `recovery` value; `Removed` -> the attempt gone after a + successful seam sequence; `recovery: RecoveryState` (adapter-wide) -> + OpenCode's `AdapterState.recovery` field directly, and Claude's + whole-file `recovery_pending` flag/barrier state — `Clear` is "no + terminal recovery obligation outstanding anywhere in the adapter," + `Pending` is "one or more obligations recorded, pipeline not + actively running," `Flushing` is "the shared pipeline is actively + processing one or more obligations," matching + `begin_terminal_cleanup`/`resolve_recovery`'s real multi-attempt + batch shape, not a single-attempt lock; `adapterHealth(phase, + recovery)` -> `classify_health`'s `healthy`/`recovering`/`blocked`/ + `invalid` result, computed the same way the real classifiers do — + from every attempt's phase plus the one adapter-wide recovery fact, + never from a single attempt in isolation; `fixState` (adapter-wide) + -> the one doctor fix-result lifecycle + `execute_doctor_with_lifecycle_providers` drives per adapter target + (assess -> repair -> re-diagnose -> record one + `DoctorFixResultRecord`), with `ReportedFixed` standing for the + final report deciding `Fixed` only from the freshly recomputed + adapter-wide `classify_health` (never from a repair function's + `Ok(())` alone, and never per-attempt, and never from having + repaired only some of several contributing blockers — see + `testRepairingOneDeadBlockerLeavesOtherBlockerBlocked` above); + `ownerAlive` (ground truth) -> the real, single owning process + instance (PID + `/proc` start-time identity, + `mutation_scope_owner`), monotonic once dead; `OwnerReading` -> + `mutation_scope_owner::is_definitely_dead`'s `Alive`/`Dead`/`Unknown` + result. + Lock mapping, corrected this pass: the third pass's completion + record described `lock: LockHolder` (`LockFree`/`DoctorHolds`) as + corresponding primarily to OpenCode's `AdapterBoundaryLock`, while + the model itself required that variable free during + `recoveryProgress`/`completeAbandon` (the seam-adjacent actions) — + but the real `AdapterBoundaryLock` remains held across OpenCode's + seam calls; it serializes the whole repair lifecycle, seam sequence + included. Only the shorter `AdapterStateLock` is released before + every seam call. Those two claims cannot both describe real + OpenCode, so the variable is renamed `stateTxn: StateTxnHolder` + (`StateTxnFree`/`DoctorStateTxn`), and the two lock actions are + renamed `doctorAcquireStateTxn`/`doctorReleaseStateTxn` to match. + `stateTxn == StateTxnFree` during `recoveryProgress`/`completeAbandon` + now correctly means only "the short durable state-transaction lock + is released," saying nothing about a larger OpenCode boundary lock: + `stateTxn`/`StateTxnHolder` -> OpenCode's `AdapterStateLock` + transaction used to re-read/re-prove/persist a durable transition, + and, for Claude, its ordinary short state-file lock transaction + around the same re-read/re-prove/persist step. OpenCode's + `AdapterBoundaryLock` itself is outside this model variable + entirely — it is a separate, coarser serialization concern (the + complete repair lifecycle boundary across concurrent OpenCode + processes, including the seam calls) that this model does not need + a dedicated variable for, since none of the eight `Safety` invariants + depend on cross-process boundary serialization; T03 is responsible + for implementing `AdapterBoundaryLock` acquisition around its whole + `repair_blocked` call, wrapping (not replacing) the shorter + `AdapterStateLock`-shaped `stateTxn` transactions this model + verifies. For Claude there is no equivalent boundary lock in this + plan; Claude's repair safety rests on the durable `PendingAbandon` + evidence itself plus its own individual state-lock transactions, not + on a boundary-shaped variable. With the renaming, `doctorDiagnose` -> + `assess_repairability`'s unlocked, possibly-stale read; + `doctorAcquireStateTxn`/`doctorAttemptRepair`/`doctorReleaseStateTxn` + -> `repair_blocked`'s durable read/re-prove/transition (run inside + OpenCode's separate `AdapterBoundaryLock`, and without one for + Claude); `recoveryProgress` -> running the adapter's existing + recovery-protocol seam operations (OpenCode's `flush`/`abandon`/ + `flush`; Claude's `abandon_attempt`'s seam `abandon`) — modeled with + `stateTxn == StateTxnFree` required, matching the repository + invariant that `AdapterStateLock` (and Claude's equivalent state + lock) is never held across a seam call; `completeAbandon` -> the + durable transaction recording that seam sequence's success, shared + by both the ordinary hook-process retry path and doctor's repair + path so doctor never invents a new terminal transition. + - Verification command and result: `quint run spec/doctor_recovery.qnt + --invariant=Safety --max-samples=10000 --max-steps=30` -> `[ok] No + violation found (389ms at 25707 traces/second)`; a second, stronger + bound (`--max-samples=20000 --max-steps=50`) -> `[ok] No violation + found (1328ms at 15060 traces/second)`. Both bounds were re-run + against this fourth pass specifically (not carried over from the + third pass's record), since removing the single-`PendingAbandon` + restriction changes which states are reachable. + - Verify outcomes: `quint typecheck spec/doctor_recovery.qnt` -> exit 0 + (no output, clean typecheck); `quint test spec/doctor_recovery.qnt + --match '^test.*'` -> `13 passing`, 0 failed; `quint run + spec/doctor_recovery.qnt --invariant=Safety --max-samples=10000 + --max-steps=30` -> `[ok] No violation found`; `quint run + spec/doctor_recovery.qnt --invariant=Safety --max-samples=20000 + --max-steps=50` -> `[ok] No violation found`; `nix flake check` -> + `all checks passed!` (re-run for this correction, including the + existing `mutation-trace-quint-connect` check against the `spec/` + tree; no check is yet wired specifically to `doctor_recovery.qnt` — + that connection is T07's job per this task's own scope). + - Context impact: Establishes `spec/doctor_recovery.qnt` as the reference + formal model T03/T04's real Rust implementations and T07's + Quint-Connect-or-documented-mapping connection must be checked against. + T03/T04 should read two corrected facts above before writing the + OpenCode/Claude repair code: (1) one adapter-wide `recovery` pipeline + may safely cover multiple concurrent `PendingAbandon` obligations, and + it cannot clear to `Clear` until every one of them is resolved — doctor + and the ordinary hook path may each independently mark different + attempts `PendingAbandon`, and `resolve_recovery`-shaped completion + logic must check the whole attempt set, not just the attempt it is + currently finishing; (2) the model's `stateTxn`/`StateTxnHolder` + variable corresponds to OpenCode's short `AdapterStateLock` transaction + only, never to `AdapterBoundaryLock` — T03 must still acquire + `AdapterBoundaryLock` around the whole `repair_blocked` call per this + plan's own constraints section, and that acquisition is a fact outside + this model's verified surface, not something `stateTxn == StateTxnFree` + stands in for. Doctor's fix result and the classifier's health input + remain both single adapter-level facts derived from every attempt's + phase plus one shared recovery value, not computed or reported per + attempt, and the seam sequence (`flush`/`abandon`/`flush` for + OpenCode, `abandon_attempt`'s seam `abandon` for Claude) must run with + the state-transaction lock released, matching `AdapterStateLock` never + being held across a seam call. No context doc listed in this plan's + "Context sync" section is implicated by this task alone (it adds no + new Rust-facing contract); T07's own context-sync pass is where a new + shared doc for the model, if warranted, would be considered per the + plan's non-speculative instruction. + - Context synchronization: synced + +- [x] T02: `Extract shared positive process-owner evidence from Pi` (status:done) + - Task ID: T02 + - Scope: In — move `ProcessOwner`, `current_process_owner`, + `process_owner_for`, and `is_definitely_dead` out of + `cli/src/services/hooks/pi_mutation_scope/process_owner.rs` into a new + shared module reachable by other adapters (e.g. + `cli/src/services/hooks/mutation_scope_owner.rs`, sibling to the + existing shared `mutation_scope_health.rs`), with identical logic + (`kill(pid, 0)` liveness plus Linux `/proc/{pid}/stat` start-time + PID-reuse proofing, conservative `false` on non-Linux/non-unix, and the + same "never assume dead" test suite including the static + no-TTL/elapsed-time-token source scan); update + `pi_mutation_scope::{mod,health}.rs` to import the shared module with + zero behavior change. Out — any new consumer of the extracted module + (OpenCode/Claude wiring happens in T03/T04); any change to Pi's own D10 + dead-owner sweep behavior, reachable health statuses, or persisted + `owner` field shape. + - Dependencies: T01 (the extracted module's `Alive`/`Dead`/`Unknown` + liveness contract must match T01's abstract owner oracle) + - Done when: `pi_mutation_scope` no longer defines its own + `ProcessOwner`/`is_definitely_dead`; every existing Pi mutation-scope + regression (dispatch, recovery, and health-classifier tests) passes + with identical outcomes; the extracted module's own test suite, + including the static "no TTL/elapsed-time primitive" source scan + (now scoped to the new file), passes. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml pi_mutation_scope`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_scope_owner` + - Completed: 2026-09-21 + - Files changed: `cli/src/services/hooks/mutation_scope_owner.rs` (new — + moved `ProcessOwner`/`current_process_owner`/`process_owner_for`/ + `is_definitely_dead` and their full test suite verbatim from + `pi_mutation_scope/process_owner.rs`); `cli/src/services/hooks/mod.rs` + (registers `pub mod mutation_scope_owner;`); + `cli/src/services/hooks/pi_mutation_scope/process_owner.rs` (deleted); + `cli/src/services/hooks/pi_mutation_scope/mod.rs` (drops the local + `pub(crate) mod process_owner;` declaration); + `cli/src/services/hooks/pi_mutation_scope/{state,health,lifecycle,lifecycle_tests}.rs` + (import paths repointed to `crate::services::hooks::mutation_scope_owner::...`, + no logic changes; `cargo fmt` reordered the new `use` blocks). + - Result: `ProcessOwner`, `current_process_owner`, `process_owner_for`, and + `is_definitely_dead` now live in the shared, adapter-neutral + `cli/src/services/hooks/mutation_scope_owner.rs`, sibling to the existing + `mutation_scope_health.rs`, with byte-identical logic (`kill(pid, 0)` + liveness, Linux `/proc/{pid}/stat` start-time PID-reuse proofing, + conservative `false` on non-Linux/non-unix) and its own complete test + suite including the static no-TTL/elapsed-time-token source scan + (re-scoped to `include_str!("mutation_scope_owner.rs")`). + `pi_mutation_scope` no longer defines any of these symbols; `state.rs`, + `health.rs`, and `lifecycle.rs`/`lifecycle_tests.rs` now import them from + the shared module. No behavior, persisted `owner` field shape, or + reachable Pi health status changed. Two active context docs + (`context/cli/pi-mutation-scope-health.md`, + `context/cli/pi-mutation-scope-integration.md`) cited the old + `pi_mutation_scope/process_owner.rs` path and were corrected to name the + new shared module. + - Deviation: while running the plan's own `Verify` commands, the initial + `SCE_CLI_PACKAGE_FALLBACK=1 cargo test` run showed 7 pre-existing, + change-unrelated failures (`no such table: mutation_trace_scope_provenance`) + caused by a stale `cli/package-fallback`/incremental-build cache; + confirmed pre-existing by stashing this task's changes and reproducing + the identical failure on the unmodified baseline. Running + `bash scripts/prepare-cli-generated-assets.sh` and clearing the stale + `cli/target/debug/build/shared-context-engineering-*` directories + resolved it; both plan `Verify` commands then passed cleanly, including + via the documented `nix develop -c ./scripts/run-cli-cargo.sh` wrapper. + Not a T02 regression or scope item. + - Verify outcomes: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml pi_mutation_scope` -> `94 passed; 0 failed`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_scope_owner` -> `8 passed; 0 failed`; `cargo fmt --manifest-path cli/Cargo.toml -- --check` -> clean; `cargo clippy --manifest-path cli/Cargo.toml` -> no warnings on touched modules. + - Context impact: A shared, adapter-neutral process-owner-liveness module + now exists at `cli/src/services/hooks/mutation_scope_owner.rs` for T03 + (OpenCode) to consume; no user-visible behavior, public interface, + persisted data shape, or architecture-boundary change. `domain`-scoped: + the two Pi-specific context docs that named the old file path were + corrected; no root context file (`overview.md`/`architecture.md`/ + `glossary.md`/`patterns.md`/`context-map.md`) referenced the old path, + so none needed edits. + - Context synchronization: synced + +- [x] T03: `OpenCode: persisted owner evidence and safe PendingStart repair` (status:done) + - Task ID: T03 + - Scope: In — `cli/src/services/hooks/opencode_mutation_scope/{boundary_lock,events,lifecycle,mod,os_lock,payload,state,health,tests}.rs`. + Preserve the existing lock hierarchy: `AdapterBoundaryLock` + (`opencode-mutation-scope-boundary.lock`) serializes complete OpenCode + lifecycle boundaries, while `AdapterStateLock` + (`opencode-mutation-scope-state.lock`) protects only individual durable + state operations and is never held across a seam call. Add an optional, + backward-compatible `owner: Option` + (`#[serde(default)]`, mirroring the existing `next_recovery_generation`/ + `recovery` precedent in `AdapterState`) to `AdapterAttempt`, stamped + with the shared `current_process_owner()` when a `PendingStart` attempt + is allocated; a state file with no `owner` for an attempt (any file + written before this change) must deserialize to `owner: None`, never + inferred. Add `assess_repairability(git_dir) -> Repairability`, called + only when `classify_health` reports `Blocked`: `AutoFixable` only when + every `PendingStart` attempt currently contributing to the `Blocked` + classification has `owner: Some(owner)` with `is_definitely_dead(owner)` + true; `ManualOnly` otherwise (no recorded owner, a live owner, or + unprovable liveness). Add `repair_blocked(git_dir, repository_root, seam) -> Result`: + acquire the OpenCode `AdapterBoundaryLock`, normalize orphaned recovery + state, then use an `AdapterStateLock` transaction to re-read state fresh + and re-prove the exact dead-owner condition `assess_repairability` found + (never trusting the caller's earlier lock-free read). Release the state + lock before driving the attempt through the adapter's real recovery + protocol: the existing `PendingStart` -> `PendingAbandon` persisted + transition, then the same `flush`/`abandon`/`flush` seam sequence + `ToolError` cleanup already uses. Each progress/completion write is its + own state transaction after the seam call; the state lock is never held + across `flush`, `abandon`, or any other seam operation. Remove the + attempt only when that protocol's own success proves it valid. A losing + re-proof (owner now alive/unknown, or the attempt already resolved) is a + safe no-op, not an error. Out — Claude, Codex, Pi; any change to + OpenCode's + existing `ToolError`/generation-tracked recovery machinery for + non-`PendingStart` shapes; any change to `classify_health`'s existing + four status boundaries (`Blocked` stays `Blocked` either way — + repairability is an additional fact, not a fifth status). + - Dependencies: T01, T02 + - Done when: a state file predating this change (no `owner` field) still + parses, and its `PendingStart` `Blocked` shape classifies `ManualOnly`; + a `PendingStart` attempt with a positively dead owner classifies + `AutoFixable` and `repair_blocked` clears it end to end (final + `classify_health` reports `Healthy` or `Recovering`, never `Blocked`); + a `PendingStart` attempt with a live or unprovable owner classifies + `ManualOnly` and `repair_blocked` is a safe no-op; a test simulates a + concurrent live hook process rewriting state between + `assess_repairability`'s read and `repair_blocked`'s lock acquisition, + proving the fresh state-transaction re-proof refuses to abandon the + now-different state; a test interrupts `repair_blocked` after the + `PendingAbandon` persist but before the seam call resolves, and proves + the next `repair_blocked` (or the ordinary recovery path) safely + continues from `PendingAbandon` without state deletion or attempt + duplication. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml opencode_mutation_scope` + - Completed: 2026-09-21 + - Files changed: `cli/src/services/hooks/opencode_mutation_scope/state.rs` + (imports `current_process_owner`/`is_definitely_dead`/`ProcessOwner` from + the shared `mutation_scope_owner` module; adds `owner: + Option` to `AdapterAttempt` with `#[serde(default)]`; + stamps it in `allocate_pending_start` via `current_process_owner()`; + factors `begin_terminal_cleanup`'s tail into a shared + `transition_to_pending_abandon_and_arm_flush` helper; adds + `reprove_dead_owner_pending_start_and_begin_repair`, a single + lock-protected read-reprove-transition, and the `#[cfg(test)]` + `set_attempt_owner_for_tests` helper); + `cli/src/services/hooks/opencode_mutation_scope/health.rs` (adds the + local `Repairability { AutoFixable, ManualOnly }` enum and + `assess_repairability`; adds ten new regression tests covering legacy + no-owner deserialization, live/dead/mixed-owner assessment, end-to-end + repair, the live-owner no-op, the concurrent-race re-proof refusal, the + all-or-nothing rejection when a sibling `PendingStart` attempt's owner + goes stale between the unlocked assessment and the locked re-proof, and + the interrupted-repair resume via the ordinary recovery path; fixes the + pre-existing `matrix_attempt` literal to set `owner: None`); + `cli/src/services/hooks/opencode_mutation_scope/lifecycle.rs` (adds + `RepairOutcome { Repaired, NoOp }` and `repair_blocked`, which runs + inside the existing `with_boundary_lock` helper); + `cli/src/services/hooks/opencode_mutation_scope/mod.rs` (re-exports + `assess_repairability`, `Repairability`, `repair_blocked`, + `RepairOutcome`, each `#[allow(unused_imports)]` pending T05's doctor + wiring, matching the existing `pi_mutation_scope/mod.rs` precedent for + not-yet-consumed exports). + - Result: `AdapterAttempt` now carries optional, backward-compatible + owner evidence stamped at `PendingStart` allocation. `assess_repairability` + classifies a `Blocked` adapter `AutoFixable` only when every currently + `PendingStart` attempt has a recorded owner positively proven dead by the + shared `is_definitely_dead`; a legacy file with no `owner` field, a live + owner, or an unprovable owner all stay `ManualOnly`. `repair_blocked` + acquires the `AdapterBoundaryLock`, normalizes orphaned recovery, then + performs one lock-protected read-reprove-transition + (`reprove_dead_owner_pending_start_and_begin_repair`) that re-evaluates + liveness fresh against the current state rather than trusting any + earlier read. This re-proof is all-or-nothing over the adapter's + complete current `PendingStart` set, not a per-attempt filter: it + collects every attempt currently `PendingStart` and requires every one + of them to have a recorded owner positively proven dead. If any current + blocker is live, unknown, or ownerless, `repair_blocked` returns `NoOp`, + no attempt is transitioned, recovery is untouched, and no seam call + occurs. Only when every current blocker is positively dead do all + current `PendingStart` attempts transition together to `PendingAbandon`, + arming the existing recovery pipeline; `repair_blocked` then releases + the state lock and drives the unmodified `flush`/`abandon`/`flush` seam + sequence via the existing `resolve_recovery`, so the state lock is never + held across a seam call and no new seam behavior was introduced. A + losing re-proof (owner no longer `PendingStart` by the time the lock is + acquired, or a sibling attempt's owner is no longer provably dead) makes + `repair_blocked` a safe no-op with no seam call and no write — proven by + a new regression test, + `repair_blocked_is_all_or_nothing_when_auto_fixable_assessment_becomes_stale`, + that seeds two independently dead-owner `PendingStart` attempts + (doctor's initial unlocked `assess_repairability` read reports + `AutoFixable`), then rewrites only the second attempt's persisted owner + to a live owner before calling `repair_blocked`, and asserts the fresh + re-proof rejects the whole batch: `RepairOutcome::NoOp`, both attempts + still `PendingStart`, recovery still `Clear`, and zero seam calls — so + the first attempt's still-dead owner is never enough on its own once any + other current blocker fails the all-dead proof. A seam failure + mid-repair leaves the existing `PendingAbandon`/`Pending` recovery + state, which the adapter's pre-existing ordinary recovery path (any + later tracked admission) resumes and completes without duplicating or + resurrecting the attempt — proven by a new regression test that + interrupts `repair_blocked` on a failing `abandon` seam call and then + drives an unrelated `ToolExecuteBefore` to completion. + - Deviation: `repair_blocked`'s signature is `repair_blocked(git_dir, + repository_root, logger, seam) -> Result`, adding a + `logger: Option<&dyn Logger>` parameter beyond the plan's literal + `repair_blocked(git_dir, repository_root, seam)`, matching every other + seam-driving function in this module (`resolve_recovery`, + `abandon_and_consume`, `establish_tracked_start`) which already thread a + logger through for fail-closed observability; `repair_blocked` calls + `resolve_recovery` directly and needed the same parameter. `Repairability` + and `RepairOutcome` are defined locally in this module (in `health.rs` + and `lifecycle.rs` respectively) rather than in the shared + `mutation_scope_health.rs`, since T05 (dependent on this task) is the + task explicitly scoped to add "the shared `Repairability` enum" there; + this task's own in-scope file list does not include + `mutation_scope_health.rs`. + - Verify outcomes: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml opencode_mutation_scope` -> `113 passed; 0 failed` (103 pre-existing + 10 new, including the + `repair_blocked_is_all_or_nothing_when_auto_fixable_assessment_becomes_stale` + follow-up regression); `cargo fmt --manifest-path cli/Cargo.toml -- --check` -> clean; `SCE_CLI_PACKAGE_FALLBACK=1 cargo clippy --manifest-path cli/Cargo.toml --all-targets` -> no warnings; `grep -rn "SystemTime\|Instant::now\|\.elapsed()\|modified()" cli/src/services/hooks/opencode_mutation_scope` -> only the pre-existing, unrelated `os_lock.rs` lock-timeout deadline (unchanged by this task), matching AC3's "no staleness use outside unrelated lock-timeout constants." + - Context impact: OpenCode's persisted state file gains a new optional + `owner` field (backward-compatible, `#[serde(default)]`) and three new + `pub(crate)` symbols (`Repairability`, `assess_repairability`, + `repair_blocked`/`RepairOutcome`) that are not yet consumed by doctor — + T05 wires them into `execute_doctor_with_lifecycle_providers`. No + user-visible behavior changed yet (doctor's `--fix` still cannot repair + OpenCode until T05 dispatches to these functions), no existing + `MutationScope*` type or JSON shape changed, and `classify_health`'s + four status boundaries are unchanged. `domain`-scoped: the context docs + this plan's "Context sync" section names for OpenCode + (`context/cli/opencode-mutation-scope-adapter-lifecycle.md` and/or + `context/cli/opencode-mutation-scope-integration.md`) describe the new + owner-evidence field and dead-owner repair path once T05/T06 make the + repair path reachable through `sce doctor --fix`; recording that + dependency here for T05's own context-sync pass rather than updating + those docs prematurely for a repair path doctor cannot yet invoke. No + root context file (`overview.md`/`architecture.md`/`glossary.md`/ + `patterns.md`/`context-map.md`) is implicated by an adapter-internal, + not-yet-wired addition. + - Context synchronization: synced + +- [x] T04: `Claude: persisted terminal-cleanup evidence and safe PendingAbandon repair` (status:done) + - Task ID: T04 + - Scope: In — `cli/src/services/hooks/claude_mutation_scope/{state,mod,health}.rs`. + Add a `PendingAbandon` `AttemptPhase` variant alongside the existing + `PendingStart`/`Active`; change `abandon_attempt` to persist the + attempt's phase as `PendingAbandon` before calling the seam's `abandon` + operation (replacing today's whole-file `mark_recovery_pending()` with + per-attempt durable evidence, or keeping both if the barrier still + needs the file-level flag — decide from the actual barrier logic), so a + failed seam call leaves explicit per-attempt retryable evidence instead + of today's ambiguous state. Audit `cleanup_attempts_matching`'s current + early-return-on-first-failure loop (`abandon_attempt(...)?` inside the + loop stops the whole batch on the first failure, leaving any + later-matched attempt untouched in its pre-cleanup phase rather than + uniformly `PendingAbandon`) and correct it, if the investigation + confirms this is reachable, so every attempt a broad cleanup signal + (`Stop`/`SessionEnd`/`UserPromptSubmit`/`SubagentStop`) decides to + abandon is durably marked `PendingAbandon` before any seam call is + attempted. Add `assess_repairability`/`repair_blocked` mirroring T03's + shape: `AutoFixable` only when every attempt contributing to the + `Blocked` classification is `PendingAbandon` (no `PendingStart`/`Active` + attempt present — those have no established abandon intent and stay + `ManualOnly`). Claude's `repair_blocked` re-reads state and re-proves + every present attempt is `PendingAbandon` in an individual state-lock + transaction, releases that lock before each already-established seam + `abandon` call, and uses later state transactions to remove only + successfully abandoned attempts and clear the recovery barrier only once + no attempts remain. It must never hold the state lock across mutation- + scope ingress or seam calls, and this cleanup pass must not add a Claude + boundary lock. If the investigation finds a currently-reachable shape where a + `PendingStart`/`Active` attempt coexists with no established abandon + intent and genuinely needs dead-owner evidence to become fixable, reuse + T02's shared primitive for it and record the finding; otherwise record + that no such shape is reachable and do not add unused machinery. Out — + OpenCode, Codex, Pi; Claude's `PreToolUse`/`PostToolUse`/`establish_start` + happy-path logic. + - Dependencies: T01 + - Done when: the existing + `stale_non_empty_attempts_after_a_failed_abandon_stays_blocked_across_repeated_pre_tool_use_ac3` + regression is preserved (still `Blocked` before repair) and extended: + after `repair_blocked`, the same scenario resolves to + `Healthy`/`Recovering`; a crash simulated between the `PendingAbandon` + persist and the seam call resolving leaves state that a second + `repair_blocked` safely completes without resurrecting or duplicating + the attempt; a `PendingStart`/`Active` attempt with no established + abandon intent is proven `ManualOnly` and untouched by + `repair_blocked`; a legacy state file persisted before this change + (no `PendingAbandon` variant ever written) remains readable and, if + `Blocked`, stays `ManualOnly` until a new abandonment establishes real + `PendingAbandon` evidence. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` + - Completed: 2026-09-21 + - Files changed: `cli/src/services/hooks/claude_mutation_scope/state.rs` (adds + `AttemptPhase::PendingAbandon`; adds a pure `transition_to_pending_abandon` + helper plus a durable `mark_recovery_pending_and_pending_abandon(git_dir, + scope_ids)` state-lock transaction that, in one read-modify-write, sets + `recovery_pending = true` and transitions every named scope_id to + `PendingAbandon` — replacing the original two-write + `mark_recovery_pending()` + `mark_pending_abandon()` sequence so there is + no crash boundary between the barrier and the abandonment evidence; makes + `mark_active` re-read the persisted phase under the state lock and match + on it: `PendingStart -> Active` (allowed), `Active -> Active` (safe + idempotent no-op), `PendingAbandon -> Active` (rejected with an error and + no state mutation), so an established abandon intent can never be + resurrected by an in-flight start; adds `reprove_pending_abandon(git_dir)`, + a lock-protected read-only re-proof returning the full current attempt + list only when `recovery_pending` is true, attempts are non-empty, and + every attempt is already `PendingAbandon`; adds the `#[cfg(test)]` + `set_attempt_phase_for_tests` helper); + `cli/src/services/hooks/claude_mutation_scope/lifecycle.rs` (factors + `abandon_attempt`'s seam-call-plus-removal tail into a shared + `abandon_marked_attempt` helper; changes both `abandon_attempt` and + `cleanup_attempts_matching` to call the single atomic + `mark_recovery_pending_and_pending_abandon` instead of the old two + separate `mark_recovery_pending()` + `mark_pending_abandon()` calls, so + the batch case still marks every selected attempt in one write before any + seam call, and the state lock is never held across a seam call; rewrites + `cleanup_attempts_matching` to process each stale attempt independently + after that one write — continuing through the whole batch and returning + the first error only after every attempt was attempted, rather than the + previous early-return-on-first-failure loop that left later-matched + attempts completely untouched; adds `RepairOutcome` and `repair_blocked`); + `cli/src/services/hooks/claude_mutation_scope/health.rs` + (adds the local `Repairability { AutoFixable, ManualOnly }` enum and + `assess_repairability`; extends the existing AC3 regression to assert the + durable `PendingAbandon` evidence and a successful `repair_blocked` run; + adds regression tests covering the not-blocked case, a legacy + attempt never marked `PendingAbandon`, a mixed-phase coexistence case + (both `assess_repairability` and `repair_blocked` proven to leave it + fully untouched), the concurrent-race re-proof refusal, the + interrupted-repair resume, the batch-marking fix for + `cleanup_attempts_matching`, the start-vs-cleanup activation race against + an established `PendingAbandon` (`mark_active` losing the race leaves + `AutoFixable` evidence that `repair_blocked` then resolves to `Healthy`), + and a partial-batch `repair_blocked` run where one of two attempts fails + to abandon (the successful one is removed, the failed one stays + `PendingAbandon`, `recovery_pending` stays armed, outcome is `NoOp`)); + `cli/src/services/hooks/claude_mutation_scope/mod.rs` + (re-exports `assess_repairability`, `Repairability`, `repair_blocked`, + `RepairOutcome`, and `cleanup_attempts_matching`, each + `#[allow(unused_imports)]` pending T05's doctor wiring and test-module + access, matching the `pi_mutation_scope`/`opencode_mutation_scope::mod.rs` + precedent). + - Result: Claude's `AttemptPhase` gains a `PendingAbandon` variant recording + that abandonment has already been decided for an attempt, durably + persisted before any seam `abandon` call — replacing the prior ambiguity + where a failed abandon left an attempt's phase untouched (`PendingStart` + or `Active`) with no evidence distinguishing "abandonment decided, + awaiting retry" from "may still be running." Investigation of the actual + barrier logic (`apply_recovery_barrier` reads `state.recovery_pending` + directly) confirmed the whole-file flag is still structurally required, + so it is kept alongside the new per-attempt phase rather than replaced, + per the plan's own "decide from the actual barrier logic" instruction. + The barrier flag and the per-attempt evidence are established as one + durable state transition, not two: `recovery_pending = true` and every + named attempt's transition to `PendingAbandon` are read, updated, and + written inside a single `AdapterStateLock` acquisition + (`mark_recovery_pending_and_pending_abandon`), so there is no crash + boundary at which the barrier could be armed with the corresponding + `PendingAbandon` evidence not yet persisted (or vice versa). An initial + version of this task left `mark_recovery_pending()` and + `mark_pending_abandon()` as two separate durable writes; that was + corrected because a crash between them could leave `recovery_pending = + true` with an attempt still `PendingStart`/`Active` — the exact ambiguity + `assess_repairability` must treat as `ManualOnly` even though cleanup had + already durably decided to abandon. Separately, `mark_active` is now + monotonic with respect to `PendingAbandon`: it re-reads the persisted + phase under the state lock and only allows `PendingStart -> Active` + (`Active -> Active` is a safe idempotent no-op); `PendingAbandon -> + Active` is rejected with an error and no state mutation, so a start seam + that was already in flight when cleanup established abandonment can never + resurrect the attempt to `Active` after the fact. `establish_start` is + unchanged beyond this: it still calls the seam and then `mark_active` + with no new Claude boundary lock, so losing this race fails PreToolUse + closed (per existing fail-closed behavior) while the durable + `PendingAbandon`/`recovery_pending` evidence set by cleanup is left + intact and retryable. + `cleanup_attempts_matching`'s early-return-on-first-failure loop was + confirmed reachable (`SessionEnd` matches every attempt in a session + regardless of `agent_id`, and `WorktreeRemove` matches every tracked + attempt unconditionally, so either can legitimately match more than one + attempt at once) and corrected: every matched attempt is now durably + marked `PendingAbandon` in one write before any seam call is attempted, + so a failure abandoning one attempt can never leave a sibling attempt in + the batch without its own retryable evidence. + `assess_repairability` classifies `AutoFixable` only when every attempt + currently in the adapter's state is `PendingAbandon`; any attempt still + `PendingStart`/`Active` (no established abandon intent) forces + `ManualOnly` for the whole adapter, proven by a test that also asserts + `repair_blocked` leaves both attempts in such a mixed state completely + untouched. `repair_blocked` re-reads and re-proves this same "every + attempt is `PendingAbandon`" condition inside one lock-protected, + read-only state transaction (`reprove_pending_abandon`) — Claude's + `PendingAbandon` transition already happened durably before repair ever + runs, so unlike OpenCode's dead-owner proof there is no transition to + perform at this step, only a fresh re-proof — then releases the lock + before retrying each attempt's already-established seam `abandon` call + independently, removing only the ones that succeed, and clears the + `recovery_pending` barrier in a final state transaction only once no + attempts remain. A losing re-proof (an attempt no longer `PendingAbandon` + by the time the lock is acquired) is a safe no-op with no seam call, + proven by a test that forces exactly that race via the new + `set_attempt_phase_for_tests` helper. Investigation finding for the + plan's conditional owner-evidence question: no currently-reachable Claude + shape needs dead-owner liveness proof. Because `assess_repairability` + requires every attempt in the adapter's state to be `PendingAbandon` + (not just the ones a particular cleanup decided to abandon), any + coexisting `PendingStart`/`Active` attempt with no established abandon + intent is already, structurally, excluded from `AutoFixable` — proven + directly by `assess_repairability_is_manual_only_when_one_of_several_attempts_has_no_established_abandon_intent`. + T02's shared `mutation_scope_owner` primitive is therefore not consumed + by this task; no unused machinery was added for it. + - Verify outcomes: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` -> `131 passed; 0 failed` (117 baseline + 8 regressions from the initial pass + 6 regressions from the atomic-persistence and monotonic-`mark_active` correction: `mark_recovery_pending_and_pending_abandon_establishes_both_facts_in_one_durable_write`, `mark_recovery_pending_and_pending_abandon_failed_write_leaves_no_partial_invariant`, `mark_active_is_idempotent_when_already_active`, `mark_active_is_forbidden_once_pending_abandon_is_established`, `mark_active_losing_the_race_against_an_established_pending_abandon_leaves_repairable_terminal_evidence`, and `repair_blocked_removes_only_successfully_abandoned_attempts_and_keeps_recovery_pending_when_one_fails`); `nix develop -c ./scripts/run-cli-cargo.sh fmt --manifest-path cli/Cargo.toml -- --check` -> clean; `SCE_CLI_PACKAGE_FALLBACK=1 nix develop -c ./scripts/run-cli-cargo.sh clippy --manifest-path cli/Cargo.toml --all-targets` -> no warnings; `grep -rn "SystemTime\|Instant::now\|\.elapsed()\|modified()" cli/src/services/hooks/claude_mutation_scope` -> only the pre-existing, unrelated `AdapterStateLock` lock-timeout deadline (unchanged by this task), matching AC3's "no staleness use outside unrelated lock-timeout constants." + - Context impact: Claude's persisted state file gains a new reachable + `AttemptPhase` variant (`pending_abandon`) and three new `pub(crate)` + symbols (`Repairability`, `assess_repairability`, `repair_blocked`/ + `RepairOutcome`) not yet consumed by doctor — T05 wires them into + `execute_doctor_with_lifecycle_providers`, matching T03's OpenCode + precedent exactly. No user-visible behavior changed yet (`sce doctor + --fix` still cannot repair Claude until T05 dispatches to these + functions), no existing `MutationScope*` type or JSON shape changed, and + `classify_health`'s existing four status boundaries and their triggering + conditions are unchanged (confirmed by every pre-existing health test + passing unmodified). `domain`-scoped: `context/cli/claude-mutation-scope-integration.md` + (named in this plan's own "Context sync" section) describes the new + `PendingAbandon` phase and its safety semantics once T05/T06 make the + repair path reachable through `sce doctor --fix`; recording that + dependency here for T05's own context-sync pass rather than updating + that doc prematurely for a repair path doctor cannot yet invoke, mirroring + T03's identical deferral for OpenCode's context docs. No root context file + (`overview.md`/`architecture.md`/`glossary.md`/`patterns.md`/ + `context-map.md`) is implicated by an adapter-internal, not-yet-wired + addition. + - Deviation: `lifecycle.rs` was touched even though this task's own scope + line names only `{state,mod,health}.rs`, because `abandon_attempt` and + `cleanup_attempts_matching` — both explicitly named as needing behavior + changes in this task's own scope text — live in `lifecycle.rs`, not + `health.rs` or `state.rs`; T03's OpenCode task explicitly listed + `lifecycle.rs` for the equivalent change, so this mirrors that precedent + rather than expanding scope. `Repairability` and `RepairOutcome` are + defined locally in this module (`health.rs` and `lifecycle.rs` + respectively) rather than in the shared `mutation_scope_health.rs`, + matching T03's identical deviation and reasoning: T05 is the task scoped + to add the shared `Repairability` enum there. + - Correction (2026-09-21): the initial T04 pass left two crash/concurrency + gaps, both closed narrowly without touching T05 scope, OpenCode, Pi, + Codex, doctor orchestration, rendering, or the Quint model. First, + `recovery_pending = true` and the initial `PendingAbandon` transition + were two separate durable transactions (`mark_recovery_pending()` then + `mark_pending_abandon()`); a crash between them could leave the barrier + armed with an attempt still `PendingStart`/`Active`, which + `assess_repairability` correctly treats as `ManualOnly` even though + cleanup had already durably decided to abandon. Fixed by replacing both + call sites (`abandon_attempt`, `cleanup_attempts_matching`) with one + state-layer operation, `mark_recovery_pending_and_pending_abandon`, that + performs the read, both field updates, and the durable write inside a + single `AdapterStateLock` acquisition — the batch case still marks every + selected attempt in that one write before any seam call, and the lock is + still never held across a seam call. Second, `mark_active` unconditionally + set `attempt.phase = Active`, so an in-flight `PreToolUse` start whose + seam call had already succeeded could overwrite an already-established + `PendingAbandon` back to `Active` if cleanup won the race first. Fixed by + making `mark_active` re-read the persisted phase under the state lock and + branch on it: `PendingStart -> Active` (allowed), `Active -> Active` + (safe idempotent no-op), `PendingAbandon -> Active` (rejected with an + error and no state mutation). `establish_start` needed no change — the + existing `seam(start)?; mark_active(...)?;` sequence now fails closed on + the error instead of resurrecting the attempt, per existing PreToolUse + fail-closed behavior, and no new Claude boundary lock was added. New + regressions: two state-level tests proving the atomic operation + establishes both facts in one write and that an injected pre-rename write + failure leaves neither fact persisted; two state-level tests proving + `mark_active`'s three-way branch; a lifecycle-level race regression + (`mark_active_losing_the_race_against_an_established_pending_abandon_leaves_repairable_terminal_evidence`) + proving that losing the activation race leaves `recovery_pending == true` + and the attempt `PendingAbandon`, that `classify_health` is `Blocked` and + `assess_repairability` is `AutoFixable` (not `ManualOnly`), and that + `repair_blocked` then resolves the whole state to `Healthy`; and a + partial-batch `repair_blocked` regression proving a successful abandon in + a batch is removed while a failed sibling stays `PendingAbandon` with + `recovery_pending` still armed. All prior wording in this entry + describing `mark_recovery_pending()` followed by `mark_pending_abandon()` + as the final implementation has been corrected above; that two-write + sequence is no longer present in the codebase. + - Correction (2026-09-21, second): `classify_health` disagreed with T01's + formal invariant `RecoveryNeverClearedWithUnresolvedAbandon` and with + `doctor_recovery.qnt`'s `adapterHealth` priority order + (`recovery == Clear and hasPendingAbandon -> Invalid`, checked before + every other classification). The Rust classifier instead returned + `Healthy` for any `recovery_pending == false` state without checking + whether a persisted attempt was still `PendingAbandon` — a structurally + impossible/corrupt shape (terminal cleanup already durably decided while + the barrier reads clear) was silently reported healthy instead of + surfaced as `Invalid`. Fixed by adding a `has_pending_abandon` check + (`state.attempts.iter().any(|attempt| attempt.phase == + AttemptPhase::PendingAbandon)`) ahead of the existing + `!state.recovery_pending -> Healthy` branch in + `claude_mutation_scope::health::classify_health`: `!recovery_pending && + has_pending_abandon` now returns `Invalid` first, matching the Quint + model's priority order exactly. `assess_repairability` needed no change + — its existing `!state.recovery_pending -> ManualOnly` short-circuit + already classified this shape `ManualOnly` before this correction, so + the impossible state was already un-auto-fixable; only the health + classification itself was wrong. Ordinary `PendingStart`/`Active` + attempts with `recovery_pending == false` are unaffected + (`has_pending_abandon` is false for them), and no Claude boundary lock + or owner-liveness primitive was added. New regressions in + `claude_mutation_scope::health::tests`: `clear_recovery_with_pending_abandon_is_invalid` + (a hand-constructed `recovery_pending: false` state with one + `PendingAbandon` attempt classifies `Invalid` and `assess_repairability` + reports `ManualOnly`) and + `invalid_takes_priority_over_blocked_in_mixed_impossible_state` (a + second, hand-constructed `PendingStart` attempt coexisting in the same + `recovery_pending: false` state still classifies `Invalid`, not + `Blocked`), mirroring the Quint model's + `testPendingAbandonWithClearRecoveryIsInvalid`/ + `testInvalidTakesPriorityOverBlockedWhenBothConditionsHold`. No T05/T06 + scope, OpenCode, Pi, Codex, doctor orchestration/rendering, or the Quint + model itself was touched by this correction. + - Context synchronization: synced + +- [x] T05: `Shared repairability contract and doctor --fix orchestration` (status:done) + - Task ID: T05 + - Scope: In — `cli/src/services/hooks/mutation_scope_health.rs` (add the + shared `Repairability` enum, kept separate from `MutationScopeHealthStatus`); + `cli/src/services/doctor/{mod,inspect}.rs`. Add a new step to + `execute_doctor_with_lifecycle_providers`, positioned after + `fix_lifecycle_providers`/`repair_merge_target_configs` and before the + final `diagnose_lifecycle_providers`/`build_report_with_lifecycle_problems` + call, preserving the existing initial-diagnosis -> existing-repairs -> + final-diagnosis -> manual-results flow: for each `Blocked` row the + *initial* report found, call the matching adapter's + `assess_repairability`; when `AutoFixable`, call that adapter's + `repair_blocked` and record a `DoctorFixResultRecord`. Doctor only + dispatches these adapter-owned operations; it does not acquire or hold + either adapter's state lock, and it does not hold any state lock across a + seam call. OpenCode owns its larger lifecycle serialization through + `AdapterBoundaryLock`; Claude's adapter owns its per-transaction state + locking and `PendingAbandon` proof without a new boundary lock. The final + report's already-existing `inspect_mutation_scope_health` call (which + recomputes `classify_health` from scratch) is the sole postcondition: a + repair function returning `Ok(())` is never itself treated as proof of + success — only the freshly recomputed final status decides whether the + fix result becomes `Fixed` (final `Healthy`/`Recovering`) or falls + through to the existing generic manual/unresolved handling (final still + `Blocked`/`Invalid`, which must never be reported `Fixed`). Codex/Pi + targets take no path through this new step, since they never classify + `Blocked`. Out — Claude/OpenCode adapter internals (owned by T03/T04); + human text/JSON rendering (T06). + - Dependencies: T01, T03, T04 + - Done when: a repository seeded with an `AutoFixable` `Blocked` + OpenCode or Claude state, run through `sce doctor --fix`, ends with a + `Fixed` fix result and a final report that is never `Blocked` while + reporting `Fixed`; a repository seeded with a `ManualOnly` `Blocked`/ + `Invalid` state is untouched by the new step and still receives the + existing manual-result handling; a repository with no mutation-scope + problems runs the new step as a no-op with no behavior change from + today. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor` + - Completed: 2026-09-21 + - Files changed: `cli/src/services/hooks/mutation_scope_health.rs` (adds the + shared `pub(crate) enum Repairability { AutoFixable, ManualOnly }`, + kept separate from `MutationScopeHealthStatus`, plus a + `repairability_variants_are_distinct` regression test); + `cli/src/services/doctor/inspect.rs` (adds + `repair_blocked_mutation_scope_targets` — the new `pub(super)` dispatch + entry point — plus its private helpers + `repair_blocked_mutation_scope_targets_with_seam`, + `mutation_scope_repair_seam` (the real production seam, + `hooks::mutation_scope::run_mutation_scope_from_payload`), + `repair_blocked_mutation_scope_target`, `claude_repairability`, + `opencode_repairability`, and `fixed_record_from_recomputed_health`; + imports `Repairability`, `mutation_scope`, and `Logger`; adds six new + regression tests and the `claude_autofixable_blocked_state`/ + `init_git_repo_with_opencode_target`/`opencode_dead_owner`/ + `no_op_repair_seam` test fixtures); + `cli/src/services/doctor/mod.rs` (wires + `repair_blocked_mutation_scope_targets(&initial_report)` into + `execute_doctor_with_lifecycle_providers`, positioned exactly after + `repair_merge_target_configs` and before the final + `diagnose_lifecycle_providers` call). + - Result: `sce doctor --fix` now repairs a `Blocked` Claude or OpenCode + Agent-tracing state whenever the owning adapter's own + `assess_repairability` proves it `AutoFixable`, by calling that + adapter's own `repair_blocked` through the real production seam + (`hooks::mutation_scope::run_mutation_scope_from_payload` — the same + seam Claude's and OpenCode's own hook entry points use), then + re-reading a fresh `classify_health` immediately afterward: only when + that fresh read is `Healthy`/`Recovering` does the dispatch record a + `Fixed` `DoctorFixResultRecord`; a `repair_blocked` call that returns + `Ok(())` but leaves the target `Blocked`/`Invalid` (a losing race, a + seam failure, or any other reason) produces no record at all, and the + existing generic `build_manual_fix_results` pass over the *final* + recomputed report — unchanged by this task — reports it `Manual` + instead, so `Fixed` and a still-`Blocked` final report can never + coincide (AC6). A `ManualOnly` target (no owner evidence, a live/unknown + owner, or a mixed-phase Claude state) is never passed to `repair_blocked` + at all — `claude_repairability`/`opencode_repairability` short-circuit + first — leaving the state file byte-for-byte untouched and routing + through the pre-existing manual-remediation path exactly as before this + task (AC2 unaffected; T06 still owns its wording). Doctor itself never + acquires or holds either adapter's state lock, never holds a lock across + a seam call, and never calls `remove_attempt()` or rewrites JSON — it + only calls each adapter's own `assess_repairability`/`repair_blocked`/ + `classify_health`, matching this plan's constraints and T01's formal + model. Codex and Pi rows are matched to `None` in + `repair_blocked_mutation_scope_target` and never dispatched further, + since neither adapter can currently classify `Blocked` (per T01's + assumptions). + - Deviation: the seam is threaded through + `repair_blocked_mutation_scope_targets_with_seam`/ + `repair_blocked_mutation_scope_target` as an explicit parameter + (`MutationScopeRepairSeam<'a> = &'a dyn Fn(&Path, &str, Option<&dyn + Logger>) -> anyhow::Result`) rather than being hardcoded inline, + mirroring the seam-injection pattern every adapter's own lifecycle + module already uses (e.g. `claude_mutation_scope`'s + `run_claude_mutation_scope_from_payload_with_resolver`). This was not + load-bearing for production behavior (the public + `repair_blocked_mutation_scope_targets` entry point always uses the one + real seam, `mutation_scope_repair_seam`) but was necessary for reliable + testing: an initial version of the regression tests drove the real + production seam end-to-end (a real git repo, a real per-repository + Agent Trace DB) and was flaky under the full `doctor` test binary — it + passed when run alone but intermittently reported `Manual` instead of + `Fixed` when preceded by other tests in the same process, traced to the + credential-store-backed Agent Trace DB encryption key + (`cli/src/services/db/encryption_key.rs`'s process-global + `DEFAULT_STORE`/keyring-store registration) not reliably surviving a + second real, encrypted per-repository database being created in the + same test process inside this sandboxed environment — a pre-existing + test-infrastructure characteristic unrelated to this task's logic and + out of its scope to fix. Threading the seam as a parameter let the + regression tests inject the same kind of no-op fake seam every other + hook-level test in this codebase already uses (matching + `claude_mutation_scope`/`opencode_mutation_scope`'s own `repair_blocked` + test suites), making the new tests deterministic while still exercising + every line of this task's own new dispatch code — the underlying + adapter `repair_blocked` behavior against the real seam remains proven + by T03/T04's own extensive test suites, which this task does not + duplicate. + - Verify outcomes: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor` -> `49 passed; 0 failed` (six new: `repair_blocked_mutation_scope_target_repairs_an_autofixable_claude_state`, `repair_blocked_mutation_scope_target_never_touches_a_manual_only_claude_state`, `repair_blocked_mutation_scope_target_repairs_an_autofixable_opencode_state`, `full_report_fix_mode_leaves_a_manual_only_claude_blocked_state_untouched`, `full_report_fix_mode_has_no_mutation_scope_effect_when_nothing_is_blocked`, plus `mutation_scope_health.rs`'s `repairability_variants_are_distinct`), re-run three times consecutively with no flakes; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` -> `136 passed; 0 failed`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml opencode_mutation_scope` -> `113 passed; 0 failed`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_scope_health` -> `10 passed; 0 failed`; `cargo fmt --manifest-path cli/Cargo.toml -- --check` -> clean; `SCE_CLI_PACKAGE_FALLBACK=1 cargo clippy --manifest-path cli/Cargo.toml --all-targets` -> no warnings; `grep -rn "SystemTime\|Instant::now\|\.elapsed()\|modified()" cli/src/services/hooks/claude_mutation_scope cli/src/services/hooks/opencode_mutation_scope cli/src/services/hooks/mutation_scope_owner.rs` -> only the pre-existing, unrelated `AdapterStateLock`/`AdapterBoundaryLock`/`os_lock` timeout deadlines and the static no-TTL-token source scan's own token list, matching AC3's "no staleness use outside unrelated lock-timeout constants" (this task introduced no new staleness heuristic). + - Context impact: `sce doctor --fix` gains real, user-visible repair + behavior for a `Blocked` Claude/OpenCode Agent-tracing state for the + first time (previously `--fix` could never touch this category at all). + The `mutation_scope_health` JSON array's shape and status strings, and + every `MutationScope*` Rust type name, are unchanged (AC7 — this task + added no new field to that array and renamed nothing). `DoctorProblem`'s + `fixability`/`remediation` text for a `Blocked` row is *not yet* dynamic + per this task (still the pre-existing static `ManualOnly` wording from + `push_mutation_scope_health_problem`, untouched by this task) — a + `Blocked` row that this task's new step successfully repairs still shows + the old wording on the *initial* diagnosis before `--fix` runs, and + `sce doctor --fix`'s human/JSON fix-result line for a repaired target + goes through the existing generic `[{outcome}] {detail}` formatter with + a `Recovered Agent tracing (now : ).` detail + string this task produces — not yet the `[fixed] Recovered + Agent tracing ...` wording T06's own Done-when names, since + `DoctorDisplayDetail::MutationScopeHealth` rendering is unchanged here. + `context/sce/mutation-scope-health-status.md`, + `context/sce/agent-trace-hook-doctor.md`, and + `context/sce/doctor-human-text-contract.md` are all named in this plan's + own "Context sync" section as needing the new repairability facet + described once the repair path exists — deferring that write to this + task's own context-synchronization pass (next), not T06, since this is + the task that actually makes `--fix` capable of the repair, per this + plan's own instruction to record docs against the task that makes a + behavior reachable. + - Correction (2026-09-21): the original dispatch violated AC6 and this + task's own "final report is the sole postcondition" requirement. + `repair_blocked_mutation_scope_target` called each adapter's + `repair_blocked`, then immediately called that same adapter's + `classify_health` again right there and built a `Fixed` + `DoctorFixResultRecord` from that *intermediate* read — a read taken + before `execute_doctor_with_lifecycle_providers` goes on to build the + actual final `HookDoctorReport`. A concurrent hook process mutating + persisted state between that intermediate read and the final diagnosis + could produce `fix_results: [Fixed]` alongside a final report that is + still `Blocked`, which AC6 and `doctor_recovery.qnt`'s + `ReportedFixedExcludesBlockedOrInvalid` invariant both forbid. Fixed by + separating "perform the repair" from "decide whether it counts as + `Fixed`": `repair_blocked_mutation_scope_target`/ + `repair_blocked_mutation_scope_targets(_with_seam)` now return which + targets doctor actually attempted a repair for + (`Option`/`Vec`), with no + `classify_health` call and no `DoctorFixResultRecord` construction of + their own. A new `finalize_mutation_scope_repair_results(repaired_targets: + &[IntegrationTarget], final_mutation_scope_health: &[MutationScopeHealthRow]) + -> Vec` is the only place a mutation-scope + `Fixed` record is now produced: it looks up each repaired target's row + in the already-built final report's `mutation_scope_health` (never + recomputing health itself) and reports `Fixed` only for + `Healthy`/`Recovering`; `Blocked`/`Invalid`, or a target missing from + the final row set entirely, produce no record and fall through to the + existing, unchanged `build_manual_fix_results` pass over the final + report. `execute_doctor_with_lifecycle_providers` in + `cli/src/services/doctor/mod.rs` now calls + `repair_blocked_mutation_scope_targets(&initial_report)` to get the + repaired-target list, builds the final report exactly as before, then + calls `finalize_mutation_scope_repair_results(&mutation_scope_repairs, + &final_report.mutation_scope_health)` before `build_manual_fix_results` + — preserving the plan's initial-diagnosis -> existing-repairs -> + repair-attempt -> final-diagnosis -> derive-fix-result-from-final-report + flow exactly, with the derivation step now strictly after the final + report exists rather than interleaved with the repair step. `fixed_record_from_recomputed_health` + now takes a `&MutationScopeHealthRow` (the final report's own row shape) + instead of a freshly computed `&MutationScopeAdapterHealth`, so there is + no code path left that can authorize `Fixed` from anything but the final + report. New regression + `finalize_mutation_scope_repair_results_ignores_an_immediate_post_repair_read_that_the_final_report_contradicts` + in `cli/src/services/doctor/inspect.rs` drives a real `repair_blocked` + call to a genuine immediate `Healthy`/`Recovering` result, then + overwrites the persisted Claude state back to `Blocked` (simulating a + concurrent hook process) before computing the final + `mutation_scope_health` row and calling `finalize_mutation_scope_repair_results` + — asserting both that the final row is `Blocked` and that no `Fixed` + `MutationScopeHealth` record is produced, proving the race AC6 requires + is now impossible. The three existing dispatch-level tests + (`repair_blocked_mutation_scope_target_repairs_an_autofixable_claude_state`, + `repair_blocked_mutation_scope_target_never_touches_a_manual_only_claude_state`, + `repair_blocked_mutation_scope_target_repairs_an_autofixable_opencode_state`) + were updated for the new `Option` return shape; the + two `AutoFixable` tests now additionally call + `finalize_mutation_scope_repair_results` against a freshly recomputed + final row set to prove the end-to-end `Fixed` path still works when the + final report agrees. No T06/T07 functionality, OpenCode/Claude adapter + internals, or human/JSON rendering were touched. + - Verify outcomes (corrected): `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor` -> `50 passed; 0 failed` (the five surviving T05 regressions plus the new `finalize_mutation_scope_repair_results_ignores_an_immediate_post_repair_read_that_the_final_report_contradicts`); `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` -> `138 passed; 0 failed` (includes the two new T04-correction Invalid-classification regressions); `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml opencode_mutation_scope` -> `113 passed; 0 failed`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_scope_health` -> `10 passed; 0 failed`; `cargo fmt --manifest-path cli/Cargo.toml -- --check` -> clean; `SCE_CLI_PACKAGE_FALLBACK=1 cargo clippy --manifest-path cli/Cargo.toml --all-targets` -> no warnings; `nix run .#quint -- typecheck spec/doctor_recovery.qnt` -> clean; `nix run .#quint -- test spec/doctor_recovery.qnt --match '^test.*'` -> `13 passing` (unchanged — this correction required no Quint model change, confirming the implementation was the thing out of sync with the already-correct formal model, not the other way around). + - Context synchronization: synced + +- [x] T06: `Human and JSON remediation contract for repaired and manual Agent tracing states` (status:done) + - Task ID: T06 + - Scope: In — `cli/src/services/doctor/{render,fixes,types}.rs`. Fix + `build_manual_fix_results` (currently drops `DoctorProblem.remediation` + entirely, rendering only `"{summary} Manual remediation is still + required."`) so a `mutation_scope_health` manual result instead + retains the adapter's own remediation text and the exact state-file + path — e.g. `"Agent tracing remains blocked. Inspect ''. + {remediation}"` — while deciding whether to fix this generically for + every `ManualOnly` category or only for `mutation_scope_health` (state + the choice and why). Extend `DoctorDisplayDetail::MutationScopeHealth` + (today `{reason, detail}` only — the "Agent tracing" tree row renders + no `Remediation:` line at all, unlike the generic + `DoctorDisplayDetail::Problem{summary, remediation}` variant used + elsewhere) to also carry and render a `Remediation:` line sourced from + the matching `DoctorProblem`, so a plain `sce doctor` run states, for + every `Blocked` row, either `Run 'sce doctor --fix' to recover ...` + (when `AutoFixable` — wire the new remediation text + `push_mutation_scope_health_problem` must gain for that fixability) or + the existing `Automatic recovery is not safe for this state. Inspect + ''.` wording (`ManualOnly`, now actually rendered). Confirm the + `[fixed]`/`[manual]` fix-result lines T05 produces render correctly + through the existing generic `[{outcome}] {detail}` formatter (no new + formatter needed). Verify the JSON `problems[]` array already carries + `fixability`/`remediation.{next_action,text}` correctly for the new + `auto_fixable`/`doctor_fix` case, while the `mutation_scope_health[]` + array's shape is untouched. Out — any change to the + `mutation_scope_health` JSON array's field set, the status strings, or + any `MutationScope*`/`mutation_scope_health` naming. + - Dependencies: T05 + - Done when: `sce doctor --format json` and human text for a seeded + `AutoFixable` `Blocked` state both name `sce doctor --fix` explicitly; + a `ManualOnly` `Blocked`/`Invalid` state's human text and JSON both + name the exact adapter state-file path with no deletion suggestion; + `sce doctor --fix` human output shows `[fixed] Recovered + Agent tracing ...` for a resolved repair and `[manual] Agent tracing + remains blocked. Inspect ''.` for an unresolved one. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor` + - Completed: 2026-09-21 + - Files changed: `cli/src/services/doctor/inspect.rs` (adds a `mutation_scope_repairability` + dispatch helper, alongside the existing `claude_repairability`/ + `opencode_repairability`; `push_mutation_scope_health_problem` now calls it for a + `Blocked` row and branches its `fixability`/`next_action`/remediation text on the + result instead of hardcoding `ManualOnly`; the function now returns + `Option` (the remediation text it decided, `None` only for `Healthy`) so + the caller can also stamp it onto the row; `inspect_mutation_scope_health` now + threads that return value into a new `MutationScopeHealthRow.remediation` field; + the `ManualOnly`-Blocked and `Invalid` remediation strings are reworded to lead + with the exact sentence doctor's various surfaces now render verbatim + (`"Agent tracing remains blocked. Inspect ''. ..."` / + `"Agent tracing state could not be safely interpreted. Inspect ''. ..."`); + adds `mutation_scope_health_blocked_autofixable_remediation_names_doctor_fix`, + `mutation_scope_health_manual_only_states_name_the_real_state_path_and_never_suggest_deletion`, + `full_report_autofixable_blocked_names_doctor_fix_in_text_and_json`, and + `full_report_fix_mode_human_text_shows_the_manual_detail_line`; strengthens + `repair_blocked_mutation_scope_target_repairs_an_autofixable_claude_state` with an + assertion on the literal `[fixed]` detail-line contract); + `cli/src/services/doctor/types.rs` (adds `remediation: Option` to + `MutationScopeHealthRow`, an internal Rust field not read by + `mutation_scope_health_json` — the JSON array's own field set is untouched; adds + the same field to `DoctorDisplayDetail::MutationScopeHealth`); + `cli/src/services/doctor/render.rs` (the `MutationScopeHealth` display-detail + render arm renders a `Remediation:` line when the field is `Some`; + `mutation_scope_health_node` threads `row.remediation` through; adds + `blocked_row_renders_remediation_when_present` and + `blocked_row_omits_remediation_line_when_absent`, and a `row_with_remediation` + test helper the pre-existing `row` helper now delegates to); + `cli/src/services/doctor/fixes.rs` (`build_manual_fix_results` now calls a new + `manual_fix_detail` helper: for `ProblemCategory::MutationScopeHealth` it uses + `problem.remediation` verbatim as the fix-result detail instead of the generic + `"{summary} Manual remediation is still required."` wrapper every other + manual-only category still gets). + - Result: A `Blocked` Agent-tracing row's remediation is now computed from the + owning adapter's own `assess_repairability` at diagnosis time, not hardcoded to + `ManualOnly`: `AutoFixable` produces `fixability: auto_fixable` / + `next_action: doctor_fix` and remediation text naming `sce doctor --fix` + explicitly (both in the human `Remediation:` line under "Agent tracing" and in + the JSON `problems[].remediation.text`, since `DoctorProblem` already flowed + into JSON unchanged — only the values it now receives are corrected); + `ManualOnly`/`Invalid` rows keep the existing `fixability: manual_only` / + `next_action: manual_steps` contract, now with wording that leads with the exact + persisted state-file path and contains no `delete`/`deleting`/`deleted` wording + anywhere (AC2's literal contract — see the 2026-09-21 correction below; the text + instead tells the user to preserve the persisted state while reviewing the + adapter's recovery model), and that same text is now actually + reachable through the previously-inert `DoctorDisplayDetail::MutationScopeHealth` + tree-row rendering. `sce doctor --fix`'s `[manual]` fix-result line for a + mutation-scope-health target now reads + `"Agent tracing remains blocked. Inspect ''. ..."` (via + `build_manual_fix_results`'s new category-specific branch for a never-attempted + `ManualOnly` target, and via `finalize_mutation_scope_repair_results` — see the + correction below — for an attempted `AutoFixable` target whose repair did not + resolve it) instead of the generic `"{summary} Manual remediation is still + required."`; the `[fixed]` line (`"Recovered Agent tracing (now + : )."`) is unchanged, since `fixed_record_from_recomputed_health` + (T05) already produced it correctly and this task only added a regression + asserting its literal prefix. The `mutation_scope_health[]` JSON array's field set + (`target`/`status`/`reason`/`detail`) and status strings are untouched — the + new `MutationScopeHealthRow.remediation` field exists only as an internal Rust + struct field that `mutation_scope_health_json` never reads (AC7). + - Deviation: `push_mutation_scope_health_problem` and the new + `mutation_scope_repairability` dispatch helper live in `inspect.rs`, not in this + task's literal `Scope: In` file list (`{render,fixes,types}.rs`), because the + task's own `Done when` text explicitly requires + `push_mutation_scope_health_problem` to "gain" fixability-aware remediation, and + no file in the named list owns that call site — `push_mutation_scope_health_problem` + was already the sole place `Blocked`/`Invalid` remediation text and `fixability` + are decided (T03/T04/T05 established `claude_repairability`/ + `opencode_repairability` there for the same reason). This mirrors T04's own + identical precedent of touching `lifecycle.rs` beyond its literal named scope + when `Done when` required it. `build_manual_fix_results`'s remediation-preserving + fix is scoped to the `mutation_scope_health` category only, not generalized to + every `ManualOnly` category: this plan's acceptance criteria (AC1/AC2) and this + task's own `Done when` name only `mutation_scope_health` wording explicitly, and + every other `ManualOnly` category's existing `"{summary} Manual remediation is + still required."` wording is unrelated to this plan's scope — changing it for + categories this plan never inspected (config/hooks/asset problems) would risk + silently changing established wording contracts those categories' own tests may + depend on, with no acceptance criterion requiring it. + - Verify outcomes: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor` -> `56 passed; 0 failed` (50 pre-existing + 6 new: `blocked_row_renders_remediation_when_present`, `blocked_row_omits_remediation_line_when_absent`, `mutation_scope_health_blocked_autofixable_remediation_names_doctor_fix`, `mutation_scope_health_manual_only_states_name_the_real_state_path_and_never_suggest_deletion`, `full_report_autofixable_blocked_names_doctor_fix_in_text_and_json`, `full_report_fix_mode_human_text_shows_the_manual_detail_line`), re-run three times consecutively with no flakes; `cargo fmt --manifest-path cli/Cargo.toml -- --check` -> clean; `SCE_CLI_PACKAGE_FALLBACK=1 cargo clippy --manifest-path cli/Cargo.toml --all-targets` -> no warnings; `grep -rn "SystemTime\|Instant::now\|\.elapsed()\|modified()" cli/src/services/doctor/render.rs cli/src/services/doctor/fixes.rs cli/src/services/doctor/types.rs cli/src/services/doctor/inspect.rs` -> only a pre-existing, unrelated test-fixture nonce (`unique_temp_repository_root`, unchanged by this task), matching AC3's "no staleness use outside unrelated lock-timeout constants." + - Context impact: `sce doctor` (no `--fix`) now states, for a `Blocked` row, either + the literal `sce doctor --fix` remediation (`AutoFixable`) or the real + state-file path with no deletion suggestion (`ManualOnly`) in both human text and + JSON — previously the JSON `problems[]` carried only the static `ManualOnly` + wording regardless of actual repairability, and the human tree rendered no + remediation for "Agent tracing" at all. `sce doctor --fix`'s `[manual]` + fix-result line for this category changed wording (from the generic "Manual + remediation is still required." wrapper to the adapter's own remediation text + leading with the real path); its `[fixed]` line is unchanged. No + `mutation_scope_health[]` JSON field, status string, or `MutationScope*`/ + `mutation_scope_health` Rust name changed (AC7). This is the task named in the + plan's own "Context sync" section as owning + `context/sce/mutation-scope-health-status.md` (the new dynamic-`fixability` + behavior for a `Blocked` problem record), + `context/sce/agent-trace-hook-doctor.md` (remediation now varies by adapter-owned + repairability within the existing initial-diagnosis -> existing-repairs -> + final-diagnosis flow), and `context/sce/doctor-human-text-contract.md` (the new + `Remediation:` line under "Agent tracing") — those three docs still need this + task's own context-synchronization pass (next), which was deferred here from + T03/T04/T05 exactly as those tasks recorded. `context/cli/claude-mutation-scope-integration.md`, + the OpenCode adapter docs, and `context/cli/pi-mutation-scope-integration.md` + describe adapter-internal repair mechanics this task did not touch (T03/T04 + already own their own sync passes for those); no root context file + (`overview.md`/`architecture.md`/`glossary.md`/`patterns.md`/`context-map.md`) + is implicated by this doctor-rendering-layer change alone. + - Context synchronization: synced + - Correction (2026-09-21): Two plan-contract issues found in this task's initial + implementation were fixed without starting T07 or redesigning mutation-scope + recovery: + 1. AC2 requires that no rendered manual remediation string contain `delete`, but + `push_mutation_scope_health_problem`'s `ManualOnly`-`Blocked` and `Invalid` + remediation text literally said `"Do not delete the state file."` / + `"...rather than deleting it."`, and the regressions + (`mutation_scope_health_reports_blocked_as_an_error_and_flips_readiness_not_ready`, + `mutation_scope_health_manual_only_states_name_the_real_state_path_and_never_suggest_deletion`, + `full_report_blocked_regression_is_consistent_across_surfaces`) asserted those + phrases were present, encoding the wrong behavior. Fixed by rewording both + strings in `cli/src/services/doctor/inspect.rs` to name the exact state path + and tell the user to preserve the persisted state while reviewing the + adapter's recovery model, with no `delete`/`deleting`/`deleted` wording, and by + rewriting the three regressions to assert the literal AC2 contract instead + (`remediation.contains(&state_path.display().to_string())` and + `!remediation.to_ascii_lowercase().contains("delete")`). + 2. `build_manual_fix_results` only covers `ProblemFixability::ManualOnly` + problems, so an `AutoFixable` `Blocked` target whose attempted repair left it + still `Blocked`/`Invalid` (repairability is recomputed fresh from adapter + state at push time, independent of whether a repair was attempted, so it stays + `AutoFixable`) produced neither a `Fixed` nor a `Manual` fix result — it simply + disappeared from `Fix results`. Fixed by broadening + `finalize_mutation_scope_repair_results` (renamed its per-target helper from + `fixed_record_from_recomputed_health` to + `mutation_scope_repair_result_from_final_row`, now returning + `DoctorFixResultRecord` unconditionally instead of + `Option`, since every attempted target now produces + exactly one outcome) so a final `Healthy`/`Recovering` row still produces + `Fixed` (unchanged from T05) while a final `Blocked`/`Invalid` row now produces + `Manual`, reusing `MutationScopeHealthRow.remediation` — the same string + already pushed into the final report's matching `DoctorProblem` — as the + `Manual` detail, so the exact state path and no-`delete` wording stay identical + across the `[manual]` fix-result line and the plain-diagnose `Remediation:` + line. `AutoFixable` fixability itself is untouched (AC6/plain-`doctor` + `fixability: auto_fixable` / `next_action: doctor_fix` still stands for a + not-yet-attempted `Blocked` `AutoFixable` state). At the time this correction + was written, `build_manual_fix_results` filtered only on + `ProblemFixability::ManualOnly` with no target-attempted exclusion, so an + attempted target whose final row was `Blocked`/`Invalid` and whose + recomputed-fresh fixability also happened to be `ManualOnly` (not just + `AutoFixable`) would in fact overlap with `finalize_mutation_scope_repair_results` + and produce two `Manual` results for the same target — see the second + correction below, which fixes that overlap explicitly; it was not actually + absent as first claimed here. Added + `finalize_mutation_scope_repair_results_reports_manual_when_an_attempted_autofixable_repair_stays_blocked` + in `cli/src/services/doctor/inspect.rs`, using a `failing_repair_seam()` test + helper (the same seam-injection pattern `no_op_repair_seam()` already + established) to deterministically force `claude_mutation_scope::repair_blocked` + to leave a proven-`AutoFixable` `Blocked` state unresolved, then asserts the + final row stays `Blocked`, no `Fixed` result is ever produced, and a `Manual` + result is produced whose detail names the exact state path and contains no + `delete` wording. + - Verify: `nix build .#checks.x86_64-linux.cli-tests` -> `1752 passed; 0 failed; 1 + ignored`; `nix build .#checks.x86_64-linux.cli-clippy` -> clean; `nix build + .#checks.x86_64-linux.cli-fmt` -> clean. + - No change to the `mutation_scope_health[]` JSON schema, T05's final-report-only + `Fixed` authorization, adapter repairability rules, owner-death proof, state + locking, boundary locking, recovery generation semantics, durable + `PendingAbandon`, atomic recovery clear, health status strings, or Codex/Pi + behavior. T07 remains todo. + - Correction (2026-09-21, second pass): The prior correction's own claim that + `build_manual_fix_results` and `finalize_mutation_scope_repair_results` never + overlap was wrong. `build_manual_fix_results(&final_report)` iterates + `final_report.problems` and emits a `Manual` result for every + `ProblemFixability::ManualOnly` problem regardless of category or whether that + target's repair was ever attempted. An attempted `AutoFixable`-at-diagnosis + target whose final row ends `Blocked`/`Invalid` with `ManualOnly` + fixability (recomputed fresh at push time, independent of the attempt) produced + **two** `Manual` fix results for the same target: one from + `finalize_mutation_scope_repair_results` (which now unconditionally owns every + attempted target's outcome per the prior correction) and a second, duplicate one + from `build_manual_fix_results`. Fixed by making result ownership explicit + instead of deduplicating after the fact: + - `DoctorProblem` gains a `mutation_scope_target: Option` + field (`cli/src/services/doctor/types.rs`), set to `Some(target)` only at the + one call site that pushes a mutation-scope-health problem + (`push_mutation_scope_health_problem` in `inspect.rs`) and `None` at every + other `DoctorProblem` construction site (lifecycle-provider problems in + `mod.rs`, and every non-mutation-scope problem in `inspect.rs`). This gives + each mutation-scope-health `DoctorProblem` a structural link back to the + `IntegrationTarget` it describes — the same identity already carried by its + corresponding `MutationScopeHealthRow.target` — without parsing `summary`, + `remediation`, or comparing adapter names as strings. + - `build_manual_fix_results` (`cli/src/services/doctor/fixes.rs`) now takes a + second parameter, `attempted_mutation_scope_targets: &[IntegrationTarget]`, + and skips any `ManualOnly` problem whose `category` is `MutationScopeHealth` + and whose `mutation_scope_target` is in that slice — those targets' results are + now exclusively owned by `finalize_mutation_scope_repair_results`. A + never-attempted `ManualOnly` mutation-scope problem (never returned by + `repair_blocked_mutation_scope_targets`) is untouched by the new filter and + still produces its `Manual` result here exactly as before. Every non- + mutation-scope `ManualOnly` problem (config/hooks/assets/...) is also + untouched, since the new filter only ever excludes the `MutationScopeHealth` + category. + - The orchestration call site (`execute_doctor_with_lifecycle_providers` in + `mod.rs`) now passes `&mutation_scope_repairs` (the same + `Vec` `repair_blocked_mutation_scope_targets` returned, and + the same slice `finalize_mutation_scope_repair_results` already consumes) as + that second argument, so both functions agree on exactly which targets were + attempted. + - Added five aggregation-layer regressions in + `cli/src/services/doctor/inspect.rs`, each combining + `finalize_mutation_scope_repair_results` and `build_manual_fix_results` the + same way the real orchestration does and asserting the combined + `MutationScopeHealth`-category result count is exactly one: + `attempted_target_final_healthy_produces_exactly_one_fixed_and_no_manual`, + `attempted_target_final_recovering_produces_exactly_one_fixed_and_no_manual`, + `attempted_target_final_blocked_manual_only_produces_exactly_one_manual_result` + (the specific regression case: initial `Blocked` with the sole attempt + `PendingAbandon` — `AutoFixable` — repaired via the existing + `no_op_repair_seam()` helper, then the persisted state is overwritten with + `claude_blocked_state()`, an `Active`-phase attempt with no established + abandon intent, so the final row is `Blocked` with fixability recomputed to + `ManualOnly`; asserts exactly one combined result, that it is `Manual`, that + its detail names the exact persisted state path, and that it contains no + `delete` wording), `attempted_target_final_invalid_produces_exactly_one_manual_result`, + and `never_attempted_manual_only_target_still_produces_exactly_one_manual_result`. + - `collect_hook_health` in `inspect.rs` gained an + `#[allow(clippy::too_many_lines)]` alongside its pre-existing + `#[allow(dead_code)]`: adding the new `mutation_scope_target: None,` field to + its five unrelated `HookRollout`-category `DoctorProblem` literals pushed it + from 100 to 103 lines under `clippy::pedantic`'s `too_many_lines` lint; no + other change to that function. + - Verify: `nix build .#checks.x86_64-linux.cli-tests` -> `1757 passed; 0 failed; + 1 ignored` (1752 pre-existing + 5 new); `nix build + .#checks.x86_64-linux.cli-clippy` -> clean; `nix build + .#checks.x86_64-linux.cli-fmt` -> clean. + - Result ownership is now explicit and total: an attempted mutation-scope + target's fix result is exclusively finalized from its final + `mutation_scope_health` row by `finalize_mutation_scope_repair_results` + (`Healthy`/`Recovering` -> `Fixed`, `Blocked`/`Invalid` -> `Manual`); a + never-attempted `ManualOnly` mutation-scope target's fix result is owned by + the generic `build_manual_fix_results` path; therefore every mutation-scope + target produces at most one fix result, closing the gap the first correction's + "no overlap" claim incorrectly asserted was already closed. + - No change to the `mutation_scope_health[]` JSON schema, adapter repairability + rules, Claude `Clear + PendingAbandon => Invalid`, owner-death proof, state + locks, OpenCode boundary locking, mutation-scope seams, recovery-generation + semantics, atomic recovery clearing, durable `PendingAbandon`, Codex/Pi + behavior, or health status strings. No direct adapter JSON editing from + doctor. T07 remains todo; not started. + +- [x] T07: `Cross-adapter end-to-end regression and formal-model connection` (status:done) + - Task ID: T07 + - Scope: In — one or more command-level integration tests (driving + `run_doctor_with_context`/the `sce doctor`/`sce doctor --fix` command + surface directly, not just the `inspect.rs` helper functions) seeding a + single repository with both a Claude `Blocked` state and an OpenCode + `Blocked` state at once, and asserting the full rendered text and JSON + output for both `sce doctor` (AC1/AC2 wording, verbatim) and + `sce doctor --fix` (`[fixed]`/`[manual]` lines, verbatim, and a final + report matching AC6) — proving T03/T04/T05/T06 integrate correctly + across adapters, which no earlier task's adapter-local tests exercise + together. Connect T01's `spec/doctor_recovery.qnt` invariants to this + implementation: either a Quint-Connect harness mirroring the existing + `cli/src/services/mutation_trace/mbt/` convention, or, if that is + disproportionate for this plan's scope, direct doc comments on these + regression tests naming which T01 invariant each one proves — decide + and record which approach was taken and why. Out — any new production + behavior; this task authors regression coverage and the formal-model + connection only, not a "run the check suite" pass. + - Dependencies: T05, T06 + - Done when: the multi-adapter end-to-end test(s) pass and assert the + literal remediation/fix-result wording from AC1, AC2, and AC6; every + T01 invariant is traceably connected to at least one Rust test (via + Quint-Connect or documented mapping); `nix flake check` passes. + - Verify: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor`; `nix flake check` + - Completed: 2026-09-21 + - Files changed: `cli/src/services/doctor/inspect.rs` (adds two new + command-level regression tests — + `full_report_multi_adapter_diagnose_names_doctor_fix_for_one_target_and_the_real_path_for_the_other` + and + `full_report_multi_adapter_fix_mode_resolves_one_target_and_leaves_the_other_manual` + — plus their fixtures + `init_git_repo_with_healthy_claude_and_opencode_targets` and + `seed_opencode_manual_only_blocked_state`; widens + `MutationScopeRepairSeam`, `mutation_scope_repair_seam`, and + `repair_blocked_mutation_scope_targets_with_seam` from private to + `pub(super)` and removes the now-redundant seam-defaulted + `repair_blocked_mutation_scope_targets` wrapper it replaces; adds a + seam-parameterized `run_full_doctor_report_with_seam` test helper that + `run_full_doctor_report` now delegates to); + `cli/src/services/doctor/mod.rs` (threads a new + `mutation_scope_seam: MutationScopeRepairSeam<'_>` parameter through + `execute_doctor_with_context`/`execute_doctor_with_lifecycle_providers`, + with `run_doctor_with_context` — the sole production caller — passing + the real `mutation_scope_repair_seam`; the mutation-scope repair + dispatch call site now reads + `repair_blocked_mutation_scope_targets_with_seam(&initial_report, + mutation_scope_seam)`). + - Result: Two new command-level regression tests drive the full + `execute_doctor_with_context`/`execute_doctor_with_lifecycle_providers` + pipeline (initial diagnosis -> existing repairs -> + `repair_blocked_mutation_scope_targets_with_seam` -> final diagnosis -> + `finalize_mutation_scope_repair_results` -> + `build_manual_fix_results`) against one repository seeded with a Claude + `AutoFixable` `Blocked` state (`claude_autofixable_blocked_state()`) and + an OpenCode `ManualOnly` `Blocked` state (a `PendingStart` attempt with + no recorded owner) at once — the first tests in this plan to exercise + T03/T04/T05/T06 together through the top-level command surface rather + than through `inspect.rs`'s per-adapter helpers. + `full_report_multi_adapter_diagnose_names_doctor_fix_for_one_target_and_the_real_path_for_the_other` + asserts plain `sce doctor`'s human text and `--format json` both name + `sce doctor --fix` for the Claude row (AC1) and the real OpenCode + state-file path with no deletion wording for the OpenCode row (AC2), in + the same report. + `full_report_multi_adapter_fix_mode_resolves_one_target_and_leaves_the_other_manual` + runs `sce doctor --fix` against the same seeded repository and asserts: + exactly one `[fixed] Recovered Claude Code Agent tracing (...)` result + and one `[manual] Agent tracing remains blocked. Inspect '...'` result; + the final report contains only the still-`Blocked` OpenCode problem + (Claude's is gone, since it is no longer a problem after being fixed); + OpenCode's persisted state is untouched and still `Blocked`; and + Claude's is never `Blocked`/`Invalid` — proving AC6 holds when one + adapter is genuinely repaired and an unrelated adapter in the same + report stays blocked. + - Deviation: driving Claude's real repair through the production seam + (`hooks::mutation_scope::run_mutation_scope_from_payload`) inside + `full_report_multi_adapter_fix_mode_resolves_one_target_and_leaves_the_other_manual` + hit the exact test-infrastructure characteristic T05 already documented + and worked around: the credential-store-backed Agent Trace DB + encryption key does not reliably survive a second real, encrypted + per-repository database being created in the same test process, + surfacing as `AbandonScopeError::AgentTraceDbUnavailable` and a + deterministic (not intermittent, in this case) `NoOp` repair outcome — + confirmed by direct diagnosis (a throwaway instrumented test, removed + before this record) before any fix was applied. Following T05's own + precedent exactly (injecting a fake seam so dispatch-level logic is + tested deterministically, while T03/T04's own adapter suites prove the + real seam separately), this task widens the seam-injection pattern one + level up the call stack: `MutationScopeRepairSeam`, + `mutation_scope_repair_seam`, and + `repair_blocked_mutation_scope_targets_with_seam` become `pub(super)`, + `execute_doctor_with_context`/`execute_doctor_with_lifecycle_providers` + take an explicit seam parameter instead of hardcoding the production + one internally, and the now-redundant seam-defaulted + `repair_blocked_mutation_scope_targets` wrapper is removed rather than + left as dead code. Production behavior is unchanged — the sole + production caller (`run_doctor_with_context`) always passes the real + `mutation_scope_repair_seam` — this is a testability-only + parameterization, not new production behavior. The new + diagnose-mode test needs no seam and is unaffected (Diagnose mode never + calls the repair step). The 15 pre-existing `full_report_*` tests + (single-adapter, mostly Diagnose-mode or a `ManualOnly`/no-op Fix-mode + path that also never reaches the seam) are unaffected: `run_full_doctor_report` + keeps its original signature and now delegates to + `run_full_doctor_report_with_seam(repo, mode, &mutation_scope_repair_seam)`, + so nothing about their behavior changed — confirmed by their unchanged + pass/fail outcome and by `claude_mutation_scope`/`opencode_mutation_scope`'s + own suites (138/113 passed, matching T05/T06's last recorded counts) + below. + - Formal-model connection: a Quint-Connect harness mirroring + `cli/src/services/mutation_trace/mbt/` was not built. That harness + traces a pure Rust refinement (`mutation_trace::protocol`) of its Quint + model action-for-action; `spec/doctor_recovery.qnt`'s actions + (`hookAllocate`, `doctorAttemptRepairWith`, `recoveryProgress`, + `completeAbandon`, ...) correspond to real adapter I/O — durable state + files, OS locks, `/proc` owner liveness, the real mutation-scope seam — + with no equivalent pure core to trace against; extracting one would be + new production behavior this task's own scope excludes. This task + instead uses the documented-mapping option T01 itself explicitly + allows, following the same "no comments in code, the plan is the + header comment" precedent T01 already established for this exact file + (this repository's own standing convention). Each of `spec/doctor_recovery.qnt`'s + eight `Safety` invariants is traceably proven by at least one existing + or new Rust regression test: + - `DoctorNeverAbandonsALiveOwner`: + `opencode_mutation_scope::health::tests::repair_blocked_is_a_safe_no_op_when_the_pending_start_owner_is_live`, + `opencode_mutation_scope::health::tests::assess_repairability_is_manual_only_when_the_pending_start_owner_is_live`. + - `UnknownOwnerNeverProvesDeath`: + `opencode_mutation_scope::health::tests::assess_repairability_is_manual_only_for_a_legacy_pending_start_attempt_with_no_recorded_owner`; + this task's new + `doctor::inspect::tests::full_report_multi_adapter_diagnose_names_doctor_fix_for_one_target_and_the_real_path_for_the_other` + (an unowned OpenCode `PendingStart` stays `ManualOnly` in the very + same report where Claude's proven-dead-equivalent state is + `AutoFixable`). + - `RecoveryNeverClearedWithUnresolvedAbandon`: + `claude_mutation_scope::health::tests::repair_blocked_removes_only_successfully_abandoned_attempts_and_keeps_recovery_pending_when_one_fails`. + - `NoOrdinaryTransitionProducesInvalidHealth`: + `claude_mutation_scope::health::tests::clear_recovery_with_pending_abandon_is_invalid`, + `claude_mutation_scope::health::tests::invalid_takes_priority_over_blocked_in_mixed_impossible_state`, + `opencode_mutation_scope::health::tests::clear_recovery_with_a_pending_abandon_attempt_is_a_structurally_impossible_state_classified_invalid`. + - `DoctorRepairProducesOnlyOrdinaryLifecycleShapes`: + `opencode_mutation_scope::health::tests::repair_blocked_clears_a_dead_owner_pending_start_end_to_end`; + `claude_mutation_scope::health::tests::repair_blocked_removes_only_successfully_abandoned_attempts_and_keeps_recovery_pending_when_one_fails` + (shared with `RecoveryNeverClearedWithUnresolvedAbandon` above). + - `RemovedAttemptsAreNeverResurrected`: + `claude_mutation_scope::state::tests::removing_an_already_removed_attempt_is_a_safe_no_op`, + `opencode_mutation_scope::state::tests::removing_an_already_removed_attempt_is_a_safe_no_op`, + `opencode_mutation_scope::tests::regression_f_start_replay_for_a_pending_abandon_identity_never_reactivates`. + - `InterruptedRecoveryStaysInOrdinaryRetryableState`: + `claude_mutation_scope::health::tests::repair_blocked_interrupted_by_a_failing_seam_leaves_state_a_later_repair_completes_without_duplication`, + `opencode_mutation_scope::health::tests::repair_blocked_interrupted_before_the_seam_resolves_leaves_state_the_ordinary_recovery_path_completes_without_duplication`. + - `ReportedFixedExcludesBlockedOrInvalid`: + `doctor::inspect::tests::finalize_mutation_scope_repair_results_ignores_an_immediate_post_repair_read_that_the_final_report_contradicts`; + this task's new + `doctor::inspect::tests::full_report_multi_adapter_fix_mode_resolves_one_target_and_leaves_the_other_manual` + (Claude's `Fixed` report is derived only from the final, freshly + recomputed row, at the same moment the adjacent OpenCode row in that + identical final report is still `Blocked`). + - Verify outcomes: `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor` -> `64 passed; 0 failed` (56 pre-existing + 2 new multi-adapter regressions; the 6 test names T05/T06 recorded plus this task's own 2 account for all `full_report_*` growth), re-run three times consecutively with no flakes; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` -> `138 passed; 0 failed`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml opencode_mutation_scope` -> `113 passed; 0 failed`; `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_scope_health` -> `12 passed; 0 failed`; `cargo fmt --manifest-path cli/Cargo.toml -- --check` -> clean; `SCE_CLI_PACKAGE_FALLBACK=1 cargo clippy --manifest-path cli/Cargo.toml --all-targets` -> no warnings; `grep -rn "SystemTime\|Instant::now\|\.elapsed()\|modified()" cli/src/services/hooks/claude_mutation_scope cli/src/services/hooks/opencode_mutation_scope cli/src/services/hooks/mutation_scope_owner.rs cli/src/services/doctor/*.rs` -> only the pre-existing, unrelated `AdapterStateLock`/`os_lock` timeout deadlines, the static no-TTL-token source scan's own token list, and an unrelated test-fixture nonce (`unique_temp_repository_root`), matching AC3; `nix run .#quint -- typecheck spec/doctor_recovery.qnt` -> clean; `nix run .#quint -- test spec/doctor_recovery.qnt --match '^test.*'` -> `13 passing` (unchanged — this task made no Quint model edit); `nix flake check` -> `all checks passed!` (includes `cli-tests`, `cli-clippy`, `cli-fmt`, and `mutation-trace-quint-connect`). + - Context impact: No user-visible behavior, public interface, persisted + data shape, or architecture-boundary change — this task adds regression + coverage and a formal-model traceability mapping only, plus a + testability-only seam parameter on two already-private `doctor::mod` + functions never called outside this crate. This is the task named in + the plan's own "Context sync" section as the last to run before the + plan's own context-synchronization pass is due; per the plan's own + non-speculative instruction, a new shared doc for "the doctor-repair + invariants" is only warranted if the context-synchronization phase + judges the mapping above substantial enough on its own merits — it is + not created here as part of task execution. + - Context synchronization: synced + +## Open questions + +None. The change request explicitly required deriving the state-machine and +architectural decisions from the actual code rather than from the request's +own hypothesized shapes, and that inspection is recorded above (Codex/Pi +need no adapter change; Claude's primary repairable shape needs no owner +evidence; OpenCode's does). Where the request itself flagged a genuine +implementation choice ("decide from the actual barrier logic", "if the +investigation finds..."), the corresponding task scope says so explicitly +rather than presenting a false certainty here. + +## Validation Report + +**Status:** validated +**Date:** 2026-09-21 + +### Commands run + +- `nix flake check` -> exit 0 (all checks passed, including `cli-tests`, `cli-clippy`, `cli-fmt`, `mutation-trace-quint-connect`) +- `grep -rn "SystemTime\|Instant::now\|\.elapsed()\|modified()" cli/src/services/hooks/claude_mutation_scope cli/src/services/hooks/opencode_mutation_scope cli/src/services/hooks/mutation_scope_owner.rs` -> exit 0 (only pre-existing `AdapterStateLock`/`os_lock` timeout-deadline `Instant::now()` uses and the static no-TTL-token scan's own token list; no staleness-based repair evidence) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml doctor` -> exit 0 (64 passed; 0 failed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml claude_mutation_scope` -> exit 0 (138 passed; 0 failed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml opencode_mutation_scope` -> exit 0 (113 passed; 0 failed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_scope_health` -> exit 0 (12 passed; 0 failed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml mutation_scope_owner` -> exit 0 (8 passed; 0 failed) +- `nix develop -c ./scripts/run-cli-cargo.sh test --manifest-path cli/Cargo.toml pi_mutation_scope` -> exit 0 (94 passed; 0 failed) +- `nix develop -c cargo fmt --manifest-path cli/Cargo.toml -- --check` -> exit 0 (clean) +- `nix run .#quint -- typecheck spec/doctor_recovery.qnt` -> exit 0 (clean typecheck) +- `nix run .#quint -- test spec/doctor_recovery.qnt --match '^test.*'` -> exit 0 (13 passing) +- `nix run .#quint -- run spec/doctor_recovery.qnt --invariant=Safety --max-samples=10000 --max-steps=30` -> exit 0 (`[ok] No violation found`) + +### Success-criteria verification + +- [x] AC1: A `Blocked`/`auto_fixable` row states the literal `sce doctor --fix` remediation in text and JSON, with `fixability: auto_fixable` / `next_action: doctor_fix` -> `full_report_autofixable_blocked_names_doctor_fix_in_text_and_json` (cli/src/services/doctor/inspect.rs) asserts both the rendered text and the `--format json` payload contain `sce doctor --fix`, `"fixability":"auto_fixable"`, and `"next_action":"doctor_fix"`; passing in the `doctor` test run above. +- [x] AC2: A `manual_only` `Blocked`/`Invalid` row names the real state-file path and never suggests deletion -> `mutation_scope_health_manual_only_states_name_the_real_state_path_and_never_suggest_deletion` and `full_report_blocked_regression_is_consistent_across_surfaces` (cli/src/services/doctor/inspect.rs) assert the rendered text/JSON contain the real `state::state_path(...)` value and no `delete` wording; passing in the `doctor` test run above. +- [x] AC3: No repair infers staleness from time; every durable transition runs under the adapter state lock, never across a seam call -> the grep above finds no staleness use outside pre-existing lock-timeout deadlines and the static scan's own token list; T03/T04 concurrent-race regressions (`opencode_mutation_scope`, `claude_mutation_scope` suites) pass. +- [x] AC4: A legacy persisted state file (no owner evidence, no `PendingAbandon`) stays `manual_only` when `Blocked` -> T03/T04 legacy-fixture regressions pass within the `opencode_mutation_scope`/`claude_mutation_scope` suites above (e.g. `assess_repairability_is_manual_only_for_a_legacy_pending_start_attempt_with_no_recorded_owner`). +- [x] AC5: An interrupted repair leaves state a later `sce doctor --fix` completes safely, without deletion or resurrection/duplication -> T03/T04 crash-mid-repair regressions pass within the suites above (e.g. `repair_blocked_interrupted_by_a_failing_seam_leaves_state_a_later_repair_completes_without_duplication`, `repair_blocked_interrupted_before_the_seam_resolves_leaves_state_the_ordinary_recovery_path_completes_without_duplication`). +- [x] AC6: `sce doctor --fix` never reports `fixed` while the freshly recomputed health stays `Blocked`/`Invalid`; `Recovering` is an accepted successful repair -> T05 postcondition regressions pass within the `doctor` suite above, including `finalize_mutation_scope_repair_results_ignores_an_immediate_post_repair_read_that_the_final_report_contradicts` and `finalize_mutation_scope_repair_results_reports_manual_when_an_attempted_autofixable_repair_stays_blocked`. +- [x] AC7: The `mutation_scope_health` JSON array's `target`/`status`/`reason`/`detail` shape and `healthy`/`recovering`/`blocked`/`invalid` status strings are unchanged; no `MutationScope*` type or field renamed -> direct inspection of `MutationScopeHealthRow` (cli/src/services/doctor/types.rs:12-18) and `mutation_scope_health_status` (types.rs:555-562) confirms the field set and status strings are unchanged (only the additive `remediation: Option` field was added); the `doctor` test run above exercises the JSON payload directly. +- [x] AC8: The stated safety invariants are formal and connected to the implementation -> `nix run .#quint -- typecheck spec/doctor_recovery.qnt` and `quint test` (13 passing) above; `quint run --invariant=Safety --max-samples=10000 --max-steps=30` finds no violation; T07's documented-mapping connection ties each of the eight `Safety` invariants to specific passing Rust regressions (recorded in T07's task record). + +### Failed checks and follow-ups + +- None. + +### Residual risks + +- None identified. diff --git a/context/sce/agent-trace-hook-doctor.md b/context/sce/agent-trace-hook-doctor.md index 59d5abd32..26fa399b5 100644 --- a/context/sce/agent-trace-hook-doctor.md +++ b/context/sce/agent-trace-hook-doctor.md @@ -45,7 +45,7 @@ The runtime in `cli/src/services/doctor/mod.rs` exposes the approved doctor comm - integration groups are rendered beneath typed, target-scoped `Claude Code`, `OpenCode`, `Pi`, and `Codex` nodes in deterministic target-specific area order; healthy groups render one concise status row without listing installed files - OpenCode plugin inventory includes the installed manifest file plus plugin/preset artifacts as required presence-only files; Claude groups are derived from embedded `.claude` assets (`settings.json` and `hooks/**` under `ClaudeCode plugins`, including `.claude/hooks/run-sce-or-show-install-guidance.sh`, then `commands/**` and `skills/**`); Pi groups are derived from embedded `.pi` assets (`prompts/**` under `Pi prompts`, `skills/**` under `Pi skills`); Codex groups are derived from the embedded Codex catalog (`.agents/skills/**` under `Codex skills`, one row per required `.codex/hooks.json` registration plus `.codex/hooks/**` under `Codex hooks`, the former also gated on Codex's own read-only hook-trust state — see `context/sce/doctor-human-text-contract.md`); generated `config/.opencode/**`, `config/.claude/**`, `config/.pi/**`, and `config/.agents/**`/`config/.codex/**` trees are not inspected by doctor - repair-mode delegation to `ServiceLifecycle::fix` implementations: `HooksLifecycle::fix` reuses `install_required_git_hooks` for missing hooks directories plus missing, stale, or non-executable required hooks, so repair restores the canonical all-hook non-blocking missing-`sce` guidance, available-CLI argument/failure propagation, and post-commit-only remote forwarding contract; `LocalDbLifecycle::fix`, `AuthDbLifecycle::fix`, and `AgentTraceDbLifecycle::fix` handle bootstrap of missing canonical SCE-owned DB parent directories -- mutation-scope runtime health reporting: for every resolved integration target, doctor consumes that adapter's own `healthy | recovering | blocked | invalid` classifier (never inspecting persisted recovery state itself) and reports it in both output modes; `blocked`/`invalid` are `manual_only` problems that `sce doctor --fix` cannot repair, while `recovering` is a `Warning`-severity, `no_action_required` diagnostic that keeps readiness `ready` and produces no `--fix` result at all — see [mutation-scope-health-status.md](mutation-scope-health-status.md) +- mutation-scope runtime health reporting: for every resolved integration target, doctor consumes that adapter's own `healthy | recovering | blocked | invalid` classifier (never inspecting persisted recovery state itself) and reports it in both output modes; `invalid` is always a `manual_only` problem, `blocked` is `auto_fixable` or `manual_only` depending on the owning adapter's own `assess_repairability` (Claude and OpenCode only — Codex and Pi never classify `blocked`), and `recovering` is a `Warning`-severity, `no_action_required` diagnostic that keeps readiness `ready` and produces no `--fix` result at all — see [mutation-scope-health-status.md](mutation-scope-health-status.md) ## Approved human text-mode contract @@ -198,6 +198,7 @@ Repair behavior must: - add new internal doctor-owned repair routines only for safe gaps with no existing canonical repair command - stay idempotent across repeated `--fix` runs - remain bounded to SCE-owned paths/files and explicit permission normalization on those paths +- for a `blocked` mutation-scope Agent-tracing row, delegate to that adapter's own proven repair (see [mutation-scope-health-status.md](mutation-scope-health-status.md)) ## ServiceLifecycle trait diff --git a/context/sce/doctor-human-text-contract.md b/context/sce/doctor-human-text-contract.md index b2fcf0640..bf00c59d4 100644 --- a/context/sce/doctor-human-text-contract.md +++ b/context/sce/doctor-human-text-contract.md @@ -96,10 +96,16 @@ classifier reports (`healthy | recovering | blocked | invalid`, see [mutation-scope-health-status.md](mutation-scope-health-status.md)), using the same compact/expand convention as any other row — `[PASS]` (`healthy`) collapses with no reason shown, `[WARN]` (`recovering`) and `[FAIL]` -(`blocked`/`invalid`) expand with a short `Reason:` line and an optional -`Detail:` line. This row is independent of that target's asset-content -checks above it: a target can show `[PASS]` for every asset area while its -`Agent tracing` row is `[WARN]`/`[FAIL]`, or vice versa. +(`blocked`/`invalid`) expand with a short `Reason:` line, an optional +`Detail:` line, and — whenever the underlying `DoctorProblem` carries +remediation text — a `Remediation:` line: for a `blocked` row this is +`Run 'sce doctor --fix' to recover ...` when the owning adapter proves the +state `auto_fixable`, or text naming the exact persisted state-file path +(never suggesting deletion) when it remains `manual_only`; see +[mutation-scope-health-status.md](mutation-scope-health-status.md). This row +is independent of that target's asset-content checks above it: a target can +show `[PASS]` for every asset area while its `Agent tracing` row is +`[WARN]`/`[FAIL]`, or vice versa. Codex's `Hooks` area covers `.codex/hooks/run-sce-or-show-install-guidance.sh` plus one row per required `.codex/hooks.json` registration instead of one diff --git a/context/sce/mutation-scope-health-status.md b/context/sce/mutation-scope-health-status.md index 723185dea..12e4021e6 100644 --- a/context/sce/mutation-scope-health-status.md +++ b/context/sce/mutation-scope-health-status.md @@ -78,24 +78,100 @@ Each non-healthy status produces one `DoctorProblem` in a new | --- | --- | --- | --- | --- | --- | | `Healthy` | none | — | — | — | none | | `Recovering` | `MutationScopeHealthRecovering` | Warning | `no_action_required` | `no_action_required` | overall readiness may remain `ready` | -| `Blocked` | `MutationScopeHealthBlocked` | Error | `manual_only` | `manual_steps` | overall readiness becomes `not_ready` | +| `Blocked` | `MutationScopeHealthBlocked` | Error | `auto_fixable` or `manual_only` (dynamic — see "Repairability" below) | `doctor_fix` or `manual_steps` | overall readiness becomes `not_ready` | | `Invalid` | `MutationScopeHealthInvalid` | Error | `manual_only` | `manual_steps` | overall readiness becomes `not_ready` | -`sce doctor --fix` never mutates adapter recovery state (deleting a state -file, clearing `recovery_pending`, resetting a `RecoveryState`, or -fabricating an abandon): doctor cannot safely discard persisted -mutation-scope lifecycle/recovery evidence whose meaning and recovery -obligations belong to the adapter state machine. For example, Claude's -unresolved-abandonment case is documented in D12/D19 in +`sce doctor --fix` never deletes a state file, clears `recovery_pending`/a +`RecoveryState` generically, resets attempts, or fabricates an abandon +outside an adapter's own real protocol operations: doctor cannot safely +discard persisted mutation-scope lifecycle/recovery evidence whose meaning +and recovery obligations belong to the adapter state machine, and it never +calls `remove_attempt()` or rewrites a state file's JSON itself. For example, +Claude's unresolved-abandonment case is documented in D12/D19 in [claude-mutation-scope-integration.md](../cli/claude-mutation-scope-integration.md); other adapters can be `Blocked` by different durable evidence, such as OpenCode's stale outstanding `PendingStart` with no unresolved abandonment at all. -`Blocked` and `Invalid` are `manual_only`: doctor cannot repair them, and -`--fix` renders a deterministic manual-remediation result naming the real -adapter state file path, stating plainly that no safe generic recovery -command exists yet, and never recommending deletion of the state file. +`Invalid` is always `manual_only`: a state file doctor cannot safely +interpret is never auto-repaired. `Blocked`, however, has a second, separate +fact beyond its health status: **repairability**. Health +(`healthy`/`recovering`/`blocked`/`invalid`, above) and repairability +(`AutoFixable`/`ManualOnly`, `Repairability` in +`cli/src/services/hooks/mutation_scope_health.rs`, kept as a distinct type +from `MutationScopeHealthStatus`) are modeled as separate facts on purpose: +a `Blocked` problem record's repairability can change as the adapter's own +positive evidence changes (for example, a dead owner becoming provably dead +only after its process actually exits), while its health stays `Blocked` +until either an ordinary lifecycle event or a repair actually clears it. + +For a `Blocked` row, each adapter owns its own `assess_repairability(git_dir) +-> Repairability` and, when `AutoFixable`, `repair_blocked(git_dir, +repository_root, logger, seam) -> Result` (Claude and OpenCode +only — Codex and Pi never classify `Blocked` today, so neither defines these +functions). `AutoFixable` requires positive, freshly-reprovable evidence, not +a timestamp, file age, or generic "clear the state" fallback: + +- **Claude** (`claude_mutation_scope::health`) is `AutoFixable` only when + every currently persisted attempt is already `PendingAbandon` (an + established, durably-recorded abandon intent for all of them); any + `PendingStart`/`Active` attempt with no established abandon intent forces + the whole adapter `ManualOnly`. `repair_blocked` re-reads and re-proves + that same condition inside one lock-protected, read-only state + transaction, then retries each attempt's already-established seam + `abandon` call independently outside the lock, removing only the ones that + succeed and clearing `recovery_pending` only once none remain. +- **OpenCode** (`opencode_mutation_scope::health`) is `AutoFixable` only when + every currently `PendingStart` attempt has a recorded owner (PID + + `/proc` start-time identity, stamped at allocation) the shared + `mutation_scope_owner::is_definitely_dead` proves dead; a legacy attempt + with no recorded owner, a live owner, or an unprovable owner is + `ManualOnly`. `repair_blocked` acquires the adapter's `AdapterBoundaryLock`, + re-proves the same all-or-nothing dead-owner condition inside one + lock-protected state transaction, transitions the qualifying attempts to + `PendingAbandon`, then drives the existing `flush`/`abandon`/`flush` seam + sequence with the state lock released. + +`sce doctor --fix` (`execute_doctor_with_lifecycle_providers` in +`cli/src/services/doctor/mod.rs`, dispatched by +`repair_blocked_mutation_scope_targets` in `cli/src/services/doctor/inspect.rs`) +runs this repair as one further step, positioned after the existing +`ServiceLifecycle`/merge-target repairs and before the final diagnosis that +produces the fix-mode report. For each row the *initial* diagnosis found +`Blocked`, it calls that adapter's `assess_repairability` fresh; when +`AutoFixable`, it calls `repair_blocked` through the real production +mutation-scope ingress seam, then immediately re-reads a fresh +`classify_health` rather than trusting `repair_blocked`'s `Ok(())` return — +only when that fresh read is `Healthy`/`Recovering` does doctor record a +`Fixed` fix result, so a `Fixed` result and a final report still +`Blocked`/`Invalid` for that target can never coincide. Doctor itself never +acquires or holds an adapter's state lock and never holds one across the +seam call; that serialization is entirely adapter-owned (OpenCode's +`AdapterBoundaryLock` around the whole repair; Claude's own per-transaction +state lock with no added boundary lock). A `ManualOnly` row is never passed +to `repair_blocked` at all and falls through unchanged to the existing +generic manual-result handling, which renders a deterministic +manual-remediation result naming the real adapter state file path, stating +plainly that no safe generic recovery command exists for it, and never +recommending deletion of the state file. + +The `DoctorProblem` rendered for a `Blocked` row is itself built from +`assess_repairability`, so its `fixability`/`remediation` text vary with +this same repairability fact rather than always carrying the shared +`manual_only` wording: `AutoFixable` produces `fixability: auto_fixable`, +`next_action: doctor_fix`, and remediation text naming `sce doctor --fix` +explicitly, both in JSON (`problems[].remediation.text`) and in the human +"Agent tracing" tree row's `Remediation:` line; `ManualOnly` keeps +`fixability: manual_only`, `next_action: manual_steps`, and remediation text +leading with the real state-file path and an explicit instruction not to +delete it. `sce doctor --fix`'s own fix-result line for this category uses +the same source text: a repaired target's `[fixed]` line reads `Recovered + Agent tracing (now : ).` from the freshly +recomputed final health row, and an unresolved `ManualOnly`/`Invalid` +target's `[manual]` line reads the adapter's own remediation text verbatim +(leading `Agent tracing remains blocked. Inspect ''. ...`) instead of +the generic `"{summary} Manual remediation is still required."` wrapper +every other manual-only doctor category still uses. `Recovering` is a distinct fixability, `no_action_required`: doctor performs no repair *because none is needed*, not because remediation is merely diff --git a/npm/package.json b/npm/package.json index ba10a7ef1..c876d6a02 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "@crocoder-dev/sce", - "version": "0.4.0-pre-alpha-v6", + "version": "0.4.0-pre-alpha-v7", "description": "npm launcher package for the Shared Context Engineering CLI", "type": "module", "private": false, diff --git a/packaging/flatpak/dev.crocoder.sce.metainfo.xml b/packaging/flatpak/dev.crocoder.sce.metainfo.xml index b78efc692..6d9165eff 100644 --- a/packaging/flatpak/dev.crocoder.sce.metainfo.xml +++ b/packaging/flatpak/dev.crocoder.sce.metainfo.xml @@ -21,7 +21,7 @@ sce - + diff --git a/schema/v0.4.0-pre-alpha-v7/config.json b/schema/v0.4.0-pre-alpha-v7/config.json new file mode 100644 index 000000000..f06e59947 --- /dev/null +++ b/schema/v0.4.0-pre-alpha-v7/config.json @@ -0,0 +1,465 @@ +{ + "$id": "https://sce.crocoder.dev/v0.4.0-pre-alpha-v7/config.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SCE Config", + "description": "Canonical JSON Schema for global and repo-local sce/config.json files.", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "const": "https://sce.crocoder.dev/v0.4.0-pre-alpha-v7/config.json" + }, + "log_level": { + "description": "Minimum severity of log records.", + "default": "error", + "type": "string", + "enum": [ + "error", + "warn", + "info", + "debug" + ] + }, + "log_format": { + "description": "Format used for log records.", + "default": "text", + "type": "string", + "enum": [ + "text", + "json" + ] + }, + "log_to_file": { + "description": "Write log records to the configured log directory.", + "default": true, + "type": "boolean" + }, + "log_dir": { + "description": "Directory for log files when file logging is enabled.", + "default": "/sce/logs", + "type": "string", + "minLength": 1 + }, + "log_file_retention_limit": { + "description": "Maximum number of log files retained.", + "default": 10, + "type": "integer", + "minimum": 1 + }, + "workos_client_id": { + "description": "WorkOS client ID used for authentication.", + "type": "string" + }, + "control_plane_base_url": { + "description": "Base URL of the control-plane Agent Trace ingestion API used by `sce trace sync`.", + "type": "string", + "minLength": 1 + }, + "agent_trace": { + "description": "Agent Trace repository identity configuration. Selects the repository-scoped Agent Trace database.", + "type": "object", + "properties": { + "repository_id": { + "description": "Explicit repository identity. When set, overrides Git remote based repository identity resolution.", + "type": "string", + "minLength": 1 + }, + "repository_remote": { + "description": "Git remote name used to derive repository identity.", + "default": "origin", + "type": "string", + "minLength": 1 + }, + "auto_sync": { + "description": "Launch a detached, best-effort `sce sync` after successful post-commit Agent Trace persistence.", + "default": true, + "type": "boolean" + } + }, + "additionalProperties": false + }, + "policies": { + "description": "Policy configuration for SCE runtime and bash-tool enforcement.", + "type": "object", + "properties": { + "attribution_hooks": { + "description": "Policy for commit-msg attribution hooks.", + "type": "object", + "properties": { + "enabled": { + "description": "Enable SCE attribution hooks.", + "default": true, + "type": "boolean" + } + }, + "additionalProperties": false + }, + "database_retry": { + "description": "Per-database retry policy overrides for local, Agent Trace, and auth databases.", + "type": "object", + "properties": { + "local_db": { + "description": "Retry policy overrides for a database's connection and query operations.", + "type": "object", + "properties": { + "connection_open": { + "description": "Retry timing and attempt limits for one database operation.", + "type": "object", + "properties": { + "max_attempts": { + "description": "Maximum number of attempts for the operation.", + "type": "integer", + "minimum": 1 + }, + "timeout_ms": { + "description": "Timeout in milliseconds for each attempt.", + "type": "integer", + "minimum": 1 + }, + "initial_backoff_ms": { + "description": "Initial retry backoff in milliseconds.", + "type": "integer", + "minimum": 0 + }, + "max_backoff_ms": { + "description": "Maximum retry backoff in milliseconds.", + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false, + "required": [ + "max_attempts", + "timeout_ms", + "initial_backoff_ms", + "max_backoff_ms" + ] + }, + "query": { + "description": "Retry timing and attempt limits for one database operation.", + "type": "object", + "properties": { + "max_attempts": { + "description": "Maximum number of attempts for the operation.", + "type": "integer", + "minimum": 1 + }, + "timeout_ms": { + "description": "Timeout in milliseconds for each attempt.", + "type": "integer", + "minimum": 1 + }, + "initial_backoff_ms": { + "description": "Initial retry backoff in milliseconds.", + "type": "integer", + "minimum": 0 + }, + "max_backoff_ms": { + "description": "Maximum retry backoff in milliseconds.", + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false, + "required": [ + "max_attempts", + "timeout_ms", + "initial_backoff_ms", + "max_backoff_ms" + ] + } + }, + "additionalProperties": false + }, + "agent_trace_db": { + "description": "Retry policy overrides for a database's connection and query operations.", + "type": "object", + "properties": { + "connection_open": { + "description": "Retry timing and attempt limits for one database operation.", + "type": "object", + "properties": { + "max_attempts": { + "description": "Maximum number of attempts for the operation.", + "type": "integer", + "minimum": 1 + }, + "timeout_ms": { + "description": "Timeout in milliseconds for each attempt.", + "type": "integer", + "minimum": 1 + }, + "initial_backoff_ms": { + "description": "Initial retry backoff in milliseconds.", + "type": "integer", + "minimum": 0 + }, + "max_backoff_ms": { + "description": "Maximum retry backoff in milliseconds.", + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false, + "required": [ + "max_attempts", + "timeout_ms", + "initial_backoff_ms", + "max_backoff_ms" + ] + }, + "query": { + "description": "Retry timing and attempt limits for one database operation.", + "type": "object", + "properties": { + "max_attempts": { + "description": "Maximum number of attempts for the operation.", + "type": "integer", + "minimum": 1 + }, + "timeout_ms": { + "description": "Timeout in milliseconds for each attempt.", + "type": "integer", + "minimum": 1 + }, + "initial_backoff_ms": { + "description": "Initial retry backoff in milliseconds.", + "type": "integer", + "minimum": 0 + }, + "max_backoff_ms": { + "description": "Maximum retry backoff in milliseconds.", + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false, + "required": [ + "max_attempts", + "timeout_ms", + "initial_backoff_ms", + "max_backoff_ms" + ] + } + }, + "additionalProperties": false + }, + "auth_db": { + "description": "Retry policy overrides for a database's connection and query operations.", + "type": "object", + "properties": { + "connection_open": { + "description": "Retry timing and attempt limits for one database operation.", + "type": "object", + "properties": { + "max_attempts": { + "description": "Maximum number of attempts for the operation.", + "type": "integer", + "minimum": 1 + }, + "timeout_ms": { + "description": "Timeout in milliseconds for each attempt.", + "type": "integer", + "minimum": 1 + }, + "initial_backoff_ms": { + "description": "Initial retry backoff in milliseconds.", + "type": "integer", + "minimum": 0 + }, + "max_backoff_ms": { + "description": "Maximum retry backoff in milliseconds.", + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false, + "required": [ + "max_attempts", + "timeout_ms", + "initial_backoff_ms", + "max_backoff_ms" + ] + }, + "query": { + "description": "Retry timing and attempt limits for one database operation.", + "type": "object", + "properties": { + "max_attempts": { + "description": "Maximum number of attempts for the operation.", + "type": "integer", + "minimum": 1 + }, + "timeout_ms": { + "description": "Timeout in milliseconds for each attempt.", + "type": "integer", + "minimum": 1 + }, + "initial_backoff_ms": { + "description": "Initial retry backoff in milliseconds.", + "type": "integer", + "minimum": 0 + }, + "max_backoff_ms": { + "description": "Maximum retry backoff in milliseconds.", + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false, + "required": [ + "max_attempts", + "timeout_ms", + "initial_backoff_ms", + "max_backoff_ms" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "bash": { + "description": "Bash-tool command blocking policy configuration.", + "type": "object", + "properties": { + "presets": { + "description": "Built-in bash-tool policy preset IDs to enable.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "forbid-git-all", + "forbid-git-commit", + "use-pnpm-over-npm", + "use-bun-over-npm", + "use-nix-flake-over-cargo" + ] + }, + "uniqueItems": true, + "allOf": [ + { + "not": { + "allOf": [ + { + "contains": { + "const": "use-pnpm-over-npm" + } + }, + { + "contains": { + "const": "use-bun-over-npm" + } + } + ] + } + } + ] + }, + "custom": { + "description": "Repository-defined bash-tool policies with custom matching and messages.", + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "description": "Unique identifier for the custom bash policy.", + "type": "string", + "minLength": 1, + "not": { + "enum": [ + "forbid-git-all", + "forbid-git-commit", + "use-pnpm-over-npm", + "use-bun-over-npm", + "use-nix-flake-over-cargo" + ] + } + }, + "match": { + "description": "Command matching rule for the custom bash policy.", + "type": "object", + "properties": { + "argv_prefix": { + "description": "Leading command arguments that activate the policy.", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1 + } + }, + "additionalProperties": false, + "required": [ + "argv_prefix" + ] + }, + "satisfied_by": { + "description": "Wrapper argv prefixes that already satisfy this policy. The policy does not fire when the matched command was unwrapped from one of these wrappers, so a policy steering `rg` toward nix can stay quiet for `nix shell nixpkgs#ripgrep -c rg ...` while still blocking a bare `rg`.", + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1 + } + }, + "message": { + "description": "User-facing message shown when the policy blocks a command.", + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false, + "required": [ + "id", + "match", + "message" + ] + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "integrations": { + "description": "Integration targets and optional workflows selected for SCE setup.", + "type": "object", + "properties": { + "target": { + "description": "Integration targets into which SCE assets are installed.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "opencode", + "claude", + "pi", + "codex" + ] + }, + "uniqueItems": true + }, + "optional_workflows": { + "description": "Optional workflows selected for installation into the configured integration targets. Only workflows marked optional in the canonical workflow catalog participate; core workflows are always installed.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "brownfield" + ] + }, + "uniqueItems": true + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/spec/doctor_recovery.qnt b/spec/doctor_recovery.qnt new file mode 100644 index 000000000..e66f1aaa1 --- /dev/null +++ b/spec/doctor_recovery.qnt @@ -0,0 +1,548 @@ +module doctor_recovery { + type AttemptId = Attempt0 | Attempt1 | Attempt2 + + type AttemptPhase = NotAllocated | PendingStart | Active | PendingAbandon | Removed + + type RecoveryState = Clear | Pending | Flushing + + type Health = Healthy | Recovering | Blocked | Invalid + + type OwnerReading = SeenAlive | SeenDead | SeenUnknown + + type StateTxnHolder = StateTxnFree | DoctorStateTxn + + type FixState = NotAttempted | RepairAttempted | RepairCompleted | ReportedFixed | ReportedManual + + val ATTEMPTS: Set[AttemptId] = Set(Attempt0, Attempt1, Attempt2) + + pure def soundReadings(alive: bool): Set[OwnerReading] = + if (alive) Set(SeenAlive, SeenUnknown) else Set(SeenAlive, SeenDead, SeenUnknown) + + pure def adapterHealth(ph: AttemptId -> AttemptPhase, rec: RecoveryState): Health = { + val hasPendingStart = ATTEMPTS.exists(a => ph.get(a) == PendingStart) + val hasPendingAbandon = ATTEMPTS.exists(a => ph.get(a) == PendingAbandon) + if (rec == Clear and hasPendingAbandon) Invalid + else if (hasPendingStart) Blocked + else if (rec == Clear) Healthy + else Recovering + } + + pure def abandonTransition( + ph: AttemptId -> AttemptPhase, + wasPendingAbandon: Set[AttemptId], + rec: RecoveryState, + attempt: AttemptId + ): { phase: AttemptId -> AttemptPhase, recovery: RecoveryState, everWasPendingAbandon: Set[AttemptId] } = { + phase: ph.set(attempt, PendingAbandon), + recovery: if (rec == Clear) Pending else rec, + everWasPendingAbandon: wasPendingAbandon.union(Set(attempt)), + } + + var phase: AttemptId -> AttemptPhase + var recovery: RecoveryState + var ownerAlive: AttemptId -> bool + var everRemoved: Set[AttemptId] + var everWasPendingAbandon: Set[AttemptId] + var pendingAbandonFromDoctor: Set[AttemptId] + var stateTxn: StateTxnHolder + var lastDiagnosis: AttemptId -> OwnerReading + var fixState: FixState + + action init: bool = all { + phase' = ATTEMPTS.mapBy(_ => NotAllocated), + recovery' = Clear, + ownerAlive' = ATTEMPTS.mapBy(_ => false), + everRemoved' = Set(), + everWasPendingAbandon' = Set(), + pendingAbandonFromDoctor' = Set(), + stateTxn' = StateTxnFree, + lastDiagnosis' = ATTEMPTS.mapBy(_ => SeenUnknown), + fixState' = NotAttempted, + } + + action stutter: bool = all { + phase' = phase, + recovery' = recovery, + ownerAlive' = ownerAlive, + everRemoved' = everRemoved, + everWasPendingAbandon' = everWasPendingAbandon, + pendingAbandonFromDoctor' = pendingAbandonFromDoctor, + stateTxn' = stateTxn, + lastDiagnosis' = lastDiagnosis, + fixState' = fixState, + } + + action hookAllocate(attempt: AttemptId): bool = all { + phase.get(attempt) == NotAllocated, + stateTxn == StateTxnFree, + phase' = phase.set(attempt, PendingStart), + recovery' = recovery, + ownerAlive' = ownerAlive.set(attempt, true), + everRemoved' = everRemoved, + everWasPendingAbandon' = everWasPendingAbandon, + pendingAbandonFromDoctor' = pendingAbandonFromDoctor, + stateTxn' = stateTxn, + lastDiagnosis' = lastDiagnosis.set(attempt, SeenUnknown), + fixState' = NotAttempted, + } + + action hookAdvance(attempt: AttemptId): bool = all { + phase.get(attempt) == PendingStart, + stateTxn == StateTxnFree, + phase' = phase.set(attempt, Active), + recovery' = recovery, + ownerAlive' = ownerAlive, + everRemoved' = everRemoved, + everWasPendingAbandon' = everWasPendingAbandon, + pendingAbandonFromDoctor' = pendingAbandonFromDoctor, + stateTxn' = stateTxn, + lastDiagnosis' = lastDiagnosis, + fixState' = fixState, + } + + action hookDecideAbandon(attempt: AttemptId): bool = { + val current = phase.get(attempt) + val t = abandonTransition(phase, everWasPendingAbandon, recovery, attempt) + all { + current == PendingStart or current == Active, + stateTxn == StateTxnFree, + phase' = t.phase, + recovery' = t.recovery, + everWasPendingAbandon' = t.everWasPendingAbandon, + ownerAlive' = ownerAlive, + everRemoved' = everRemoved, + pendingAbandonFromDoctor' = pendingAbandonFromDoctor, + stateTxn' = stateTxn, + lastDiagnosis' = lastDiagnosis, + fixState' = fixState, + } + } + + action hookOwnerCrashes(attempt: AttemptId): bool = all { + phase.get(attempt) != NotAllocated, + phase.get(attempt) != Removed, + ownerAlive.get(attempt), + phase' = phase, + recovery' = recovery, + ownerAlive' = ownerAlive.set(attempt, false), + everRemoved' = everRemoved, + everWasPendingAbandon' = everWasPendingAbandon, + pendingAbandonFromDoctor' = pendingAbandonFromDoctor, + stateTxn' = stateTxn, + lastDiagnosis' = lastDiagnosis, + fixState' = fixState, + } + + action recoveryProgress(attempt: AttemptId): bool = all { + phase.get(attempt) == PendingAbandon, + recovery == Pending, + stateTxn == StateTxnFree, + phase' = phase, + recovery' = Flushing, + ownerAlive' = ownerAlive, + everRemoved' = everRemoved, + everWasPendingAbandon' = everWasPendingAbandon, + pendingAbandonFromDoctor' = pendingAbandonFromDoctor, + stateTxn' = stateTxn, + lastDiagnosis' = lastDiagnosis, + fixState' = fixState, + } + + action completeAbandon(attempt: AttemptId): bool = { + val newPhase = phase.set(attempt, Removed) + val otherObligationsRemain = ATTEMPTS.exists(a => newPhase.get(a) == PendingAbandon) + all { + phase.get(attempt) == PendingAbandon, + recovery == Flushing, + stateTxn == StateTxnFree, + phase' = newPhase, + recovery' = if (otherObligationsRemain) Pending else Clear, + ownerAlive' = ownerAlive, + everRemoved' = everRemoved.union(Set(attempt)), + everWasPendingAbandon' = everWasPendingAbandon, + pendingAbandonFromDoctor' = pendingAbandonFromDoctor, + stateTxn' = stateTxn, + lastDiagnosis' = lastDiagnosis, + fixState' = + if (fixState == RepairAttempted) RepairCompleted else fixState, + } + } + + action doctorDiagnoseWith(attempt: AttemptId, reading: OwnerReading): bool = all { + soundReadings(ownerAlive.get(attempt)).contains(reading), + phase' = phase, + recovery' = recovery, + ownerAlive' = ownerAlive, + everRemoved' = everRemoved, + everWasPendingAbandon' = everWasPendingAbandon, + pendingAbandonFromDoctor' = pendingAbandonFromDoctor, + stateTxn' = stateTxn, + lastDiagnosis' = lastDiagnosis.set(attempt, reading), + fixState' = fixState, + } + + action doctorDiagnose(attempt: AttemptId): bool = { + nondet reading = soundReadings(ownerAlive.get(attempt)).oneOf() + doctorDiagnoseWith(attempt, reading) + } + + action doctorAcquireStateTxn: bool = all { + stateTxn == StateTxnFree, + phase' = phase, + recovery' = recovery, + ownerAlive' = ownerAlive, + everRemoved' = everRemoved, + everWasPendingAbandon' = everWasPendingAbandon, + pendingAbandonFromDoctor' = pendingAbandonFromDoctor, + stateTxn' = DoctorStateTxn, + lastDiagnosis' = lastDiagnosis, + fixState' = fixState, + } + + action doctorReleaseStateTxn: bool = all { + stateTxn == DoctorStateTxn, + phase' = phase, + recovery' = recovery, + ownerAlive' = ownerAlive, + everRemoved' = everRemoved, + everWasPendingAbandon' = everWasPendingAbandon, + pendingAbandonFromDoctor' = pendingAbandonFromDoctor, + stateTxn' = StateTxnFree, + lastDiagnosis' = lastDiagnosis, + fixState' = fixState, + } + + action doctorAttemptRepairWith(attempt: AttemptId, reading: OwnerReading): bool = { + val eligible = + stateTxn == DoctorStateTxn and phase.get(attempt) == PendingStart and reading == SeenDead + val t = abandonTransition(phase, everWasPendingAbandon, recovery, attempt) + all { + phase.get(attempt) != NotAllocated, + soundReadings(ownerAlive.get(attempt)).contains(reading), + phase' = if (eligible) t.phase else phase, + recovery' = if (eligible) t.recovery else recovery, + everWasPendingAbandon' = if (eligible) t.everWasPendingAbandon else everWasPendingAbandon, + ownerAlive' = ownerAlive, + everRemoved' = everRemoved, + pendingAbandonFromDoctor' = + if (eligible) pendingAbandonFromDoctor.union(Set(attempt)) else pendingAbandonFromDoctor, + stateTxn' = stateTxn, + lastDiagnosis' = lastDiagnosis.set(attempt, reading), + fixState' = RepairAttempted, + } + } + + action doctorAttemptRepair(attempt: AttemptId): bool = { + nondet reading = soundReadings(ownerAlive.get(attempt)).oneOf() + doctorAttemptRepairWith(attempt, reading) + } + + action doctorReportResult: bool = { + val attempted = fixState == RepairAttempted or fixState == RepairCompleted + val h = adapterHealth(phase, recovery) + val outcome = if (h == Healthy or h == Recovering) ReportedFixed else ReportedManual + all { + attempted, + fixState' = outcome, + phase' = phase, + recovery' = recovery, + ownerAlive' = ownerAlive, + everRemoved' = everRemoved, + everWasPendingAbandon' = everWasPendingAbandon, + pendingAbandonFromDoctor' = pendingAbandonFromDoctor, + stateTxn' = stateTxn, + lastDiagnosis' = lastDiagnosis, + } + } + + action step: bool = any { + { nondet a = ATTEMPTS.oneOf(); hookAllocate(a) }, + { nondet a = ATTEMPTS.oneOf(); hookAdvance(a) }, + { nondet a = ATTEMPTS.oneOf(); hookDecideAbandon(a) }, + { nondet a = ATTEMPTS.oneOf(); hookOwnerCrashes(a) }, + { nondet a = ATTEMPTS.oneOf(); recoveryProgress(a) }, + { nondet a = ATTEMPTS.oneOf(); completeAbandon(a) }, + { nondet a = ATTEMPTS.oneOf(); doctorDiagnose(a) }, + doctorAcquireStateTxn, + doctorReleaseStateTxn, + { nondet a = ATTEMPTS.oneOf(); doctorAttemptRepair(a) }, + doctorReportResult, + stutter, + } + + val DoctorNeverAbandonsALiveOwner = ATTEMPTS.forall(a => + pendingAbandonFromDoctor.contains(a) implies not(ownerAlive.get(a)) + ) + + val UnknownOwnerNeverProvesDeath = ATTEMPTS.forall(a => + lastDiagnosis.get(a) == SeenDead implies not(ownerAlive.get(a)) + ) + + val RecoveryNeverClearedWithUnresolvedAbandon = + recovery == Clear implies ATTEMPTS.forall(a => phase.get(a) != PendingAbandon) + + val NoOrdinaryTransitionProducesInvalidHealth = + adapterHealth(phase, recovery) != Invalid + + val DoctorRepairProducesOnlyOrdinaryLifecycleShapes = ATTEMPTS.forall(a => + pendingAbandonFromDoctor.contains(a) implies ( + phase.get(a) == Removed + or (phase.get(a) == PendingAbandon and (recovery == Pending or recovery == Flushing)) + ) + ) + + val RemovedAttemptsAreNeverResurrected = + everRemoved.forall(a => phase.get(a) == Removed) + + val InterruptedRecoveryStaysInOrdinaryRetryableState = ATTEMPTS.forall(a => + (everWasPendingAbandon.contains(a) and not(everRemoved.contains(a))) + implies (phase.get(a) == PendingAbandon and (recovery == Pending or recovery == Flushing)) + ) + + val ReportedFixedExcludesBlockedOrInvalid = + fixState == ReportedFixed implies + (adapterHealth(phase, recovery) != Blocked and adapterHealth(phase, recovery) != Invalid) + + val Safety = and { + DoctorNeverAbandonsALiveOwner, + UnknownOwnerNeverProvesDeath, + RecoveryNeverClearedWithUnresolvedAbandon, + NoOrdinaryTransitionProducesInvalidHealth, + DoctorRepairProducesOnlyOrdinaryLifecycleShapes, + RemovedAttemptsAreNeverResurrected, + InterruptedRecoveryStaysInOrdinaryRetryableState, + ReportedFixedExcludesBlockedOrInvalid, + } + + run testOrdinaryLifecycleReachesActive = + init + .then(hookAllocate(Attempt0)) + .expect(phase.get(Attempt0) == PendingStart) + .expect(adapterHealth(phase, recovery) == Blocked) + .then(hookAdvance(Attempt0)) + .expect(phase.get(Attempt0) == Active) + .expect(adapterHealth(phase, recovery) == Healthy) + .expect(Safety) + + run testHookOwnAbandonNeedsNoOwnerEvidence = + init + .then(hookAllocate(Attempt0)) + .then(hookDecideAbandon(Attempt0)) + .expect(phase.get(Attempt0) == PendingAbandon) + .expect(recovery == Pending) + .expect(adapterHealth(phase, recovery) == Recovering) + .then(recoveryProgress(Attempt0)) + .expect(recovery == Flushing) + .then(completeAbandon(Attempt0)) + .expect(phase.get(Attempt0) == Removed) + .expect(recovery == Clear) + .expect(adapterHealth(phase, recovery) == Healthy) + .expect(Safety) + + run testMultiplePendingAbandonShareOneRecoveryPipeline = + init + .then(hookAllocate(Attempt0)) + .then(hookAllocate(Attempt1)) + .then(hookDecideAbandon(Attempt0)) + .expect(phase.get(Attempt0) == PendingAbandon) + .expect(recovery == Pending) + .then(hookDecideAbandon(Attempt1)) + .expect(phase.get(Attempt0) == PendingAbandon) + .expect(phase.get(Attempt1) == PendingAbandon) + .expect(recovery == Pending) + .expect(adapterHealth(phase, recovery) == Recovering) + .expect(Safety) + .then(recoveryProgress(Attempt0)) + .expect(recovery == Flushing) + .then(completeAbandon(Attempt0)) + .expect(phase.get(Attempt0) == Removed) + .expect(phase.get(Attempt1) == PendingAbandon) + .expect(recovery != Clear) + .expect(adapterHealth(phase, recovery) == Recovering) + .expect(Safety) + .then(recoveryProgress(Attempt1)) + .expect(recovery == Flushing) + .then(completeAbandon(Attempt1)) + .expect(phase.get(Attempt1) == Removed) + .expect(recovery == Clear) + .expect(adapterHealth(phase, recovery) == Healthy) + .expect(Safety) + + run testDoctorRepairsADefinitelyDeadOwner = + init + .then(hookAllocate(Attempt0)) + .then(hookOwnerCrashes(Attempt0)) + .then(doctorDiagnoseWith(Attempt0, SeenDead)) + .then(doctorAcquireStateTxn) + .then(doctorAttemptRepairWith(Attempt0, SeenDead)) + .expect(phase.get(Attempt0) == PendingAbandon) + .expect(recovery == Pending) + .expect(pendingAbandonFromDoctor.contains(Attempt0)) + .then(doctorReleaseStateTxn) + .then(recoveryProgress(Attempt0)) + .then(completeAbandon(Attempt0)) + .expect(phase.get(Attempt0) == Removed) + .expect(adapterHealth(phase, recovery) == Healthy) + .then(doctorReportResult) + .expect(fixState == ReportedFixed) + .expect(Safety) + + run testDoctorRefusesALiveOwner = + init + .then(hookAllocate(Attempt0)) + .then(doctorDiagnose(Attempt0)) + .then(doctorAcquireStateTxn) + .then(doctorAttemptRepair(Attempt0)) + .expect(phase.get(Attempt0) == PendingStart) + .expect(not(pendingAbandonFromDoctor.contains(Attempt0))) + .then(doctorReportResult) + .expect(fixState == ReportedManual) + .expect(Safety) + + run testUnknownOwnerRepairIsRefused = + init + .then(hookAllocate(Attempt0)) + .then(doctorAcquireStateTxn) + .then(doctorAttemptRepairWith(Attempt0, SeenUnknown)) + .expect(phase.get(Attempt0) == PendingStart) + .expect(not(pendingAbandonFromDoctor.contains(Attempt0))) + .expect(adapterHealth(phase, recovery) == Blocked) + .then(doctorReportResult) + .expect(fixState == ReportedManual) + .expect(Safety) + + run testStaleDiagnosisRefusedAfterConcurrentPhaseChange = + init + .then(hookAllocate(Attempt0)) + .then(hookOwnerCrashes(Attempt0)) + .then(doctorDiagnoseWith(Attempt0, SeenDead)) + .expect(lastDiagnosis.get(Attempt0) == SeenDead) + .then(hookAdvance(Attempt0)) + .expect(phase.get(Attempt0) == Active) + .then(doctorAcquireStateTxn) + .then(doctorAttemptRepairWith(Attempt0, SeenDead)) + .expect(phase.get(Attempt0) == Active) + .expect(not(pendingAbandonFromDoctor.contains(Attempt0))) + .expect(Safety) + + run testInterruptedRepairIsRetryable = + init + .then(hookAllocate(Attempt0)) + .then(hookOwnerCrashes(Attempt0)) + .then(doctorDiagnoseWith(Attempt0, SeenDead)) + .then(doctorAcquireStateTxn) + .then(doctorAttemptRepairWith(Attempt0, SeenDead)) + .then(doctorReleaseStateTxn) + .expect(phase.get(Attempt0) == PendingAbandon) + .expect(recovery == Pending) + .expect(adapterHealth(phase, recovery) == Recovering) + .then(stutter) + .expect(phase.get(Attempt0) == PendingAbandon) + .expect(recovery == Pending) + .expect(InterruptedRecoveryStaysInOrdinaryRetryableState) + .then(recoveryProgress(Attempt0)) + .expect(recovery == Flushing) + .then(stutter) + .expect(phase.get(Attempt0) == PendingAbandon) + .expect(recovery == Flushing) + .expect(InterruptedRecoveryStaysInOrdinaryRetryableState) + .then(completeAbandon(Attempt0)) + .expect(phase.get(Attempt0) == Removed) + .expect(recovery == Clear) + .expect(everRemoved.contains(Attempt0)) + .expect(Safety) + + run testSuccessfulRepairReachesRecoveringThenHealthy = + init + .then(hookAllocate(Attempt0)) + .then(hookOwnerCrashes(Attempt0)) + .then(doctorDiagnoseWith(Attempt0, SeenDead)) + .then(doctorAcquireStateTxn) + .then(doctorAttemptRepairWith(Attempt0, SeenDead)) + .then(doctorReleaseStateTxn) + .expect(adapterHealth(phase, recovery) == Recovering) + .then(doctorReportResult) + .expect(fixState == ReportedFixed) + .then(recoveryProgress(Attempt0)) + .then(completeAbandon(Attempt0)) + .expect(adapterHealth(phase, recovery) == Healthy) + .expect(Safety) + + run testRepairingOneDeadBlockerLeavesOtherBlockerBlocked = + init + .then(hookAllocate(Attempt0)) + .then(hookAllocate(Attempt1)) + .then(hookOwnerCrashes(Attempt0)) + .expect(adapterHealth(phase, recovery) == Blocked) + .then(doctorDiagnoseWith(Attempt0, SeenDead)) + .then(doctorDiagnoseWith(Attempt1, SeenUnknown)) + .then(doctorAcquireStateTxn) + .then(doctorAttemptRepairWith(Attempt0, SeenDead)) + .expect(phase.get(Attempt0) == PendingAbandon) + .expect(phase.get(Attempt1) == PendingStart) + .then(doctorReleaseStateTxn) + .expect(adapterHealth(phase, recovery) == Blocked) + .then(doctorReportResult) + .expect(fixState == ReportedManual) + .expect(not(fixState == ReportedFixed)) + .then(recoveryProgress(Attempt0)) + .then(completeAbandon(Attempt0)) + .expect(phase.get(Attempt0) == Removed) + .expect(phase.get(Attempt1) == PendingStart) + .expect(adapterHealth(phase, recovery) == Blocked) + .expect(not(fixState == ReportedFixed)) + .expect(Safety) + + run testPendingAbandonWithClearRecoveryIsInvalid = + init + .then(hookAllocate(Attempt0)) + .then(all { + phase' = phase.set(Attempt0, PendingAbandon), + recovery' = recovery, + ownerAlive' = ownerAlive, + everRemoved' = everRemoved, + everWasPendingAbandon' = everWasPendingAbandon.union(Set(Attempt0)), + pendingAbandonFromDoctor' = pendingAbandonFromDoctor, + stateTxn' = stateTxn, + lastDiagnosis' = lastDiagnosis, + fixState' = RepairAttempted, + }) + .expect(phase.get(Attempt0) == PendingAbandon) + .expect(recovery == Clear) + .expect(adapterHealth(phase, recovery) == Invalid) + .then(doctorReportResult) + .expect(fixState == ReportedManual) + .expect(not(fixState == ReportedFixed)) + + run testInvalidTakesPriorityOverBlockedWhenBothConditionsHold = + init + .then(hookAllocate(Attempt1)) + .then(all { + phase' = phase.set(Attempt0, PendingAbandon), + recovery' = recovery, + ownerAlive' = ownerAlive, + everRemoved' = everRemoved, + everWasPendingAbandon' = everWasPendingAbandon.union(Set(Attempt0)), + pendingAbandonFromDoctor' = pendingAbandonFromDoctor, + stateTxn' = stateTxn, + lastDiagnosis' = lastDiagnosis, + fixState' = fixState, + }) + .expect(phase.get(Attempt1) == PendingStart) + .expect(phase.get(Attempt0) == PendingAbandon) + .expect(recovery == Clear) + .expect(adapterHealth(phase, recovery) == Invalid) + + run testRemovedAttemptIsNeverResurrected = + init + .then(hookAllocate(Attempt0)) + .then(hookDecideAbandon(Attempt0)) + .then(recoveryProgress(Attempt0)) + .then(completeAbandon(Attempt0)) + .expect(everRemoved.contains(Attempt0)) + .expect(phase.get(Attempt0) == Removed) + .expect(recovery == Clear) + .expect(phase.get(Attempt0) != NotAllocated) + .expect(RemovedAttemptsAreNeverResurrected) + .expect(Safety) +}