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
3 changes: 3 additions & 0 deletions crates/tracedecay-cli/src/cli/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,9 @@ Notes:
- --project <path> 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.";

Expand Down
8 changes: 8 additions & 0 deletions crates/tracedecay-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down Expand Up @@ -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
};
Expand Down
53 changes: 52 additions & 1 deletion crates/tracedecay-cli/src/tool_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -1086,14 +1090,21 @@ 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),
)
.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)?;
Expand All @@ -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.
Expand All @@ -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(());
}
Expand Down
152 changes: 151 additions & 1 deletion crates/tracedecay-cli/tests/core_cli_suite/tool_daemon_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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::<Value>(&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 <path>"
);

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 <path>",
}})
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -212,6 +216,13 @@ impl CodeIndexSchedulerRegistryV1 {
budget: Duration,
) -> Result<CodeIndexReadinessWaitReadV1, CodeIndexFreshnessReadFailureV1> {
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);
Comment on lines +219 to +224

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Revalidate fresh targets before returning reached

When a file changes after the last settled generation without a hook notification—for example, an editor or script writes directly before the backstop sweep—the cached dashboard reading still reports Fresh, so this new short circuit returns Reached without request_fresh_now detecting the edit. Consequently, both fresh and ready waits can succeed while retrieval still serves the previous source; restrict the cache-only fast path to GraphReady, or otherwise prove the current source before returning.

AGENTS.md reference: AGENTS.md:L192-L194

Useful? React with 👍 / 👎.

}
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(&registry, fixture.path()).await;
settled_owner_with_idle_admission(&registry, 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
Expand Down
7 changes: 7 additions & 0 deletions crates/tracedecay-contracts/src/code_index_freshness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading