From 302ce64f52b701f6d8b8a23a12927c83f1062768 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 10 Jul 2026 14:27:01 +0000 Subject: [PATCH 1/2] fix(hooks): honor lifecycle quiescence --- src/hook_cmd.rs | 224 ++++++++++++++++++++++++++++++++++ src/lifecycle_lease.rs | 151 +++++++++++++++++++++-- src/main.rs | 38 +++--- tests/hooks_lsp_suite/main.rs | 1 + 4 files changed, 381 insertions(+), 33 deletions(-) diff --git a/src/hook_cmd.rs b/src/hook_cmd.rs index 763e1f435..4135e4f19 100644 --- a/src/hook_cmd.rs +++ b/src/hook_cmd.rs @@ -1,6 +1,91 @@ use crate::cli::Commands; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum HookInput { + NoInput, + Stdin, +} + +#[derive(Debug)] +pub(crate) enum HookAdmission { + NotHook, + Acquired(tracedecay::lifecycle_lease::LifecycleLease), + Busy, +} + +pub(crate) fn admit_hook_command(command: &Commands) -> tracedecay::errors::Result { + if hook_input(command).is_none() { + return Ok(HookAdmission::NotHook); + } + Ok(admission_from_attempt( + tracedecay::lifecycle_lease::try_acquire_shared("agent hook"), + )) +} + +fn admission_from_attempt( + attempt: tracedecay::errors::Result, +) -> HookAdmission { + match attempt { + Ok(tracedecay::lifecycle_lease::SharedLeaseAttempt::Acquired(lease)) => { + HookAdmission::Acquired(lease) + } + Ok(tracedecay::lifecycle_lease::SharedLeaseAttempt::Busy) | Err(_) => HookAdmission::Busy, + } +} + +pub(crate) fn drain_busy_hook_stdin(command: &Commands) { + use std::io::IsTerminal; + + let Some(input_mode) = hook_input(command) else { + return; + }; + if input_mode == HookInput::NoInput || std::io::stdin().is_terminal() { + return; + } + let _ = drain_hook_input(input_mode, &mut std::io::stdin().lock()); +} + +fn drain_hook_input(mode: HookInput, input: &mut impl std::io::Read) -> std::io::Result { + match mode { + HookInput::NoInput => Ok(0), + HookInput::Stdin => std::io::copy(input, &mut std::io::sink()), + } +} + +pub(crate) fn hook_input(command: &Commands) -> Option { + match command { + Commands::HookPreToolUse | Commands::HookPromptSubmit | Commands::HookStop => { + Some(HookInput::NoInput) + } + Commands::HookClaudeSessionStart + | Commands::HookClaudePostToolUse + | Commands::HookClaudeSubagentStart + | Commands::HookKiroPreToolUse + | Commands::HookKiroPromptSubmit + | Commands::HookKiroPostToolUse + | Commands::HookCursorSubagentStart + | Commands::HookCursorPostToolUse + | Commands::HookCursorBeforeSubmitPrompt + | Commands::HookCursorPreCompact + | Commands::HookCursorAfterFileEdit + | Commands::HookCursorSessionStart + | Commands::HookCursorSessionEnd + | Commands::HookCursorAfterShell + | Commands::HookCursorWorkspaceOpen + | Commands::HookCursorStop + | Commands::HookCodexSessionStart + | Commands::HookCodexUserPromptSubmit + | Commands::HookCodexSubagentStart + | Commands::HookCodexPostToolUse + | Commands::HookCodexPostCompact => Some(HookInput::Stdin), + _ => None, + } +} + pub(crate) async fn handle_hook_command(command: Commands) -> tracedecay::errors::Result<()> { + // Claude command hooks own a single JSON document on stdin. Early lease + // admission guarantees this dispatcher runs only while a shared lease is + // held; the busy path drains piped input without producing hook output. match command { Commands::HookPreToolUse => { tracedecay::hooks::hook_pre_tool_use(); @@ -84,3 +169,142 @@ fn exit_if_nonzero(code: i32) { std::process::exit(code); } } + +#[cfg(test)] +mod tests { + use std::fs::OpenOptions; + use std::io::Write; + + use super::{ + Commands, HookAdmission, HookInput, admission_from_attempt, drain_hook_input, hook_input, + }; + use tracedecay::lifecycle_lease::{ + acquire_exclusive_for_profile, try_acquire_shared_for_profile, + }; + + fn hook_commands() -> Vec<(Commands, HookInput)> { + vec![ + (Commands::HookPreToolUse, HookInput::NoInput), + (Commands::HookPromptSubmit, HookInput::NoInput), + (Commands::HookStop, HookInput::NoInput), + (Commands::HookClaudeSessionStart, HookInput::Stdin), + (Commands::HookClaudePostToolUse, HookInput::Stdin), + (Commands::HookClaudeSubagentStart, HookInput::Stdin), + (Commands::HookKiroPreToolUse, HookInput::Stdin), + (Commands::HookKiroPromptSubmit, HookInput::Stdin), + (Commands::HookKiroPostToolUse, HookInput::Stdin), + (Commands::HookCursorSubagentStart, HookInput::Stdin), + (Commands::HookCursorPostToolUse, HookInput::Stdin), + (Commands::HookCursorBeforeSubmitPrompt, HookInput::Stdin), + (Commands::HookCursorPreCompact, HookInput::Stdin), + (Commands::HookCursorAfterFileEdit, HookInput::Stdin), + (Commands::HookCursorSessionStart, HookInput::Stdin), + (Commands::HookCursorSessionEnd, HookInput::Stdin), + (Commands::HookCursorAfterShell, HookInput::Stdin), + (Commands::HookCursorWorkspaceOpen, HookInput::Stdin), + (Commands::HookCursorStop, HookInput::Stdin), + (Commands::HookCodexSessionStart, HookInput::Stdin), + (Commands::HookCodexUserPromptSubmit, HookInput::Stdin), + (Commands::HookCodexSubagentStart, HookInput::Stdin), + (Commands::HookCodexPostToolUse, HookInput::Stdin), + (Commands::HookCodexPostCompact, HookInput::Stdin), + ] + } + + #[test] + fn all_hook_commands_have_explicit_input_semantics() { + let hooks = hook_commands(); + assert_eq!(hooks.len(), 24); + assert_eq!( + hooks + .iter() + .filter(|(_, input)| *input == HookInput::NoInput) + .count(), + 3 + ); + assert_eq!( + hooks + .iter() + .filter(|(_, input)| *input == HookInput::Stdin) + .count(), + 21 + ); + for (command, expected) in hooks { + assert_eq!(hook_input(&command), Some(expected)); + assert!(crate::should_skip_agent_install_maintenance(&command)); + } + } + + #[test] + fn unrelated_exclusive_owner_produces_busy_admission() { + let tmp = tempfile::tempdir().unwrap(); + let lock_path = tmp.path().join("lifecycle.lock"); + let mut external = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .unwrap(); + fs2::FileExt::try_lock_exclusive(&external).unwrap(); + writeln!(external, "external-token\tmigration\t999").unwrap(); + external.flush().unwrap(); + let attempt = try_acquire_shared_for_profile(tmp.path(), "agent hook"); + + assert!(matches!( + admission_from_attempt(attempt), + HookAdmission::Busy + )); + } + + #[test] + fn process_owned_exclusive_lease_is_not_inherited_by_hooks() { + let tmp = tempfile::tempdir().unwrap(); + let _exclusive = acquire_exclusive_for_profile(tmp.path(), "post-update").unwrap(); + let attempt = try_acquire_shared_for_profile(tmp.path(), "agent hook"); + + assert!(matches!( + admission_from_attempt(attempt), + HookAdmission::Busy + )); + } + + #[test] + fn normal_shared_lease_admits_hook_dispatch() { + let tmp = tempfile::tempdir().unwrap(); + let attempt = try_acquire_shared_for_profile(tmp.path(), "agent hook"); + + assert!(matches!( + admission_from_attempt(attempt), + HookAdmission::Acquired(_) + )); + } + + #[test] + fn lifecycle_profile_errors_quiesce_hooks_like_a_busy_lease() { + let tmp = tempfile::tempdir().unwrap(); + let profile_file = tmp.path().join("not-a-profile"); + std::fs::write(&profile_file, "file").unwrap(); + let attempt = try_acquire_shared_for_profile(&profile_file, "agent hook"); + + assert!(matches!( + admission_from_attempt(attempt), + HookAdmission::Busy + )); + } + + #[test] + fn busy_stdin_hooks_drain_but_legacy_no_input_hooks_do_not() { + let mut stdin_payload = b"{\"hook_event_name\":\"SessionStart\"}".as_slice(); + let stdin_len = stdin_payload.len() as u64; + let drained = drain_hook_input(HookInput::Stdin, &mut stdin_payload).unwrap(); + assert_eq!(drained, stdin_len); + assert!(stdin_payload.is_empty()); + + let mut legacy_payload = b"terminal input must remain unread".as_slice(); + let legacy_len = legacy_payload.len(); + let drained = drain_hook_input(HookInput::NoInput, &mut legacy_payload).unwrap(); + assert_eq!(drained, 0); + assert_eq!(legacy_payload.len(), legacy_len); + } +} diff --git a/src/lifecycle_lease.rs b/src/lifecycle_lease.rs index 3c1a75e38..3a0f74ae0 100644 --- a/src/lifecycle_lease.rs +++ b/src/lifecycle_lease.rs @@ -26,6 +26,12 @@ pub struct LifecycleLease { token: Option, } +#[derive(Debug)] +pub enum SharedLeaseAttempt { + Acquired(LifecycleLease), + Busy, +} + impl LifecycleLease { pub fn token(&self) -> Option<&str> { self.token.as_deref() @@ -54,19 +60,25 @@ pub fn acquire_exclusive_for_profile( profile_root: &Path, operation: &str, ) -> Result { - std::fs::create_dir_all(profile_root).map_err(|error| TraceDecayError::Config { - message: format!( - "failed to create TraceDecay profile root '{}': {error}", - profile_root.display() - ), - })?; - acquire_exclusive_at(&profile_root.join(LIFECYCLE_LOCK_FILENAME), operation) + acquire_exclusive_at(&lifecycle_lock_path_for_profile(profile_root)?, operation) } pub fn acquire_shared(operation: &str) -> Result { acquire_shared_at(&lifecycle_lock_path()?, operation) } +/// Attempts to acquire a non-inherited shared lease without blocking. +pub fn try_acquire_shared(operation: &str) -> Result { + try_acquire_shared_at(&lifecycle_lock_path()?, operation) +} + +pub fn try_acquire_shared_for_profile( + profile_root: &Path, + operation: &str, +) -> Result { + try_acquire_shared_at(&lifecycle_lock_path_for_profile(profile_root)?, operation) +} + /// Acquires a shared diagnostic lease, or joins the exclusive lease held by /// this process's post-update parent. pub fn acquire_shared_or_inherited(operation: &str) -> Result { @@ -74,6 +86,23 @@ pub fn acquire_shared_or_inherited(operation: &str) -> Result { acquire_shared_or_inherited_at(&path, operation) } +/// Attempts to acquire a shared lifecycle lease without blocking. A live +/// unrelated exclusive owner is reported as [`SharedLeaseAttempt::Busy`]; +/// lock-file and profile configuration failures remain errors. +pub fn try_acquire_shared_or_inherited(operation: &str) -> Result { + let path = lifecycle_lock_path()?; + try_acquire_shared_or_inherited_at(&path, operation) +} + +/// Explicit-profile counterpart used when ambient HOME/profile resolution is +/// not authoritative. +pub fn try_acquire_shared_or_inherited_for_profile( + profile_root: &Path, + operation: &str, +) -> Result { + try_acquire_shared_or_inherited_at(&lifecycle_lock_path_for_profile(profile_root)?, operation) +} + fn acquire_shared_or_inherited_at(path: &Path, operation: &str) -> Result { let mut file = open_lock_file(path)?; match fs2::FileExt::try_lock_shared(&file) { @@ -97,6 +126,29 @@ fn acquire_shared_or_inherited_at(path: &Path, operation: &str) -> Result Result { + let mut file = open_lock_file(path)?; + match fs2::FileExt::try_lock_shared(&file) { + Ok(()) => Ok(SharedLeaseAttempt::Acquired(LifecycleLease { + hold: LeaseHold::File(file), + token: None, + })), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + let owner = read_owner(&mut file); + let owner_token = owner.as_deref().and_then(|line| line.split('\t').next()); + if owner_token.is_some_and(process_owns_token) { + Ok(SharedLeaseAttempt::Acquired(LifecycleLease { + hold: LeaseHold::Inherited, + token: None, + })) + } else { + Ok(SharedLeaseAttempt::Busy) + } + } + Err(error) => Err(lock_error(path, operation, &error)), + } +} + /// Acquires the lifecycle lease, or proves that this process is the /// post-update child of the process that still owns it. pub fn acquire_exclusive_or_inherited( @@ -150,6 +202,16 @@ fn lifecycle_lock_path() -> Result { Ok(root.join(LIFECYCLE_LOCK_FILENAME)) } +fn lifecycle_lock_path_for_profile(profile_root: &Path) -> Result { + std::fs::create_dir_all(profile_root).map_err(|error| TraceDecayError::Config { + message: format!( + "failed to create TraceDecay profile root '{}': {error}", + profile_root.display() + ), + })?; + Ok(profile_root.join(LIFECYCLE_LOCK_FILENAME)) +} + fn acquire_exclusive_at(path: &Path, operation: &str) -> Result { let file = open_lock_file(path)?; match fs2::FileExt::try_lock_exclusive(&file) { @@ -178,6 +240,20 @@ fn acquire_shared_at(path: &Path, operation: &str) -> Result { } } +fn try_acquire_shared_at(path: &Path, operation: &str) -> Result { + let file = open_lock_file(path)?; + match fs2::FileExt::try_lock_shared(&file) { + Ok(()) => Ok(SharedLeaseAttempt::Acquired(LifecycleLease { + hold: LeaseHold::File(file), + token: None, + })), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + Ok(SharedLeaseAttempt::Busy) + } + Err(error) => Err(lock_error(path, operation, &error)), + } +} + fn own_exclusive(mut file: File, operation: &str) -> Result { let token = lease_token(); file.set_len(0).map_err(|error| owner_write_error(&error))?; @@ -282,9 +358,13 @@ fn owner_write_error(error: &std::io::Error) -> TraceDecayError { #[cfg(test)] mod tests { + use std::fs::OpenOptions; + use std::io::Write; + use super::{ - acquire_exclusive_at, acquire_exclusive_or_inherited_at, acquire_shared_at, - acquire_shared_or_inherited_at, + SharedLeaseAttempt, acquire_exclusive_at, acquire_exclusive_or_inherited_at, + acquire_shared_at, acquire_shared_or_inherited_at, try_acquire_shared_at, + try_acquire_shared_or_inherited_at, try_acquire_shared_or_inherited_for_profile, }; #[test] @@ -321,6 +401,59 @@ mod tests { let _parent = acquire_exclusive_at(&path, "post-update").unwrap(); acquire_shared_or_inherited_at(&path, "doctor").unwrap(); + assert!(matches!( + try_acquire_shared_or_inherited_at(&path, "hook").unwrap(), + SharedLeaseAttempt::Acquired(_) + )); + } + + #[test] + fn nonblocking_shared_attempt_reports_an_unrelated_exclusive_owner_as_busy() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("lifecycle.lock"); + let mut external = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&path) + .unwrap(); + fs2::FileExt::try_lock_exclusive(&external).unwrap(); + writeln!(external, "external-token\tmigration\t999").unwrap(); + external.flush().unwrap(); + + assert!(matches!( + try_acquire_shared_or_inherited_at(&path, "hook").unwrap(), + SharedLeaseAttempt::Busy + )); + } + + #[test] + fn noninherited_shared_attempt_does_not_join_a_process_owned_exclusive_lease() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("lifecycle.lock"); + let _parent = acquire_exclusive_at(&path, "update").unwrap(); + + assert!(matches!( + try_acquire_shared_at(&path, "hook").unwrap(), + SharedLeaseAttempt::Busy + )); + } + + #[test] + fn nonblocking_shared_attempt_preserves_profile_io_errors() { + let tmp = tempfile::tempdir().unwrap(); + let not_a_directory = tmp.path().join("profile-file"); + std::fs::write(¬_a_directory, "not a directory").unwrap(); + + let error = + try_acquire_shared_or_inherited_for_profile(¬_a_directory, "hook").unwrap_err(); + + assert!( + error + .to_string() + .contains("failed to create TraceDecay profile root") + ); } #[test] diff --git a/src/main.rs b/src/main.rs index 83d1fda54..fcad3fb71 100644 --- a/src/main.rs +++ b/src/main.rs @@ -206,6 +206,14 @@ async fn run(cli: Cli) -> tracedecay::errors::Result<()> { }; maybe_run_extract_worker(&command); + let _hook_lease = match hook_cmd::admit_hook_command(&command)? { + hook_cmd::HookAdmission::NotHook => None, + hook_cmd::HookAdmission::Acquired(lease) => Some(lease), + hook_cmd::HookAdmission::Busy => { + hook_cmd::drain_busy_hook_stdin(&command); + return Ok(()); + } + }; run_startup_preamble(&command).await; dispatch_command(command).await } @@ -727,6 +735,9 @@ async fn dispatch_command(command: Commands) -> tracedecay::errors::Result<()> { } fn should_skip_startup_maintenance(command: &Commands) -> bool { + if hook_cmd::hook_input(command).is_some() { + return true; + } matches!( command, Commands::Install { .. } @@ -744,30 +755,6 @@ fn should_skip_startup_maintenance(command: &Commands) -> bool { } | Commands::Migrate { .. } | Commands::Projects { .. } - | Commands::HookPreToolUse - | Commands::HookPromptSubmit - | Commands::HookStop - | Commands::HookClaudeSessionStart - | Commands::HookClaudePostToolUse - | Commands::HookClaudeSubagentStart - | Commands::HookKiroPreToolUse - | Commands::HookKiroPromptSubmit - | Commands::HookKiroPostToolUse - | Commands::HookCursorSubagentStart - | Commands::HookCursorPostToolUse - | Commands::HookCursorBeforeSubmitPrompt - | Commands::HookCursorPreCompact - | Commands::HookCursorAfterFileEdit - | Commands::HookCursorSessionStart - | Commands::HookCursorSessionEnd - | Commands::HookCursorAfterShell - | Commands::HookCursorWorkspaceOpen - | Commands::HookCursorStop - | Commands::HookCodexSessionStart - | Commands::HookCodexUserPromptSubmit - | Commands::HookCodexSubagentStart - | Commands::HookCodexPostToolUse - | Commands::HookCodexPostCompact | Commands::Daemon { .. } // `Serve` is the hot path used by MCP clients (Claude Code, // Codex, etc.). Clients impose a 30 s `initialize` timeout, so @@ -781,6 +768,9 @@ fn should_skip_startup_maintenance(command: &Commands) -> bool { } fn should_skip_agent_install_maintenance(command: &Commands) -> bool { + if hook_cmd::hook_input(command).is_some() { + return true; + } // Selectively gate the implicit `check_install_stale` + silent-reinstall // path so agent permissions/hooks/MCP config stay in sync after a binary // upgrade, without firing on paths where it would be wrong or wasteful: diff --git a/tests/hooks_lsp_suite/main.rs b/tests/hooks_lsp_suite/main.rs index e61c10bb7..4b743bbd8 100644 --- a/tests/hooks_lsp_suite/main.rs +++ b/tests/hooks_lsp_suite/main.rs @@ -15,6 +15,7 @@ mod common; mod extract_worker_test; mod hook_branch_routing_test; +mod hook_lifecycle_lease_test; mod hook_replay_test; mod hooks_test; mod lsp_code_diagnostics_test; From b2fd149f4a1a42b23ae1a677f4823daeb54bc8a8 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 10 Jul 2026 14:27:10 +0000 Subject: [PATCH 2/2] test(hooks): cover lifecycle quiescence --- .../hook_lifecycle_lease_test.rs | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 tests/hooks_lsp_suite/hook_lifecycle_lease_test.rs diff --git a/tests/hooks_lsp_suite/hook_lifecycle_lease_test.rs b/tests/hooks_lsp_suite/hook_lifecycle_lease_test.rs new file mode 100644 index 000000000..cdd2711a9 --- /dev/null +++ b/tests/hooks_lsp_suite/hook_lifecycle_lease_test.rs @@ -0,0 +1,140 @@ +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::path::Path; +use std::process::{Command, Output, Stdio}; + +use super::common::apply_tracedecay_home_env; + +const NO_INPUT_HOOKS: &[&str] = &["hook-pre-tool-use", "hook-prompt-submit", "hook-stop"]; +const STDIN_HOOKS: &[&str] = &[ + "hook-claude-session-start", + "hook-claude-post-tool-use", + "hook-claude-subagent-start", + "hook-kiro-pre-tool-use", + "hook-kiro-prompt-submit", + "hook-kiro-post-tool-use", + "hook-cursor-subagent-start", + "hook-cursor-post-tool-use", + "hook-cursor-before-submit-prompt", + "hook-cursor-pre-compact", + "hook-cursor-after-file-edit", + "hook-cursor-session-start", + "hook-cursor-session-end", + "hook-cursor-after-shell", + "hook-cursor-workspace-open", + "hook-cursor-stop", + "hook-codex-session-start", + "hook-codex-user-prompt-submit", + "hook-codex-subagent-start", + "hook-codex-post-tool-use", + "hook-codex-post-compact", +]; + +fn hold_external_exclusive_lease(home: &Path) -> File { + let profile = home.join(".tracedecay"); + std::fs::create_dir_all(&profile).unwrap(); + let mut lock = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(profile.join("lifecycle.lock")) + .unwrap(); + fs2::FileExt::try_lock_exclusive(&lock).unwrap(); + writeln!(lock, "external-token\tmigration\t999").unwrap(); + lock.flush().unwrap(); + lock +} + +fn run_hook(home: &Path, hook: &str, input: Option<&[u8]>) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_tracedecay")); + apply_tracedecay_home_env(&mut command, home); + command + .arg(hook) + .current_dir(home) + .stdin(if input.is_some() { + Stdio::piped() + } else { + Stdio::null() + }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command.spawn().unwrap(); + if let Some(input) = input { + child.stdin.take().unwrap().write_all(input).unwrap(); + } + child.wait_with_output().unwrap() +} + +#[test] +fn exclusive_lifecycle_owner_quiesces_every_hook_before_startup_or_dispatch() { + assert_eq!(NO_INPUT_HOOKS.len(), 3); + assert_eq!(STDIN_HOOKS.len(), 21); + let temp = tempfile::tempdir().unwrap(); + let home = temp.path(); + let profile = home.join(".tracedecay"); + std::fs::create_dir_all(&profile).unwrap(); + let config = profile.join("config.toml"); + let config_bytes = b"upload_enabled = false\npending_upload = 41\n"; + std::fs::write(&config, config_bytes).unwrap(); + let _exclusive = hold_external_exclusive_lease(home); + + for hook in NO_INPUT_HOOKS { + let output = run_hook(home, hook, None); + assert!(output.status.success(), "{hook}: {output:?}"); + assert!(output.stdout.is_empty(), "{hook} wrote stdout"); + assert!(output.stderr.is_empty(), "{hook} wrote stderr"); + } + for hook in STDIN_HOOKS { + let payload = if *hook == "hook-claude-session-start" { + vec![b' '; 256 * 1024] + } else { + b"{}".to_vec() + }; + let output = run_hook(home, hook, Some(&payload)); + assert!(output.status.success(), "{hook}: {output:?}"); + assert!(output.stdout.is_empty(), "{hook} wrote stdout"); + assert!(output.stderr.is_empty(), "{hook} wrote stderr"); + } + + assert_eq!(std::fs::read(&config).unwrap(), config_bytes); + assert!(!profile.join("global.db").exists()); + assert!(!profile.join("projects").exists()); +} + +#[test] +fn normal_lease_path_still_executes_a_direct_claude_stdin_hook() { + let temp = tempfile::tempdir().unwrap(); + let event = format!( + "{{\"hook_event_name\":\"SessionStart\",\"cwd\":{}}}", + serde_json::to_string(&temp.path().to_string_lossy()).unwrap() + ); + + let output = run_hook( + temp.path(), + "hook-claude-session-start", + Some(event.as_bytes()), + ); + + assert!(output.status.success(), "{output:?}"); + assert!(output.stderr.is_empty(), "{output:?}"); + assert!(serde_json::from_slice::(&output.stdout).is_ok()); +} + +#[test] +fn lifecycle_path_error_silently_drains_and_quiesces_the_hook() { + let temp = tempfile::tempdir().unwrap(); + let profile_file = temp.path().join(".tracedecay"); + std::fs::write(&profile_file, b"not a profile directory").unwrap(); + let payload = vec![b' '; 256 * 1024]; + + let output = run_hook(temp.path(), "hook-claude-session-start", Some(&payload)); + + assert!(output.status.success(), "{output:?}"); + assert!(output.stdout.is_empty()); + assert!(output.stderr.is_empty()); + assert_eq!( + std::fs::read(profile_file).unwrap(), + b"not a profile directory" + ); +}