feat: implement remaining PRD features (10 beads)

Complete the PRD feature set with shell gating pipeline, cache
improvements, layout enhancements, and diagnostics:

- Shell: exec_gated with allowlist/denylist, circuit breaker, env merge
- Shell: parallel prefetch via std::thread::scope for cold renders
- Cache: TTL jitter (FNV-1a), config hash namespace, garbage collection
- Cache: diagnostic tracking (hit/miss, age) for dump-state
- Layout: gradual drop strategy (one-by-one vs tiered)
- Layout: render budget timer with graceful priority-based degradation
- Layout: breakpoint hysteresis to prevent preset toggling
- Width: detection source tracking for diagnostics
- CLI: --no-cache, --no-shell, --clear-cache, env var overrides
- Diagnostics: enhanced --dump-state with section timing and cache stats

Closes: bd-3oy, bd-62g, bd-khk, bd-3q1, bd-ywx, bd-3l2,
        bd-2vm, bd-1if, bd-2qr, bd-30o, bd-3ax, bd-3uw, bd-4b1

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Taylor Eernisse
2026-02-06 15:59:15 -05:00
parent 73401beb47
commit 4c9139ec42
15 changed files with 1198 additions and 138 deletions

View File

@@ -1,21 +1,56 @@
use std::cell::RefCell;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
/// Diagnostic entry for a single cache lookup.
#[derive(Debug, Clone)]
pub struct CacheDiag {
pub key: String,
pub hit: bool,
pub age_ms: Option<u64>,
}
pub struct Cache {
dir: Option<PathBuf>,
jitter_pct: u8,
diagnostics: RefCell<Vec<CacheDiag>>,
}
impl Cache {
/// Create a disabled cache where all operations are no-ops.
/// Used for --no-cache mode.
pub fn disabled() -> Self {
Self {
dir: None,
jitter_pct: 0,
diagnostics: RefCell::new(Vec::new()),
}
}
/// Create cache with secure directory. Returns disabled cache on failure.
pub fn new(template: &str, session_id: &str) -> Self {
let dir_str = template.replace("{session_id}", session_id);
/// Replaces `{session_id}`, `{cache_version}`, and `{config_hash}` in template.
pub fn new(
template: &str,
session_id: &str,
cache_version: u32,
config_hash: &str,
jitter_pct: u8,
) -> Self {
let dir_str = template
.replace("{session_id}", session_id)
.replace("{cache_version}", &cache_version.to_string())
.replace("{config_hash}", config_hash);
let dir = PathBuf::from(&dir_str);
if !dir.exists() {
if fs::create_dir_all(&dir).is_err() {
return Self { dir: None };
return Self {
dir: None,
jitter_pct,
diagnostics: RefCell::new(Vec::new()),
};
}
#[cfg(unix)]
{
@@ -26,29 +61,93 @@ impl Cache {
// Security: verify ownership, not a symlink, not world-writable
if !verify_cache_dir(&dir) {
return Self { dir: None };
return Self {
dir: None,
jitter_pct,
diagnostics: RefCell::new(Vec::new()),
};
}
Self { dir: Some(dir) }
Self {
dir: Some(dir),
jitter_pct,
diagnostics: RefCell::new(Vec::new()),
}
}
pub fn dir(&self) -> Option<&Path> {
self.dir.as_deref()
}
/// Get cached value if fresher than TTL.
/// Get cached value if fresher than TTL (with per-key jitter applied).
pub fn get(&self, key: &str, ttl: Duration) -> Option<String> {
let path = self.key_path(key)?;
let meta = fs::metadata(&path).ok()?;
let path = match self.key_path(key) {
Some(p) => p,
None => {
self.record_diag(key, false, None);
return None;
}
};
let meta = match fs::metadata(&path).ok() {
Some(m) => m,
None => {
self.record_diag(key, false, None);
return None;
}
};
let modified = meta.modified().ok()?;
let age = SystemTime::now().duration_since(modified).ok()?;
if age < ttl {
fs::read_to_string(&path).ok()
let age_ms = age.as_millis() as u64;
let effective_ttl = self.jittered_ttl(key, ttl);
if age < effective_ttl {
let value = fs::read_to_string(&path).ok();
self.record_diag(key, value.is_some(), Some(age_ms));
value
} else {
self.record_diag(key, false, Some(age_ms));
None
}
}
fn record_diag(&self, key: &str, hit: bool, age_ms: Option<u64>) {
if let Ok(mut diags) = self.diagnostics.try_borrow_mut() {
diags.push(CacheDiag {
key: key.to_string(),
hit,
age_ms,
});
}
}
/// Return collected cache diagnostics (for --dump-state).
pub fn diagnostics(&self) -> Vec<CacheDiag> {
self.diagnostics.borrow().clone()
}
/// Apply deterministic per-key jitter to TTL.
/// Uses FNV-1a hash of key to produce stable jitter (same key = same jitter every time).
fn jittered_ttl(&self, key: &str, base_ttl: Duration) -> Duration {
if self.jitter_pct == 0 {
return base_ttl;
}
// FNV-1a hash of key for deterministic per-key jitter
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in key.bytes() {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0100_0000_01b3);
}
// Map hash to range [-jitter_pct, +jitter_pct]
let jitter_range = f64::from(self.jitter_pct) / 100.0;
let normalized = (hash % 2001) as f64 / 1000.0 - 1.0; // [-1.0, 1.0]
let multiplier = 1.0 + (normalized * jitter_range);
let jittered_ms = (base_ttl.as_millis() as f64 * multiplier) as u64;
// Clamp: minimum 100ms to avoid zero TTL
Duration::from_millis(jittered_ms.max(100))
}
/// Get stale cached value (ignores TTL). Used as fallback on command failure.
pub fn get_stale(&self, key: &str) -> Option<String> {
let path = self.key_path(key)?;
@@ -152,3 +251,94 @@ pub fn session_id(project_dir: &str) -> String {
let hash = Md5::digest(project_dir.as_bytes());
format!("{:x}", hash)[..12].to_string()
}
/// Garbage-collect old cache directories.
/// Runs at most once per `gc_interval_hours`. Deletes dirs older than `gc_days`
/// that match /tmp/claude-sl-* and are owned by the current user.
/// Never blocks: uses non-blocking flock on a sentinel file.
pub fn gc(gc_days: u16, gc_interval_hours: u16) {
let lock_path = Path::new("/tmp/claude-sl-gc.lock");
// Check interval: if lock file exists and is younger than gc_interval, skip
if let Ok(meta) = fs::metadata(lock_path) {
if let Ok(modified) = meta.modified() {
if let Ok(age) = SystemTime::now().duration_since(modified) {
if age < Duration::from_secs(u64::from(gc_interval_hours) * 3600) {
return;
}
}
}
}
// Try non-blocking lock
let lock_file = match fs::File::create(lock_path) {
Ok(f) => f,
Err(_) => return,
};
if !try_flock(&lock_file) {
return; // another process is GC-ing
}
// Touch the lock file (create already set mtime to now)
let max_age = Duration::from_secs(u64::from(gc_days) * 86400);
let entries = match fs::read_dir("/tmp") {
Ok(e) => e,
Err(_) => {
unlock(&lock_file);
return;
}
};
let uid = current_uid();
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.starts_with("claude-sl-") {
continue;
}
let path = entry.path();
// Safety: skip symlinks
let meta = match fs::symlink_metadata(&path) {
Ok(m) => m,
Err(_) => continue,
};
if !meta.is_dir() || meta.file_type().is_symlink() {
continue;
}
// Only delete dirs owned by current user
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if meta.uid() != uid {
continue;
}
}
// Check age
if let Ok(modified) = meta.modified() {
if let Ok(age) = SystemTime::now().duration_since(modified) {
if age > max_age {
let _ = fs::remove_dir_all(&path);
}
}
}
}
unlock(&lock_file);
}
fn current_uid() -> u32 {
#[cfg(unix)]
{
unsafe { libc::getuid() }
}
#[cfg(not(unix))]
{
0
}
}