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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions project/ticket-018/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Ticket 018: Native Rust acceleration for agent log panic scanning and session diagnostics

- **ID**: ticket-018
- **Owner**: Antigravity
- **Status**: IN_PROGRESS
- **Workflow state**: EDIT
- **Created**: 2026-10-11

## Goal and scope

Accelerate agent storage health inspections and panic diagnostics using native Rust
execution in the worker binary while preserving fail-safe Python fallbacks.
Specifically:
1. Extend `src/uncrash/snapshot.rs` with high-throughput `--scan-panics <dir> [budget]` mode.
2. Integrate native panic scanning into `src/uncrash/diagnostics.py` with safe binary discovery and automatic fallback.
3. Add worker precompilation helpers to eliminate runtime `rustc` compiler invocation latency during diagnostics.
4. Comprehensive test coverage ensuring bit-for-bit parity between native Rust and Python scanner results.

## Acceptance criteria

- [x] AC-01: Native worker supports `--scan-panics` sub-command with budget limits and symlink protections.
- [x] AC-02: `scan_agent_log_panics` transparently attempts native acceleration and falls back to pure Python if unavailable.
- [x] AC-03: Precompilation caching prevents redundant runtime `rustc` executions.
- [x] AC-04: Test suite verifies parity, edge cases, and fallback behavior without shortcuts or `pytest.skip`.

## Tracking boundary

This directory contains the reviewed intent and implementation record.
32 changes: 32 additions & 0 deletions project/ticket-018/intent.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"schema": "new-project.intent/v3",
"ticket": "ticket-018",
"summary": "Native Rust acceleration for agent log panic scanning and session diagnostics",
"workstream": "application",
"classification": {
"kind": "FEATURE",
"priority": "P2",
"origin": "health"
},
"allowedPaths": [
"project/ticket-018/**",
"TODO.md",
"project/TICKETS.md",
"src/uncrash/snapshot.rs",
"src/uncrash/diagnostics.py",
"src/uncrash/store.py",
"src/uncrash/cli.py",
"tests/test_diagnostics.py",
"tests/test_native.py"
],
"forbiddenPaths": [
"project/ticket-*/user-*.md"
],
"stacks": [
"python",
"rust"
],
"dependsOn": [],
"conflictsWith": [],
"integrationTicket": null
}
39 changes: 37 additions & 2 deletions src/uncrash/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@
import re
import sqlite3
import stat
import shutil
import subprocess
import tempfile
import time
from urllib.parse import quote

from .store import RecoveryError, no_links, write_file
from .store import RecoveryError, build_native, find_native_worker, no_links, write_file


PROVIDERS = {'codex', 'claude', 'agy'}
Expand Down Expand Up @@ -151,10 +154,42 @@ def check_sqlite_integrity(path: Path) -> tuple[str, str | None]:
connection.close()


def scan_agent_log_panics(log_dir: Path, max_files: int = 20) -> list[dict]:
def native_scan_agent_log_panics(log_dir: Path, max_files: int = 20, state_root: Path = None) -> list[dict] | None:
"""Attempt fast native Rust scan of agent logs; returns None if native worker is unavailable."""
if not isinstance(max_files, int) or not 1 <= max_files <= 100:
raise ValueError('Log file budget must be between 1 and 100')
binary = find_native_worker(state_root)
if binary is None and shutil.which('rustc'):
try:
target_root = state_root or (Path(tempfile.gettempdir()) / f'uncrash-{os.getuid()}')
binary = build_native(target_root)
except (RecoveryError, OSError):
binary = None
if binary is None:
return None
try:
proc = subprocess.run(
[str(binary), '--scan-panics', str(log_dir), str(max_files)],
capture_output=True,
text=True,
timeout=10,
)
if proc.returncode == 0:
data = json.loads(proc.stdout.strip() or '[]')
if isinstance(data, list):
return data
except (subprocess.TimeoutExpired, OSError, json.JSONDecodeError):
pass
return None


def scan_agent_log_panics(log_dir: Path, max_files: int = 20, state_root: Path = None) -> list[dict]:
"""Scan recent agent log files for unhandled panic/crash signatures."""
if not isinstance(max_files, int) or not 1 <= max_files <= 100:
raise ValueError('Log file budget must be between 1 and 100')
native_result = native_scan_agent_log_panics(log_dir, max_files, state_root)
if native_result is not None:
return native_result
try:
log_dir = no_links(log_dir)
candidates = []
Expand Down
192 changes: 192 additions & 0 deletions src/uncrash/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,199 @@ fn run(index: usize, job: Job, key: &[u8], compress: bool) -> io::Result<String>
logical_size,
))
}
fn json_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 16);
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => {
out.push_str(&format!("\\u{:04x}", c as u32));
}
c => out.push(c),
}
}
out
}
fn find_panic_in_content(content: &str) -> Option<(String, String)> {
for line in content.lines() {
let lower = line.to_ascii_lowercase();
if let Some(pos) = lower.find("caught panic:") {
let orig_from_match = &line[pos..];
let after_prefix = &orig_from_match["caught panic:".len()..];
let detail = after_prefix.trim_start();
let signature = orig_from_match.trim();
return Some((signature.to_string(), detail.trim().to_string()));
}
}
for line in content.lines() {
let lower = line.to_ascii_lowercase();
if let Some(pos) = lower.find("runtime error:") {
let orig_from_match = &line[pos..];
let after_prefix = &orig_from_match["runtime error:".len()..];
let detail = after_prefix.trim_start();
let detail_lower = detail.to_ascii_lowercase();
if detail_lower.starts_with("invalid memory address or nil pointer dereference") {
let signature = orig_from_match.trim();
return Some((signature.to_string(), detail.trim().to_string()));
}
}
}
for line in content.lines() {
let lower = line.to_ascii_lowercase();
if let Some(pos) = lower.find("failed to load conversation ") {
let orig_from_match = &line[pos..];
let rest = &orig_from_match["failed to load conversation ".len()..];
if let Some(colon_pos) = rest.find(':') {
let id_candidate = rest[..colon_pos].trim();
if !id_candidate.is_empty() && id_candidate.chars().all(|c| c.is_ascii_hexdigit() || c == '-') {
let after_colon = &rest[colon_pos + 1..];
let after_colon_lower = after_colon.to_ascii_lowercase();
if after_colon_lower.contains("database disk image is malformed") {
let signature = orig_from_match.trim();
return Some((signature.to_string(), id_candidate.to_string()));
}
}
}
}
}
for line in content.lines() {
let lower = line.to_ascii_lowercase();
if let Some(pos) = lower.find("program was killed:") {
let orig_from_match = &line[pos..];
let after_prefix = &orig_from_match["program was killed:".len()..];
let detail = after_prefix.trim_start();
let detail_lower = detail.to_ascii_lowercase();
if detail_lower.starts_with("program experienced a panic") {
let signature = orig_from_match.trim();
return Some((signature.to_string(), detail.trim().to_string()));
}
}
}
None
}
fn scan_panics(args: &[String]) -> io::Result<()> {
if args.is_empty() {
println!("[]");
return Ok(());
}
let log_dir = Path::new(&args[0]);
let max_files: usize = if args.len() > 1 {
match args[1].parse::<usize>() {
Ok(n) if (1..=100).contains(&n) => n,
_ => return Err(fail()),
}
} else {
20
};

let meta = match std::fs::symlink_metadata(log_dir) {
Ok(m) => m,
Err(_) => {
println!("[]");
return Ok(());
}
};
if meta.file_type().is_symlink() || !meta.is_dir() {
println!("[]");
return Ok(());
}

let entries = match std::fs::read_dir(log_dir) {
Ok(e) => e,
Err(_) => {
println!("[]");
return Ok(());
}
};

let mut candidates: Vec<(i128, PathBuf)> = Vec::new();
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
let p = entry.path();
if p.extension().and_then(|s| s.to_str()) != Some("log") {
continue;
}
let smeta = match std::fs::symlink_metadata(&p) {
Ok(m) => m,
Err(_) => continue,
};
if smeta.file_type().is_symlink() || !smeta.is_file() {
continue;
}
let mtime_ns = (smeta.mtime() as i128) * 1_000_000_000 + (smeta.mtime_nsec() as i128);
candidates.push((mtime_ns, p));
}

