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
2 changes: 1 addition & 1 deletion crates/tracedecay-cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,5 @@ pub(crate) use settings::{
};
pub(crate) use storage::{
ProfileOfflineAuthority, annotate_reset_required, handle_list, handle_wipe,
join_outcome_and_restore, take_profile_offline, try_admit_profile_registry,
join_outcome_and_restore, process_error_text, take_profile_offline, try_admit_profile_registry,
};
139 changes: 105 additions & 34 deletions crates/tracedecay-cli/src/commands/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -425,38 +425,34 @@ mod init_bootstrap_tests {

use super::daemon_precondition_tests::SocketEnvGuard;

/// Init's "daemon code-index reconciliation requested" must describe a
/// request that actually crossed the wire: admission first, then the
/// explicit `tracedecay_admin_sync` reconcile. Without the second call the
/// first index only starts if the background full-server upgrade survives
/// long enough to demand it, which a daemon restart silently discards.
#[tokio::test]
async fn brokered_init_requests_a_real_code_index_reconciliation() {
static SOCKET_ENV_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());

type RecordedRequests = std::sync::Arc<std::sync::Mutex<Vec<(String, serde_json::Value)>>>;

/// A daemon on `socket` that authenticates `connections` requests and
/// answers each with `respond(tool_name, request_id)`.
fn spawn_fixture_daemon(
temp: &Path,
socket: &Path,
connections: usize,
respond: fn(&str, serde_json::Value) -> serde_json::Value,
) -> (tokio::task::JoinHandle<()>, RecordedRequests) {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};

static SOCKET_ENV_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
let _serialize = SOCKET_ENV_TEST_LOCK.lock().await;
let temp = tempfile::TempDir::new().unwrap();
let project = temp.path().join("project");
let profile = temp.path().join("profile");
std::fs::create_dir_all(&project).unwrap();
let socket = temp.path().join("daemon.sock");
let authority = tracedecay_daemon_identity::authority::DaemonAuthority::acquire(
temp.path(),
&tracedecay_daemon_protocol::DaemonEndpoint::Unix(socket.clone()),
temp,
&tracedecay_daemon_protocol::DaemonEndpoint::Unix(socket.to_path_buf()),
env!("CARGO_PKG_VERSION"),
)
.expect("publish the fixture daemon's authority record");
let auth_token = authority.auth_token().to_owned();
let listener = tokio::net::UnixListener::bind(&socket).unwrap();
let _socket_env = SocketEnvGuard::set(&socket);

let recorded: std::sync::Arc<std::sync::Mutex<Vec<(String, serde_json::Value)>>> =
std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let listener = tokio::net::UnixListener::bind(socket).unwrap();
let recorded = RecordedRequests::default();
let responder = {
let recorded = std::sync::Arc::clone(&recorded);
tokio::spawn(async move {
for _ in 0..2 {
let _authority = authority;
for _ in 0..connections {
let (stream, _addr) = listener.accept().await.unwrap();
let (reader, mut writer) = stream.into_split();
let mut lines = tokio::io::BufReader::new(reader).lines();
Expand All @@ -470,18 +466,15 @@ mod init_bootstrap_tests {
let _handshake_line = lines.next_line().await.unwrap().unwrap();
let request_line = lines.next_line().await.unwrap().unwrap();
let request: serde_json::Value = serde_json::from_str(&request_line).unwrap();
recorded.lock().unwrap().push((
request["params"]["name"]
.as_str()
.unwrap_or_default()
.to_owned(),
request["params"]["arguments"].clone(),
));
let response = serde_json::json!({
"jsonrpc": "2.0",
"id": request["id"],
"result": { "content": [] },
});
let name = request["params"]["name"]
.as_str()
.unwrap_or_default()
.to_owned();
recorded
.lock()
.unwrap()
.push((name.clone(), request["params"]["arguments"].clone()));
let response = respond(&name, request["id"].clone());
writer
.write_all(serde_json::to_string(&response).unwrap().as_bytes())
.await
Expand All @@ -491,6 +484,33 @@ mod init_bootstrap_tests {
}
})
};
(responder, recorded)
}

fn empty_tool_result(_tool: &str, id: serde_json::Value) -> serde_json::Value {
serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"result": { "content": [] },
})
}

/// Init's "daemon code-index reconciliation requested" must describe a
/// request that actually crossed the wire: admission first, then the
/// explicit `tracedecay_admin_sync` reconcile. Without the second call the
/// first index only starts if the background full-server upgrade survives
/// long enough to demand it, which a daemon restart silently discards.
#[tokio::test]
async fn brokered_init_requests_a_real_code_index_reconciliation() {
let _serialize = SOCKET_ENV_TEST_LOCK.lock().await;
let temp = tempfile::TempDir::new().unwrap();
let project = temp.path().join("project");
let profile = temp.path().join("profile");
std::fs::create_dir_all(&project).unwrap();
let socket = temp.path().join("daemon.sock");
let (responder, recorded) =
spawn_fixture_daemon(temp.path(), &socket, 2, empty_tool_result);
let _socket_env = SocketEnvGuard::set(&socket);

let handshake = test_handshake(&project, &profile);
brokered_init(&project, &[], &[], &handshake)
Expand All @@ -515,6 +535,57 @@ mod init_bootstrap_tests {
);
}

/// A worktree parked on a corrupt publication authority refuses the
/// reconcile; the operator reads its cause and remedy as whole fields,
/// not folded into one sentence cut at the diagnostic bound.
#[tokio::test]
async fn brokered_init_prints_a_parked_refusal_as_fields() {
fn refuse_sync(tool: &str, id: serde_json::Value) -> serde_json::Value {
if tool != "tracedecay_admin_sync" {
return empty_tool_result(tool, id);
}
let parked = tracedecay_contracts::code_index_freshness::CodeIndexConvergenceParkedV1 {
reason: format!("the publication authority is corrupt: {}", "x".repeat(600)),
blocked_reason: None,
remediation: "run `tracedecay daemon restart`".to_owned(),
parked_at_micros: 1,
observed_passes: 1,
retries_on_wake: false,
};
serde_json::to_value(tracedecay_mcp::tool_error_response(
id,
tool,
&parked.publication_authority_corrupt_error(),
))
.unwrap()
}

let _serialize = SOCKET_ENV_TEST_LOCK.lock().await;
let temp = tempfile::TempDir::new().unwrap();
let project = temp.path().join("project");
let profile = temp.path().join("profile");
std::fs::create_dir_all(&project).unwrap();
let socket = temp.path().join("daemon.sock");
let (responder, _recorded) = spawn_fixture_daemon(temp.path(), &socket, 2, refuse_sync);
let _socket_env = SocketEnvGuard::set(&socket);

let error = brokered_init(&project, &[], &[], &test_handshake(&project, &profile))
.await
.expect_err("a parked worktree refuses the reconcile");
responder.await.expect("fixture daemon task");

assert_eq!(
crate::commands::process_error_text(error),
format!(
"project route error (code_index_publication_authority_corrupt)\n\
Parked cause: the publication authority is corrupt: {}\n\
Parked remedy: run `tracedecay daemon restart`\n\
Retries on wake: false",
"x".repeat(600)
)
);
}

#[tokio::test(flavor = "current_thread")]
async fn brokered_init_retains_folder_option_error_before_sending_request() {
let temp = tempfile::TempDir::new().unwrap();
Expand Down
18 changes: 18 additions & 0 deletions crates/tracedecay-cli/src/commands/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,24 @@ pub(crate) fn annotate_reset_required(
}
}

/// The text the process boundary prints after `Error: `. A route refusal
/// with typed detail prints its reason code and one line per detail field, so
/// a parked index shows its whole cause and remedy rather than the bounded
/// sentence its message folds them into.
pub(crate) fn process_error_text(error: tracedecay_domain::errors::TraceDecayError) -> String {
if let (Some((reason_code, _, _)), Some(detail)) = (
error.project_route_context(),
error.project_route_typed_detail(),
) {
let mut text = format!("project route error ({reason_code})");
for (label, value) in detail.labelled_fields() {
text.push_str(&format!("\n{label}: {value}"));
}
return text;
}
annotate_reset_required(error, None).to_string()
}

/// Combines a destructive command's outcome with the daemon-restore outcome
/// so neither failure can shadow the other.
pub(crate) fn join_outcome_and_restore(
Expand Down
5 changes: 3 additions & 2 deletions crates/tracedecay-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -633,8 +633,9 @@ fn main() -> ExitCode {
ExitCode::FAILURE
};
// A typed reset refusal from any command ends with the refused
// authority and the exact command that resets it.
eprintln!("Error: {}", commands::annotate_reset_required(e, None));
// authority and the exact command that resets it; a typed route
// detail prints as fields.
eprintln!("Error: {}", commands::process_error_text(e));
code
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ use std::path::{Path, PathBuf};
use std::time::Duration;

use tokio::time::Instant;
use tracedecay_contracts::code_index_freshness::CODE_INDEX_PUBLICATION_AUTHORITY_CORRUPT;
use tracedecay_contracts::code_index_freshness::{
CodeGraphServingReadinessV1, CodeIndexWorktreeFreshnessV1,
};
Expand Down Expand Up @@ -45,11 +44,9 @@ pub(super) fn branch_refresh_admission_error(
) -> Option<TraceDecayError> {
match admission {
CodeIndexDemandAdmissionV1::Queued | CodeIndexDemandAdmissionV1::NotApplicable => None,
CodeIndexDemandAdmissionV1::Terminal(parked) => Some(TraceDecayError::project_route(
CODE_INDEX_PUBLICATION_AUTHORITY_CORRUPT,
false,
format!("{}; {}", parked.reason, parked.remediation),
)),
CodeIndexDemandAdmissionV1::Terminal(parked) => {
Some(parked.clone().publication_authority_corrupt_error())
}
CodeIndexDemandAdmissionV1::RefusedByPolicy
| CodeIndexDemandAdmissionV1::Unavailable(_) => Some(TraceDecayError::project_route(
CODE_INDEX_SCHEDULER_UNAVAILABLE,
Expand Down Expand Up @@ -491,11 +488,7 @@ impl BranchPublicationContextV1 {
.publication_authority_corruption(canonical_worktree_root)
.await
{
return Err(TraceDecayError::project_route(
CODE_INDEX_PUBLICATION_AUTHORITY_CORRUPT,
false,
format!("{}; {}", parked.reason, parked.remediation),
));
return Err(parked.publication_authority_corrupt_error());
}
let scope = schedulers
.serving_code_scope(canonical_worktree_root)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -312,12 +312,17 @@ async fn mid_wait_branch_publication_surfaces_terminal_publication_park() {
tracedecay_contracts::code_index_freshness::CODE_INDEX_PUBLICATION_AUTHORITY_CORRUPT
);
assert!(!context.1, "terminal corruption is non-retryable");
assert!(
context
.2
.contains("mid-wait injected publication corruption"),
"detail must retain the parked reason: {}",
context.2
assert_eq!(
error.project_route_typed_detail(),
Some(&tracedecay_domain::ApplicationProblemDetailV1::Parked {
cause: "mid-wait injected publication corruption".to_owned(),
remedy: "the derived code-index publication was deleted and rebuilt once in this \
daemon and is corrupt again; run `tracedecay daemon restart` for one more \
automatic rebuild, and report the daemon log's \
code_index_publication_authority_* events if it recurs"
.to_owned(),
retries_on_wake: false,
})
);
drop(held);
registry.shutdown().await;
Expand Down
19 changes: 19 additions & 0 deletions crates/tracedecay-contracts/src/code_index_freshness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ use std::sync::Arc;

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use tracedecay_domain::ApplicationProblemDetailV1;
use tracedecay_domain::errors::TraceDecayError;

/// The durable build phase whose committed boundary the dashboard is reading.
///
Expand Down Expand Up @@ -144,6 +146,23 @@ pub struct CodeIndexConvergenceParkedV1 {
pub retries_on_wake: bool,
}

impl CodeIndexConvergenceParkedV1 {
/// The route refusal of a worktree parked on a corrupt publication
/// authority. The park travels as its typed detail, so every adapter
/// renders `cause` and `remedy` as fields rather than one bounded sentence.
pub fn publication_authority_corrupt_error(self) -> TraceDecayError {
TraceDecayError::project_route_with_detail(
CODE_INDEX_PUBLICATION_AUTHORITY_CORRUPT,
false,
ApplicationProblemDetailV1::Parked {
cause: self.reason,
remedy: self.remediation,
retries_on_wake: self.retries_on_wake,
},
)
}
}

/// Recovery state for a durable generation sealed under a different production
/// owner configuration.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
Expand Down
31 changes: 18 additions & 13 deletions crates/tracedecay-mcp/src/tool_errors.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Semantic tool-failure classification and JSON-RPC error-response mapping.

use serde_json::{Value, json};
use tracedecay_contracts::ApplicationProblem;
use tracedecay_domain::errors::{PROFILE_RESET_COMMAND, TraceDecayError};
use tracedecay_sessions::admission::HostAdmissionStatus;

Expand Down Expand Up @@ -153,15 +154,16 @@ pub fn project_route_problem(tool_name: &str, error: &TraceDecayError) -> Option
"retryable": retryable,
"detail": detail,
});
let mut kind = project_route_problem_kind(reason_code).map(|kind| json!(kind));
if let (Some(typed_detail), Some(object)) =
(error.project_route_typed_detail(), data.as_object_mut())
{
object.insert("detail".to_string(), json!(typed_detail));
kind = Some(json!(
ApplicationProblem::from_detail(typed_detail.clone()).kind()
));
}
if let (Some(kind), Some(object)) = (
project_route_problem_kind(reason_code),
data.as_object_mut(),
) {
if let (Some(kind), Some(object)) = (kind, data.as_object_mut()) {
object.insert("kind".to_string(), json!(kind));
object.insert("code".to_string(), json!(reason_code));
}
Expand Down Expand Up @@ -596,16 +598,19 @@ mod tests {
let wire = serde_json::to_value(response).expect("JSON-RPC wire response");

assert_eq!(
wire["error"]["data"]["reason_code"],
"code_index_publication_authority_corrupt"
);
assert_eq!(
wire["error"]["data"]["detail"],
wire["error"]["data"],
json!({
"kind": "parked",
"cause": "source mode is not owner-private",
"remedy": "restore mode 0600",
"retries_on_wake": true,
"tool": "tracedecay_sync",
"kind": "unavailable",
"code": "code_index_publication_authority_corrupt",
"reason_code": "code_index_publication_authority_corrupt",
"retryable": false,
"detail": {
"kind": "parked",
"cause": "source mode is not owner-private",
"remedy": "restore mode 0600",
"retries_on_wake": true,
},
})
);
}
Expand Down
Loading
Loading