diff --git a/LEAN-CTX.md b/LEAN-CTX.md index 16c2b3e0b0..a9147dd0f9 100644 --- a/LEAN-CTX.md +++ b/LEAN-CTX.md @@ -43,7 +43,7 @@ NAVIGATION PARADOX: reading more ≠ understanding more. PARALLEL: fire independent tool calls in the SAME turn — ctx_compose bundles multiple lookups into one call. -Auto: preload/dedup/compress run in background. ctx_session=memory, ctx_knowledge=facts, ctx_shell raw=true=uncompressed. Full guide: LEAN-CTX.md +Long shell jobs: ctx_shell(run_in_background=true), then poll job_id. ctx_session=memory; full guide: LEAN-CTX.md RECOVER: compressed output is reversible — never re-read line-by-line. Need full/exact? Read the shown file path with any tool (no MCP), or ctx_read(mode=full|raw=true); [Archived]/tee/firewall → ctx_expand(id=...). diff --git a/rust/LEAN-CTX.md b/rust/LEAN-CTX.md index 16c2b3e0b0..a9147dd0f9 100644 --- a/rust/LEAN-CTX.md +++ b/rust/LEAN-CTX.md @@ -43,7 +43,7 @@ NAVIGATION PARADOX: reading more ≠ understanding more. PARALLEL: fire independent tool calls in the SAME turn — ctx_compose bundles multiple lookups into one call. -Auto: preload/dedup/compress run in background. ctx_session=memory, ctx_knowledge=facts, ctx_shell raw=true=uncompressed. Full guide: LEAN-CTX.md +Long shell jobs: ctx_shell(run_in_background=true), then poll job_id. ctx_session=memory; full guide: LEAN-CTX.md RECOVER: compressed output is reversible — never re-read line-by-line. Need full/exact? Read the shown file path with any tool (no MCP), or ctx_read(mode=full|raw=true); [Archived]/tee/firewall → ctx_expand(id=...). diff --git a/rust/LOCK_ORDERING.md b/rust/LOCK_ORDERING.md index 20660649eb..f7e9e9331f 100644 --- a/rust/LOCK_ORDERING.md +++ b/rust/LOCK_ORDERING.md @@ -77,6 +77,7 @@ All `std::sync::Mutex` unless noted otherwise. | L64 | `SESSION_READ_ONLY_ROOTS` | `core/pathjail.rs:143` | `OnceLock>>` | Session-scoped read-only roots auto-detected from language caches (#899); consulted by both read allow-list (jail) and `is_read_only_path` (write-deny) so a cache root is readable but never writable; independent leaf lock, never nested | | L65 | `SESSIONS` | `proxy/sticky_tools.rs:17` | `OnceLock>>` (fn-local static) | CCR active conversation tracking; capacity-bounded (`MAX_TRACKED`), oldest-first eviction on overflow; independent leaf lock, never nested | | L66 | `JOBS` | `server/background_shell.rs:26` | `LazyLock>>` | Content-addressed background shell jobs; lock is held only to insert, inspect, or remove a job, then released before executing or joining its worker thread; independent leaf lock, never nested | +| L67 | `BUILD_GATE` | `core/graph_provider.rs:659` | `LazyLock>` | Serializes synchronous graph-index builds; acquired with `try_lock()` and held only while the spawned builder is awaited, never nested with another static lock | ### Test / Environment Locks (serialise env-var mutations) @@ -208,9 +209,9 @@ Override via `LEAN_CTX_WORKER_THREADS` (positive integer) for environments with concurrent subagents. Example: `LEAN_CTX_WORKER_THREADS=8`. The blocking thread pool is always `worker_threads * 4`, clamped to `[8, 32]`. -### Independent Static Locks (L3–L66) +### Independent Static Locks (L3–L67) -All other static locks (L3–L66) — **except the L22 → L4 pair documented above** — are +All other static locks (L3–L67) — **except the L22 → L4 pair documented above** — are **independent singletons**: they protect isolated subsystem state and are never nested inside each other. Each should be acquired in isolation: diff --git a/rust/src/core/rules_canonical.rs b/rust/src/core/rules_canonical.rs index 45da3e7081..d813c4fe36 100644 --- a/rust/src/core/rules_canonical.rs +++ b/rust/src/core/rules_canonical.rs @@ -166,9 +166,8 @@ NAVIGATION PARADOX: reading more ≠ understanding more.\n\ • Navigate structure (signatures, symbols) before reading entire files"; /// One-line automation reminder. -pub const AUTO: &str = "Auto: preload/dedup/compress run in background. \ - ctx_session=memory, ctx_knowledge=facts, ctx_shell raw=true=uncompressed. \ - Full guide: LEAN-CTX.md"; +pub const AUTO: &str = "Long shell jobs: ctx_shell(run_in_background=true), then poll job_id. \ + ctx_session=memory; full guide: LEAN-CTX.md"; /// Recovery vocabulary (verbose, LONGFORM profile). lean-ctx compression is fully /// reversible (CCR), but agents otherwise only discover the escape hatch reactively diff --git a/rust/src/hook_handlers/codex.rs b/rust/src/hook_handlers/codex.rs index 3853dc861a..a04c5b930e 100644 --- a/rust/src/hook_handlers/codex.rs +++ b/rust/src/hook_handlers/codex.rs @@ -29,7 +29,6 @@ pub fn handle_codex_pretooluse() { } let binary = resolve_binary(); let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else { - // #809: always emit valid JSON — empty stdout is invalid for Codex CLI. print!("{}", codex_allow_output()); return; }; @@ -79,7 +78,6 @@ pub fn handle_codex_pretooluse() { if mode == crate::hooks::HookMode::Replace { print!("{}", codex_deny_output(cmd)); } else { - // #809: always emit valid JSON — Codex CLI requires it. print!("{}", codex_allow_output()); } } @@ -100,16 +98,12 @@ pub(super) fn codex_deny_output(original_cmd: &str) -> String { } /// Allow-passthrough output for the Codex PreToolUse hook (#809). -/// Every code path must emit valid JSON — Codex CLI parses stdout as JSON -/// and reports "invalid pre-tool-use JSON output" on empty/malformed output. +/// Codex treats an exit-0 hook with no stdout as an allowed tool call. Its +/// `permissionDecision: "allow"` form is only valid when paired with +/// `updatedInput`; emitting it for an unchanged command makes current Codex +/// report `unsupported permissionDecision:allow` (#1019). pub(super) fn codex_allow_output() -> String { - serde_json::json!({ - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "allow" - } - }) - .to_string() + String::new() } /// Emit SessionStart guidance through Codex's documented hidden-context channel. @@ -189,15 +183,8 @@ mod tests { // This is the deny message format — verify it exists for native commands assert!(deny_msg.contains("deny"), "deny output must contain deny"); - // The allow output must NOT contain deny + // A successful PreToolUse hook with no output allows the command. let allow_msg = codex_allow_output(); - assert!( - allow_msg.contains("allow"), - "allow output must contain allow" - ); - assert!( - !allow_msg.contains("deny"), - "allow output must not contain deny" - ); + assert!(allow_msg.is_empty(), "allow output must be empty"); } } diff --git a/rust/src/hook_handlers/tests.rs b/rust/src/hook_handlers/tests.rs index 6680c19bb0..86dd37de82 100644 --- a/rust/src/hook_handlers/tests.rs +++ b/rust/src/hook_handlers/tests.rs @@ -463,14 +463,11 @@ fn codex_rewrite_output_uses_native_updated_input_contract() { ); } -/// #809: codex_allow_output produces valid JSON with allow decision. +/// An unchanged PreToolUse call is allowed by a successful hook with no stdout. #[test] -fn codex_allow_output_is_valid_json() { +fn codex_allow_output_is_empty() { let output = codex_allow_output(); - let parsed: serde_json::Value = - serde_json::from_str(&output).expect("codex_allow_output must be valid JSON"); - assert_eq!(parsed["hookSpecificOutput"]["hookEventName"], "PreToolUse"); - assert_eq!(parsed["hookSpecificOutput"]["permissionDecision"], "allow"); + assert!(output.is_empty()); } /// #809: codex_deny_output with heavily escaped content is valid JSON. diff --git a/rust/src/tools/registered/ctx_shell.rs b/rust/src/tools/registered/ctx_shell.rs index 338abc6a5e..c5edb4ae3e 100644 --- a/rust/src/tools/registered/ctx_shell.rs +++ b/rust/src/tools/registered/ctx_shell.rs @@ -265,15 +265,21 @@ impl McpTool for CtxShellTool { }) .unwrap_or_default(); - if get_bool(args, "run_in_background").unwrap_or(false) { + let auto_background = should_auto_background(&cmd_clone, timeout_ms); + if get_bool(args, "run_in_background").unwrap_or(false) || auto_background { let job_id = crate::server::background_shell::start( cmd_clone, cwd_clone, extra_env, timeout_ms, ); + let mode = if auto_background { + "auto-background" + } else { + "background" + }; return Ok(ToolOutput { shell_outcome: Some(ShellOutcome::Exit(0)), content_blocks: None, ..ToolOutput::simple(format!( - "[background:{job_id} started — use ctx_shell(background_action=\"status\", job_id=\"{job_id}\") to poll or background_action=\"cancel\" to stop it]" + "[{mode}:{job_id} started — use ctx_shell(background_action=\"status\", job_id=\"{job_id}\") to poll or background_action=\"cancel\" to stop it]" )) }); } @@ -566,6 +572,17 @@ fn redact_shell_output_secrets(output: &str) -> String { redacted } +/// The Codex MCP client abandons a tool call after five minutes. A Cargo test +/// with an explicit five-minute-or-longer shell timeout cannot reliably finish +/// inside that transport deadline (and can wait on Cargo's target lock), so +/// detach it and return a pollable job instead. +fn should_auto_background(command: &str, timeout_ms: Option) -> bool { + timeout_ms.is_some_and(|timeout| timeout >= 300_000) + && command + .lines() + .any(|line| line.trim_start().starts_with("cargo test")) +} + /// #842: detect a bare `cat ` command (no pipes, redirects, flags). fn detect_bare_cat_file(command: &str) -> Option { let trimmed = command.trim(); @@ -595,7 +612,17 @@ fn detect_bare_cat_file(command: &str) -> Option { #[cfg(test)] mod tests { - use super::is_timeout_notice_only; + use super::{is_timeout_notice_only, should_auto_background}; + + #[test] + fn long_cargo_test_is_auto_backgrounded() { + assert!(should_auto_background( + "cargo test --lib a\ncargo test --lib b", + Some(3_600_000) + )); + assert!(should_auto_background("cargo test --lib a", Some(300_000))); + assert!(!should_auto_background("cargo test --lib a", Some(299_999))); + } #[test] fn timeout_notice_without_child_output_is_not_recoverable() { diff --git a/rust/tests/ctx_compose_scenarios.rs b/rust/tests/ctx_compose_scenarios.rs index 8d35bcc498..bfe75156a1 100644 --- a/rust/tests/ctx_compose_scenarios.rs +++ b/rust/tests/ctx_compose_scenarios.rs @@ -187,6 +187,11 @@ fn compose_disambiguates_same_named_symbol_by_task_keywords() { // SAFETY: the suite runs with --test-threads=1 (see ci.yml). unsafe { std::env::remove_var("LEAN_CTX_COMPOSE_BUDGET_MS") }; let dir = write_ambiguous_symbol_corpus(); + // `ctx_compose` reads symbol bodies from the graph index. Build the tiny + // fixture explicitly so this scenario verifies disambiguation rather than + // depending on whether a prior test happened to warm the index. + lean_ctx::core::graph_provider::build_property_graph(&dir.path().to_string_lossy()) + .expect("ambiguous-symbol corpus graph must build"); // "OCPP" in the task keywords points the symbol picker at ocpp.rs. let (out, _tokens) = ctx_compose::handle( diff --git a/rust/tests/shell_and_agent_tests.rs b/rust/tests/shell_and_agent_tests.rs index f1599a23b2..4e52d779d8 100644 --- a/rust/tests/shell_and_agent_tests.rs +++ b/rust/tests/shell_and_agent_tests.rs @@ -479,7 +479,7 @@ fn agent_init_lists_grok_in_supported() { } #[test] -fn agent_init_grok_writes_mcp_hooks_and_skill() { +fn agent_init_grok_writes_mcp_without_unsupported_hooks() { let tmpdir = tempfile::tempdir().expect("create tempdir"); let home = tmpdir.path(); std::fs::create_dir_all(home.join(".grok")).unwrap(); @@ -515,10 +515,9 @@ fn agent_init_grok_writes_mcp_hooks_and_skill() { "MCP section missing: {config}" ); - let hooks = std::fs::read_to_string(home.join(".grok/hooks/lean-ctx.json")).unwrap(); assert!( - hooks.contains("hook rewrite") && hooks.contains("PreToolUse"), - "hooks missing rewrite: {hooks}" + !home.join(".grok/hooks/lean-ctx.json").exists(), + "Grok Build's documented integration is MCP-only; do not install an unverified hook format" ); let skill = home.join(".grok/skills/lean-ctx/SKILL.md");