candidates.sort_by(|a, b| (b.0, &b.1).cmp(&(a.0, &a.1)));
if candidates.len() > max_files {
candidates.truncate(max_files);
}

let mut panics_json = Vec::new();
for (_, log_path) in candidates {
let file_name = match log_path.file_name().and_then(|s| s.to_str()) {
Some(s) => s.to_string(),
None => continue,
};

let c_path = match CString::new(log_path.as_os_str().as_bytes()) {
Ok(c) => c,
Err(_) => continue,
};
let fd = unsafe { openat(-100 /* AT_FDCWD */, c_path.as_ptr(), 0 /* O_RDONLY */ | NOFOLLOW | CLOEXEC) };
if fd < 0 {
continue;
}
let mut file = unsafe { File::from_raw_fd(fd) };
let meta = match file.metadata() {
Ok(m) => m,
Err(_) => continue,
};
if !meta.is_file() {
continue;
}
let file_size = meta.len();
let chunk_size: u64 = 512 * 1024;
let read_offset = if file_size > chunk_size {
file_size - chunk_size
} else {
0
};
if file.seek(io::SeekFrom::Start(read_offset)).is_err() {
continue;
}
let mut buf = Vec::new();
if file.take(chunk_size).read_to_end(&mut buf).is_err() {
continue;
}
let content = String::from_utf8_lossy(&buf);

if let Some((signature, matched_detail)) = find_panic_in_content(&content) {
panics_json.push(format!(
"{{\"log_path\":\"{}\",\"log_name\":\"{}\",\"signature\":\"{}\",\"matched_detail\":\"{}\"}}",
json_escape(&log_path.to_string_lossy()),
json_escape(&file_name),
json_escape(&signature),
json_escape(&matched_detail)
));
}
}

println!("[{}]", panics_json.join(","));
Ok(())
}
fn main() -> io::Result<()> {
let args: Vec<String> = std::env::args().collect();
if args.len() > 1 && args[1] == "--scan-panics" {
return scan_panics(&args[2..]);
}
let mut input = String::new();
io::stdin()
.take(16 * 1024 * 1024 + 1)
Expand Down
25 changes: 24 additions & 1 deletion src/uncrash/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import time
from urllib.parse import quote
import uuid
import tempfile
import zlib

from cryptography.hazmat.primitives.ciphers.aead import AESGCM
Expand Down Expand Up @@ -167,9 +168,31 @@ def build_native(root):
return binary


def find_native_worker(root=None):
source = Path(__file__).with_name('snapshot.rs')
if not source.is_file():
return None
digest = hashlib.sha256(source.read_bytes()).hexdigest()
candidates = []
if root is not None:
candidates.append(Path(root) / 'native' / digest / 'uncrash-snapshot')
candidates.append(Path.home() / '.local/share/uncrash/native' / digest / 'uncrash-snapshot')
candidates.append(Path(tempfile.gettempdir()) / f'uncrash-{os.getuid()}' / 'native' / digest / 'uncrash-snapshot')
for binary in candidates:
try:
if binary.exists():
info = binary.stat()
if binary.is_file() and info.st_uid == os.getuid() and not (info.st_mode & 0o077):
no_links(binary)
return binary
except (OSError, RecoveryError):
continue
return None


def rust_capture(root, jobs, config, key):
if not jobs: return []
binary = no_links(config['rust_binary']) if config.get('rust_binary') else build_native(root)
binary = no_links(config['rust_binary']) if config.get('rust_binary') else (find_native_worker(root) or build_native(root))
workers = int(config.get('rust_threads', 4))
if not 1 <= workers <= 16: raise RecoveryError('Rust threads must be between 1 and 16')
header = str(workers)+'\t'+(key.hex() if key else '-')+'\t'+str(int(config.get('compress', False)))
Expand Down
Loading
Loading