From 07427e23512fb08146feb9efa1110da03c6567af Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 27 Sep 2026 07:32:45 +0000 Subject: [PATCH] fix(cli): type readiness waits and projectless refusals end to end A `tracedecay_status` `wait_for` on an index that was already current could still answer `timed_out { last_state: current }`: before reading freshness the wait swept the whole worktree (stat walk plus content digests, behind the scheduler lock), and a short budget ran out inside the sweep. A target the current reading already satisfies is now reached at once; the sweep still runs whenever the reading is pending. The wait's outcome travels as structured content beside the rendered status in every format, so `tracedecay tool status` exits 75 when the wait ends `timed_out` and 1 when it ends `unavailable`, naming the last state or reason on stderr; markdown now shows the outcome instead of `{2 field(s)}`. A compatibility tool called without a project in reach was refused by the projectless daemon route as an untyped JSON-RPC error, which the CLI flattened into `config error: daemon tool call failed: ...` with empty stdout. The refusal now carries the typed `project_required` problem (`project_not_enrolled` alike), and `tracedecay tool --format json` prints `{"problem": ...}` on stdout before exiting non-zero, as the owner-answered tools already do. --- crates/tracedecay-cli/src/cli/help.rs | 3 + crates/tracedecay-cli/src/main.rs | 8 + crates/tracedecay-cli/src/tool_command.rs | 53 +++++- .../tests/core_cli_suite/tool_daemon_test.rs | 152 +++++++++++++++++- .../registry/owner_signals.rs | 17 +- .../code_index_scheduler/tests/reconcile.rs | 58 +++++++ .../src/code_index_freshness.rs | 7 + .../src/handlers/info/status.rs | 27 +++- crates/tracedecay-mcp/src/tool_errors.rs | 51 +++--- crates/tracedecay/src/daemon/projectless.rs | 13 +- .../src/mcp/tools/handlers/dispatch_tests.rs | 92 +++++++++++ .../stale_client_resilience_test.rs | 15 +- 12 files changed, 457 insertions(+), 39 deletions(-) diff --git a/crates/tracedecay-cli/src/cli/help.rs b/crates/tracedecay-cli/src/cli/help.rs index b0b3b47b50..7c2b106b59 100644 --- a/crates/tracedecay-cli/src/cli/help.rs +++ b/crates/tracedecay-cli/src/cli/help.rs @@ -145,6 +145,9 @@ Notes: - --project targets another project; the default is the nearest initialised project walking up from the current directory. - Exit code is non-zero on unknown tools, bad arguments, or handler errors. + A `status` wait_for that ends `timed_out` exits 75 (rerun the wait); one + that ends `unavailable` exits 1. A typed daemon refusal of a JSON request + still prints `{\"problem\": …}` on stdout. Related: tracedecay serve (same tools over MCP stdio), tracedecay status."; diff --git a/crates/tracedecay-cli/src/main.rs b/crates/tracedecay-cli/src/main.rs index 588cfa9203..43322b1723 100644 --- a/crates/tracedecay-cli/src/main.rs +++ b/crates/tracedecay-cli/src/main.rs @@ -387,6 +387,10 @@ enum CommandOutcome { Exit(i32), } +/// `EX_TEMPFAIL`: a `wait_for` status read ended before the index reached +/// the requested state; rerunning the wait may reach it. +const READINESS_WAIT_TIMED_OUT_EXIT_CODE: u8 = 75; + fn process_exit_code(code: i32) -> ExitCode { ExitCode::from(u8::try_from(code).unwrap_or(1)) } @@ -621,6 +625,10 @@ fn main() -> ExitCode { Err(e) => { let code = if tracedecay_daemon_identity::daemon_unreachable(&e) { ExitCode::from(tracedecay_daemon_identity::DAEMON_UNREACHABLE_EXIT_CODE) + } else if e.project_route_context().is_some_and(|(code, _, _)| { + code == tracedecay_contracts::code_index_freshness::CODE_INDEX_READINESS_WAIT_TIMED_OUT + }) { + ExitCode::from(READINESS_WAIT_TIMED_OUT_EXIT_CODE) } else { ExitCode::FAILURE }; diff --git a/crates/tracedecay-cli/src/tool_command.rs b/crates/tracedecay-cli/src/tool_command.rs index 5b0e90f770..6f008d7869 100644 --- a/crates/tracedecay-cli/src/tool_command.rs +++ b/crates/tracedecay-cli/src/tool_command.rs @@ -49,6 +49,10 @@ use serde_json::Value; use tokio::time::{Instant, timeout_at}; use tracedecay::daemon::call_default_tool_awaiting_project_open; +use tracedecay_contracts::code_index_freshness::{ + CODE_INDEX_READINESS_WAIT_TIMED_OUT, CODE_INDEX_READINESS_WAIT_UNAVAILABLE, + CodeIndexReadinessWaitOutcomeV1, +}; use tracedecay_contracts::request_identity::{GlobalRequestSurface, mint_global_request_id}; use tracedecay_contracts::{CancellationSignal, Deadline, RetainedSurfaceOperation}; use tracedecay_daemon_protocol::{ @@ -1086,6 +1090,7 @@ async fn dispatch_compatibility_tool( // abort had already printed "outcome may be unknown", untruthful, since // the outcome was in flight. Never discard an envelope that was received. let response_bound = tracedecay::daemon::daemon_tool_response_bound(deadline)?; + let json_output = raw_json || tool_args.get("format").and_then(Value::as_str) == Some("json"); let result_value = match timeout_at( response_bound, dispatch.call(profile, tool_name, tool_args, deadline), @@ -1093,7 +1098,13 @@ async fn dispatch_compatibility_tool( .await { Ok(Ok(value)) => value, - Ok(Err(error)) => return Err(map_tool_deadline_error(tool_name, error)), + Ok(Err(error)) => { + let error = map_tool_deadline_error(tool_name, error); + if json_output { + print_project_route_problem(tool_name, &error)?; + } + return Err(error); + } Err(_) => return Err(tool_timeout_error(tool_name)), }; reject_tool_result_truncation(&result_value, tool_name)?; @@ -1106,6 +1117,19 @@ async fn dispatch_compatibility_tool( tool_result_process_outcome(&result_value, tool_name) } +/// A JSON request answered by a typed daemon refusal still gets a JSON +/// document on stdout: `{"problem": …}`, the same problem the MCP error +/// carries. The error itself goes to stderr and sets the exit status. +fn print_project_route_problem(tool_name: &str, error: &TraceDecayError) -> Result<()> { + let Some(problem) = tracedecay_mcp::tool_errors::project_route_problem(tool_name, error) else { + return Ok(()); + }; + let mut stdout = std::io::stdout().lock(); + writeln!(stdout, "{}", serde_json::json!({ "problem": problem }))?; + stdout.flush()?; + Ok(()) +} + /// The process outcome for a completed MCP tool result: `Ok` (exit 0) for a /// successful call, `Err` (nonzero exit) for one the daemon classified as an /// application failure. @@ -1123,6 +1147,33 @@ async fn dispatch_compatibility_tool( /// the status, which mirrors what the typed application-surface path already /// does in [`print_cli_application_surface`]. fn tool_result_process_outcome(result_value: &Value, tool_name: &str) -> Result<()> { + if let Some(wait) = result_value.pointer("/structuredContent/wait") { + let wait: CodeIndexReadinessWaitOutcomeV1 = serde_json::from_value(wait.clone())?; + let refusal = match wait { + CodeIndexReadinessWaitOutcomeV1::Reached => None, + CodeIndexReadinessWaitOutcomeV1::TimedOut { last_state } => { + Some(TraceDecayError::project_route( + CODE_INDEX_READINESS_WAIT_TIMED_OUT, + true, + format!( + "{tool_name} wait_for timed out before the index reached the requested \ + state; last state: {last_state}" + ), + )) + } + CodeIndexReadinessWaitOutcomeV1::Unavailable { reason } => { + Some(TraceDecayError::project_route( + CODE_INDEX_READINESS_WAIT_UNAVAILABLE, + false, + format!("{tool_name} wait_for cannot reach the requested state: {reason}"), + )) + } + }; + if let Some(refusal) = refusal { + std::io::stdout().flush()?; + return Err(refusal); + } + } if result_value.get("isError").and_then(Value::as_bool) != Some(true) { return Ok(()); } diff --git a/crates/tracedecay-cli/tests/core_cli_suite/tool_daemon_test.rs b/crates/tracedecay-cli/tests/core_cli_suite/tool_daemon_test.rs index dd059451e8..22b5811bc6 100644 --- a/crates/tracedecay-cli/tests/core_cli_suite/tool_daemon_test.rs +++ b/crates/tracedecay-cli/tests/core_cli_suite/tool_daemon_test.rs @@ -109,7 +109,13 @@ fn run_command_with_timeout(mut command: Command, timeout: Duration) -> Output { } enum FakeDaemonResponse { - Complete { text: String }, + Complete { + text: String, + }, + /// A whole tool result, for members beside `content`. + Result { + result: Value, + }, HoldOpen, } @@ -254,6 +260,16 @@ fn spawn_scripted_daemon( writeln!(writer, "{}", serde_json::to_string(&response).unwrap()) .expect("write fake daemon response"); } + FakeDaemonResponse::Result { result } => { + let response = json!({ + "jsonrpc": "2.0", + "id": request["id"].clone(), + "result": result, + }); + let mut writer = stream; + writeln!(writer, "{}", serde_json::to_string(&response).unwrap()) + .expect("write fake daemon response"); + } FakeDaemonResponse::HoldOpen => { // Keep the accepted socket open without writing a matching response. std::thread::sleep(CLI_CHILD_KILL_TIMEOUT + Duration::from_secs(2)); @@ -2706,3 +2722,137 @@ fn daemon_status_headline_is_the_daemon_when_the_service_manager_is_unreachable( ); assert!(lines.contains(&"protocol: Ready"), "{stdout}"); } + +/// A `status` wait that ends `timed_out` prints the status and exits 75, the +/// retry-later status, naming the last state; a wait that reached exits 0. +#[test] +fn tool_status_exit_follows_the_wait_outcome() { + let home = TempDir::new().unwrap(); + let project = TempDir::new().unwrap(); + let home_path = canonical_existing_path(home.path()); + let project_path = canonical_existing_path(project.path()); + init_project_with_cli(&home_path, &project_path); + let run = |wait: Value| { + let socket_dir = TempDir::new().unwrap(); + let socket_path = socket_dir.path().join("tracedecay.sock"); + let observed = spawn_scripted_daemon( + socket_path.clone(), + "tracedecay_status", + FakeDaemonResponse::Result { + result: json!({ + "content": [{"type": "text", "text": "## Project Status\n- **wait**: …"}], + "structuredContent": {"wait": wait}, + }), + }, + ); + let mut command = tracedecay_command_with_home(&home_path); + command + .current_dir(&project_path) + .env("TRACEDECAY_DAEMON_SOCKET", &socket_path) + .args([ + "tool", + "status", + "--args", + r#"{"wait_for":{"state":"fresh","timeout_ms":1000}}"#, + ]); + let output = run_command_with_timeout(command, CLI_ROUNDTRIP_TIMEOUT); + let request = observed + .recv_timeout(CLI_ROUNDTRIP_TIMEOUT) + .expect("the status call reached the daemon"); + assert_eq!( + request["params"]["arguments"]["wait_for"], + json!({"state": "fresh", "timeout_ms": 1000}) + ); + output + }; + + let timed_out = run(json!({"outcome": "timed_out", "last_state": "warming"})); + let stderr = String::from_utf8_lossy(&timed_out.stderr); + assert_eq!(timed_out.status.code(), Some(75), "{stderr}"); + assert_eq!( + String::from_utf8_lossy(&timed_out.stdout), + "## Project Status\n- **wait**: …\n" + ); + assert!( + stderr.contains( + "tracedecay_status wait_for timed out before the index reached the requested \ + state; last state: warming" + ), + "{stderr}" + ); + + let reached = run(json!({"outcome": "reached"})); + assert_eq!( + reached.status.code(), + Some(0), + "{}", + String::from_utf8_lossy(&reached.stderr) + ); +} + +/// A JSON request the daemon refuses because no project is in reach still +/// prints the typed problem on stdout, and the process exits non-zero, on +/// the owner-answered route (`search`) and the compatibility route +/// (`status`) alike. +#[test] +fn projectless_json_tool_call_prints_the_typed_refusal() { + let home = TempDir::new().unwrap(); + let home = canonical_existing_path(home.path()); + let outside = TempDir::new().unwrap(); + let _daemon = spawn_tracedecay_daemon(&home); + let run = |args: &[&str]| { + let output = tracedecay_command_with_home(&home) + .current_dir(outside.path()) + .arg("tool") + .args(args) + .stdin(Stdio::null()) + .output() + .expect("tracedecay tool should run"); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + serde_json::from_slice::(&output.stdout).unwrap_or_else(|error| { + panic!( + "stdout is not one JSON document ({error}): {}", + String::from_utf8_lossy(&output.stdout) + ) + }) + }; + + let search = run(&["search", "--query", "daemon", "--format", "json"]); + let problem = &search["problem"]; + assert_eq!( + ( + &problem["kind"], + &problem["code"], + &problem["detail"], + &problem["retryable"], + &problem["legal_actions"], + ), + ( + &json!("invalid_request"), + &json!("project_required"), + &Value::Null, + &json!(false), + &json!(["correct_request"]), + ), + "{search}" + ); + assert_eq!( + problem["message"], + "this operation needs a TraceDecay project, and the request named none; run it \ + inside an initialized project or pass --project " + ); + + let status = run(&["status", "--format", "json"]); + assert_eq!( + status, + json!({"problem": { + "tool": "tracedecay_status", + "kind": "invalid_request", + "code": "project_required", + "reason_code": "project_required", + "retryable": false, + "detail": "tracedecay_status requires an initialized code project; run it inside \ + an initialized project or pass --project ", + }}) + ); +} diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/owner_signals.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/owner_signals.rs index e05bc1b2b4..77d39b8d1b 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/owner_signals.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/registry/owner_signals.rs @@ -197,9 +197,13 @@ impl CodeIndexSchedulerRegistryV1 { /// Wait until `project_root` reaches `target`, re-reading freshness only /// when the registry publishes a change, for at most `budget`. /// - /// The wait first proves freshness against the source as it is now: the - /// bounded Git/stat/content probe either refreshes the verified watermark - /// or posts the wake for a proven change, so a reading taken after it + /// A target the current reading already satisfies is reached at once: + /// that reading is the scheduler's last proof, the answer a plain status + /// read gives, so a save no hook reported is left to the backstop sweep + /// rather than swept inside the caller's budget. Otherwise the wait proves + /// freshness against the source as it is now: the bounded + /// Git/stat/content probe either refreshes the verified watermark or + /// posts the wake for a proven change, so a reading taken after it /// cannot report an edit the scheduler has not yet seen as fresh. /// `graph_ready` does not depend on freshness and skips that probe. An /// unmounted root is waited through: a mount that lands inside the budget @@ -212,6 +216,13 @@ impl CodeIndexSchedulerRegistryV1 { budget: Duration, ) -> Result { let deadline = tokio::time::Instant::now() + budget; + if self + .dashboard_freshness_read(project_root) + .await? + .is_some_and(|freshness| freshness.readiness(target) == CodeIndexReadinessV1::Reached) + { + return Ok(CodeIndexReadinessWaitReadV1::Reached); + } let mut signals = CodeIndexOwnerSignalsV1::subscribe(self, project_root).await; // The probe can take the scheduler mutex; the caller's budget bounds // it, and an unproven source cannot be reported as reached. diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/reconcile.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/reconcile.rs index 59839410bf..573caa6f3c 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/reconcile.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/tests/reconcile.rs @@ -6563,6 +6563,64 @@ async fn readiness_wait_reaches_ready_exactly_when_the_held_graph_publishes() { registry.shutdown().await; } +/// A settled worktree already satisfies `fresh`, so a short wait reaches it +/// even while another holder owns the scheduler lock the source sweep would +/// need; the busy-read ladder still reports the served generation current. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn readiness_wait_reaches_a_target_the_current_reading_already_holds() { + let fixture = GitFixture::new(&[("src/lib.rs", "pub fn source() -> u32 { 1 }\n")]); + let store = TempDir::new().expect("store root"); + let registry = CodeIndexSchedulerRegistryV1::with_background_reconcile_permits(1, 1); + registry + .mount_worktree( + test_project_id(), + fixture.path(), + store.path().to_path_buf(), + ) + .await + .expect("mount worktree"); + wait_for_initial_generation(®istry, fixture.path()).await; + settled_owner_with_idle_admission(®istry, fixture.path()).await; + let fresh = tracedecay_contracts::code_index_freshness::CodeIndexReadinessTargetV1::Fresh; + assert!(matches!( + registry + .wait_for_readiness(fixture.path(), fresh, SERVING_SEAT_FAILURE_CEILING) + .await + .expect("freshness read"), + tracedecay_contracts::code_index_freshness::CodeIndexReadinessWaitReadV1::Reached + )); + + let handle = registry + .scheduler_handle(fixture.path()) + .await + .expect("mounted scheduler"); + let (held_tx, held_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel::<()>(); + let lock_thread = std::thread::spawn(move || { + let _guard = handle + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + held_tx.send(()).expect("signal scheduler lock held"); + let _ = release_rx.recv(); + }); + held_rx.recv().expect("scheduler lock acquired"); + + let held = registry + .wait_for_readiness(fixture.path(), fresh, Duration::from_millis(200)) + .await + .expect("freshness read"); + release_tx.send(()).expect("release scheduler lock"); + lock_thread.join().expect("lock thread joins"); + assert!( + matches!( + held, + tracedecay_contracts::code_index_freshness::CodeIndexReadinessWaitReadV1::Reached + ), + "a current worktree must not time out behind the source sweep: {held:?}" + ); + registry.shutdown().await; +} + /// A pass can start and settle entirely between two reads of the running /// level. The owner-activity counts only grow, so a reader that looks after /// the pass still sees it, and the worker phase says the pass and its tail diff --git a/crates/tracedecay-contracts/src/code_index_freshness.rs b/crates/tracedecay-contracts/src/code_index_freshness.rs index 1ab75c9531..c7c9ff60b9 100644 --- a/crates/tracedecay-contracts/src/code_index_freshness.rs +++ b/crates/tracedecay-contracts/src/code_index_freshness.rs @@ -619,6 +619,13 @@ pub enum CodeIndexReadinessWaitOutcomeV1 { }, } +/// Reason code of a refusal reporting a `timed_out` wait outcome; retrying +/// the wait may reach the target. +pub const CODE_INDEX_READINESS_WAIT_TIMED_OUT: &str = "code_index_readiness_wait_timed_out"; + +/// Reason code of a refusal reporting an `unavailable` wait outcome. +pub const CODE_INDEX_READINESS_WAIT_UNAVAILABLE: &str = "code_index_readiness_wait_unavailable"; + /// Whether one freshness reading satisfies a readiness target. #[derive(Clone, Debug, PartialEq, Eq)] pub enum CodeIndexReadinessV1 { diff --git a/crates/tracedecay-mcp/src/handlers/info/status.rs b/crates/tracedecay-mcp/src/handlers/info/status.rs index 859afa475e..1bca967055 100644 --- a/crates/tracedecay-mcp/src/handlers/info/status.rs +++ b/crates/tracedecay-mcp/src/handlers/info/status.rs @@ -583,17 +583,25 @@ pub async fn handle_status( if let Some(prefix) = scope_prefix { output["scope_prefix"] = json!(prefix); } - if let Some(wait) = wait { - output["wait"] = serde_json::to_value(wait)?; + let wait = wait.map(serde_json::to_value).transpose()?; + if let Some(wait) = &wait { + output["wait"] = wait.clone(); } - Ok(rendered_tool_result( + let mut result = rendered_tool_result( Some(&ctx.store_layout().response_handle_root), &args, &output, vec![], || render_status_md(&output), - )) + ); + // Structured content beside the rendered body in every format, like a + // typed `problem`, so a caller such as `tracedecay tool` can type its + // exit status on the outcome. + if let (Some(wait), Some(object)) = (wait, result.value.as_object_mut()) { + object.insert("structuredContent".to_owned(), json!({ "wait": wait })); + } + Ok(result) } /// Project one freshness reading into the operator-facing status label and @@ -781,6 +789,17 @@ fn render_status_md(value: &Value) -> String { } } } + Value::Object(o) if k == "wait" => { + let outcome = o.get("outcome").and_then(Value::as_str).unwrap_or_default(); + match o + .get("last_state") + .or_else(|| o.get("reason")) + .and_then(Value::as_str) + { + Some(detail) => md.field(k, &format!("{outcome} ({detail})")), + None => md.field(k, outcome), + }; + } Value::Object(o) => { if let Some(status) = o.get("status").and_then(Value::as_str) { md.field(&format!("{k}.status"), status); diff --git a/crates/tracedecay-mcp/src/tool_errors.rs b/crates/tracedecay-mcp/src/tool_errors.rs index 342aa505f5..47d99fbfe6 100644 --- a/crates/tracedecay-mcp/src/tool_errors.rs +++ b/crates/tracedecay-mcp/src/tool_errors.rs @@ -133,12 +133,41 @@ fn project_route_problem_kind(reason_code: &str) -> Option<&'static str> { "tool_dispatch_shutdown" | "mcp_dispatch_effect_journey_unverified" | "application_surface_unavailable" => Some("unavailable"), - "application_surface_invalid_request" => Some("invalid_request"), + "application_surface_invalid_request" | "project_required" | "project_not_enrolled" => { + Some("invalid_request") + } "application_surface_not_found_or_not_authorized" => Some("denied"), _ => None, } } +/// The typed problem a project-route refusal of `tool_name` carries: the +/// JSON-RPC error `data` of an MCP tool call, and the `problem` member +/// `tracedecay tool` prints for a JSON request. `None` for any other error. +#[must_use] +pub fn project_route_problem(tool_name: &str, error: &TraceDecayError) -> Option { + let (reason_code, retryable, detail) = error.project_route_context()?; + let mut data = json!({ + "tool": tool_name, + "reason_code": reason_code, + "retryable": retryable, + "detail": detail, + }); + if let (Some(typed_detail), Some(object)) = + (error.project_route_typed_detail(), data.as_object_mut()) + { + object.insert("detail".to_string(), json!(typed_detail)); + } + if let (Some(kind), Some(object)) = ( + project_route_problem_kind(reason_code), + data.as_object_mut(), + ) { + object.insert("kind".to_string(), json!(kind)); + object.insert("code".to_string(), json!(reason_code)); + } + Some(data) +} + /// Map response-handle failures onto actionable JSON-RPC errors at the MCP /// boundary so clients can distinguish bad input from cache/runtime problems. #[must_use] @@ -149,31 +178,13 @@ pub fn tool_error_response(id: Value, tool_name: &str, error: &TraceDecayError) } else { ErrorCode::InvalidParams }; - let mut data = json!({ - "tool": tool_name, - "reason_code": reason_code, - "retryable": retryable, - "detail": detail, - }); - if let (Some(typed_detail), Some(object)) = - (error.project_route_typed_detail(), data.as_object_mut()) - { - object.insert("detail".to_string(), json!(typed_detail)); - } - if let (Some(kind), Some(object)) = ( - project_route_problem_kind(reason_code), - data.as_object_mut(), - ) { - object.insert("kind".to_string(), json!(kind)); - object.insert("code".to_string(), json!(reason_code)); - } return JsonRpcResponse::error_with_data( id, code, format!( "tool project route failed: reason_code={reason_code} retryable={retryable}: {detail}" ), - Some(data), + project_route_problem(tool_name, error), ); } if tool_name == "tracedecay_hook_runtime" diff --git a/crates/tracedecay/src/daemon/projectless.rs b/crates/tracedecay/src/daemon/projectless.rs index 5e11b71803..7edeb9ccad 100644 --- a/crates/tracedecay/src/daemon/projectless.rs +++ b/crates/tracedecay/src/daemon/projectless.rs @@ -380,10 +380,17 @@ async fn projectless_tools_call_response_with_connection( } fn requires_project_error(id: serde_json::Value, tool_name: &str) -> JsonRpcResponse { - JsonRpcResponse::error( + tool_error_response( id, - ErrorCode::InternalError, - format!("{tool_name} requires an initialized code project"), + tool_name, + &TraceDecayError::project_route( + PROJECT_REQUIRED_REASON_CODE, + false, + format!( + "{tool_name} requires an initialized code project; run it inside an \ + initialized project or pass --project " + ), + ), ) } diff --git a/crates/tracedecay/src/mcp/tools/handlers/dispatch_tests.rs b/crates/tracedecay/src/mcp/tools/handlers/dispatch_tests.rs index 6836da740b..0d3785462c 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/dispatch_tests.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/dispatch_tests.rs @@ -556,6 +556,98 @@ async fn status_and_runtime_share_cursor_session_ingest_authority() { cg.close(); } +/// A `wait_for` status read carries its outcome beside the body in both +/// formats, so a caller types its exit on it without parsing the rendering, +/// and a budget this call cannot live out is refused by naming that budget. +#[tokio::test] +async fn status_wait_outcome_rides_beside_the_body_and_the_budget_refusal_names_it() { + let dir = TempDir::new().unwrap(); + let profile = SelectorProfile::new(dir.path()); + let project = dir.path().join("status-wait-outcome"); + fs::create_dir_all(project.join("src")).unwrap(); + fs::write(project.join("src/lib.rs"), "pub fn probe() {}\n").unwrap(); + let (cg, _runtime) = TraceDecay::init_test_fixture_with_registered_runtime( + profile.data_dir(), + &project, + "project.mcp-status-wait-outcome", + ) + .await + .unwrap(); + let waiter: tracedecay_contracts::code_index_freshness::CodeIndexReadinessWaiter = + std::sync::Arc::new(|worktree_root, _, _| { + Box::pin(async move { + Ok( + tracedecay_contracts::code_index_freshness::CodeIndexReadinessWaitReadV1::TimedOut { + last: Some(Box::new( + tracedecay_contracts::code_index_freshness::CodeIndexWorktreeFreshnessV1 { + worktree_root: worktree_root.display().to_string(), + staleness_state: Some( + tracedecay_contracts::code_index_freshness::CodeIndexStalenessStateV1::Indexing, + ), + rebuild_in_flight: true, + ..Default::default() + }, + )), + }, + ) + }) + }); + let status = |args: Value| { + handle_tool_call_with_registry_options( + &cg, + "tracedecay_status", + args, + None, + None, + ToolCallRegistryOptions { + code_index_readiness_waiter: Some(std::sync::Arc::clone(&waiter)), + ..Default::default() + } + .admit_opened_project(&cg) + .expect("opened fixture admits"), + ) + }; + + let markdown = status(json!({"wait_for": {"state": "fresh", "timeout_ms": 1000}})) + .await + .expect("a timed-out wait still answers the status"); + let expected_wait = json!({"outcome": "timed_out", "last_state": "warming"}); + assert_eq!( + markdown.value["structuredContent"], + json!({"wait": expected_wait}), + "{}", + markdown.value + ); + let text = markdown.value["content"][0]["text"] + .as_str() + .expect("status markdown"); + assert!( + text.lines() + .any(|line| line == "**wait:** timed_out (warming)"), + "{text}" + ); + let json_status = status(json!({ + "format": "json", + "wait_for": {"state": "fresh", "timeout_ms": 1000}, + })) + .await + .expect("a timed-out wait still answers the status"); + assert_eq!( + json_status.value["structuredContent"], + json!({"wait": expected_wait}) + ); + + let refused = status(json!({"wait_for": {"state": "fresh", "timeout_ms": 120_001}})) + .await + .expect_err("a wait longer than the dispatch ceiling is refused"); + assert_eq!( + refused.to_string(), + "config error: tracedecay_status wait_for.timeout_ms 120001 exceeds this \ + call's 120000 ms dispatch budget" + ); + cg.close(); +} + /// Status must report the serving truth the retrieval lanes enforce. On a /// fresh daemon the census answers before any generation seals; claiming /// `serving_branch` there contradicted every lane's truthful diff --git a/crates/tracedecay/tests/daemon_suite/stale_client_resilience_test.rs b/crates/tracedecay/tests/daemon_suite/stale_client_resilience_test.rs index 485a66a146..7a10652b66 100644 --- a/crates/tracedecay/tests/daemon_suite/stale_client_resilience_test.rs +++ b/crates/tracedecay/tests/daemon_suite/stale_client_resilience_test.rs @@ -77,13 +77,14 @@ async fn version_skewed_client_cannot_crash_the_daemon() { .await .expect("skewed projectless tools/call timed out") .expect_err("a project tool on a projectless connection must be refused"); - let refusal = refusal.to_string(); - assert!( - refusal.contains("daemon tool call failed"), - "skewed client must receive a daemon-authored response, not a transport failure: {refusal}" - ); - assert!( - refusal.contains("requires an initialized code project"), + assert_eq!( + refusal.project_route_context(), + Some(( + "project_required", + false, + "tracedecay_search requires an initialized code project; run it inside an \ + initialized project or pass --project " + )), "skewed client must receive the typed projectless refusal: {refusal}" );