diff --git a/crates/tracedecay-application/src/feedback/cycle_runtime.rs b/crates/tracedecay-application/src/feedback/cycle_runtime.rs index 9496d26096..974c19b405 100644 --- a/crates/tracedecay-application/src/feedback/cycle_runtime.rs +++ b/crates/tracedecay-application/src/feedback/cycle_runtime.rs @@ -233,7 +233,7 @@ impl FeedbackCycleRuntimeError { pub fn lsp_failure_class(&self) -> &'static str { match self { Self::Contract(_) => "feedback-cycle-contract", - Self::Runtime(FeedbackRuntimeError::HandleStore(TraceDecayError::SyncLock { + Self::Runtime(FeedbackRuntimeError::HandleStore(TraceDecayError::LockDeadline { .. })) => "feedback-cycle-handle-store-busy", Self::Runtime(_) => "feedback-cycle-runtime", diff --git a/crates/tracedecay-application/src/lsp_runtime/advisory_source_tests.rs b/crates/tracedecay-application/src/lsp_runtime/advisory_source_tests.rs index bff2eb43be..9e07d3f391 100644 --- a/crates/tracedecay-application/src/lsp_runtime/advisory_source_tests.rs +++ b/crates/tracedecay-application/src/lsp_runtime/advisory_source_tests.rs @@ -1074,12 +1074,14 @@ async fn held_handle_store_lock_surfaces_the_typed_deadline_miss() { error.source() }) .find_map(|error| error.downcast_ref::()); - let expected = format!( - "response-handle writer lock at {} stayed contended past its admission deadline; retry the operation", - lock_path.display() - ); assert!( - matches!(cause, Some(TraceDecayError::SyncLock { message }) if *message == expected), + matches!( + cause, + Some(TraceDecayError::LockDeadline { + resource: "response-handle writer lock", + deadline_ms: 10_000, + }) + ), "{error:?}" ); assert_eq!( diff --git a/crates/tracedecay-application/src/operation_stream.rs b/crates/tracedecay-application/src/operation_stream.rs index f3f93c2a6c..b1775d6d1e 100644 --- a/crates/tracedecay-application/src/operation_stream.rs +++ b/crates/tracedecay-application/src/operation_stream.rs @@ -270,6 +270,7 @@ impl OperationEventError { )?, retry: RetryDirective::AfterRevalidate, legal_actions: vec![LegalAction::Refresh], + detail: None, }, Self::InvalidFrontier => ApplicationProblem::conflict( "operation_event.invalid_frontier", diff --git a/crates/tracedecay-application/src/primitives/runtime.rs b/crates/tracedecay-application/src/primitives/runtime.rs index 620fc73309..d42fcfd8d2 100644 --- a/crates/tracedecay-application/src/primitives/runtime.rs +++ b/crates/tracedecay-application/src/primitives/runtime.rs @@ -29,14 +29,15 @@ use tracedecay_contracts::retrieval::{ }; use tracedecay_contracts::{ ApplicationContractError, ApplicationEnvelope, ApplicationOperation, ApplicationOutcome, - ApplicationProblem, ApplicationProblemEnvelope, ApplicationProblemKind, ApplicationResult, - AuthorityReceipt, CancellationContext, CancellationObservation, CancellationStage, - CapabilityGrantId, CapabilityGrantSnapshot, CoverageCompleteness, CoverageDomainState, - Deadline, DisclosureClass, EvidenceCoverage, EvidenceDomain, EvidencePacket, FreshnessState, - LegalAction, Omission, OmissionReason, OpaqueCursor, OperationBudgetUsage, OperationReceipt, - OperationTermination, PageCursor, PageRequest, PageState, PolicyDecisionRef, RequestAdmission, - RequestContext, RequestCostReceiptV1, RequestId, ResolvedScope, RetrievalEvidence, - RetryDirective, SafeDiagnostic, TemporalState, + ApplicationProblem, ApplicationProblemDetailV1, ApplicationProblemEnvelope, + ApplicationProblemKind, ApplicationResult, AuthorityReceipt, CancellationContext, + CancellationObservation, CancellationStage, CapabilityGrantId, CapabilityGrantSnapshot, + CoverageCompleteness, CoverageDomainState, Deadline, DisclosureClass, EvidenceCoverage, + EvidenceDomain, EvidencePacket, FreshnessState, LegalAction, Omission, OmissionReason, + OpaqueCursor, OperationBudgetUsage, OperationReceipt, OperationTermination, PageCursor, + PageRequest, PageState, PolicyDecisionRef, RequestAdmission, RequestContext, + RequestCostReceiptV1, RequestId, ResolvedScope, RetrievalEvidence, RetryDirective, + SafeDiagnostic, TemporalState, }; use tracedecay_domain::text::forward_slash_path; use tracedecay_domain::{CodeGenerationId, CommitId, ComponentVersion, UtcMicros}; @@ -472,8 +473,7 @@ impl OwnedPrimitiveRuntime { impl OwnedPrimitiveRuntime { /// A code-index read refused as retryable while the worktree is parked /// would be retried forever: nothing converges until the operator acts. - /// The park's remedy and cause replace the generic refusal; the remedy - /// leads so a long cause is what the diagnostic bound cuts. + /// The park's typed cause and remedy replace the generic refusal. async fn parked_refusal(&self, refusal: ApplicationProblemEnvelope) -> PrimitiveResult { let Some(parked) = self .convergence_park @@ -482,14 +482,14 @@ impl OwnedPrimitiveRuntime { else { return Ok(Err(refusal)); }; - let message = safe_problem_message(&format!( - "The code index for this worktree is parked; remedy: {}; cause: {}", - parked.remediation, parked.reason - )); Ok(Err(ApplicationProblemEnvelope::new( refusal.contract, refusal.request_id, - ApplicationProblem::code_index_parked(message), + ApplicationProblem::from_detail(ApplicationProblemDetailV1::Parked { + cause: parked.reason, + remedy: parked.remediation, + retries_on_wake: parked.retries_on_wake, + }), )?)) } } diff --git a/crates/tracedecay-contracts/src/lib.rs b/crates/tracedecay-contracts/src/lib.rs index 7d4d3e8b19..624917f979 100644 --- a/crates/tracedecay-contracts/src/lib.rs +++ b/crates/tracedecay-contracts/src/lib.rs @@ -334,20 +334,21 @@ pub use project_registry::{ pub use remote::status::RemoteOperationalStatusReaderV1; pub use result::{ APPLICATION_PROBLEM_REVISION, ApplicationEnvelope, ApplicationExecutionFailureClassV1, - ApplicationOutcome, ApplicationProblem, ApplicationProblemEnvelope, ApplicationProblemKind, - ApplicationProblemRecord, ApplicationResult, ApplicationUnavailableClassV1, AuthorityReceipt, - BudgetClass, CancellationObservation, CancellationStage, ContextMemoryAnalyticsV1, - CoverageCompleteness, CoverageDomainState, EffectId, EffectReceipt, EffectResult, - EffectTermination, EvidenceAuthority, EvidenceCoverage, EvidenceDomain, EvidenceIdentity, - EvidencePacket, EvidenceScore, EvidenceScoreKind, EvidenceScoreValue, FreshnessState, - IdempotencyKey, InvocationAnalyticsV1, LegalAction, Omission, OmissionReason, OpaqueCursor, - OperationBudgetUsage, OperationReceipt, OperationTermination, PageCursor, PageState, - PolicyDecisionRef, PrContextAnalyticsV1, PrContextStageTimingsV1, PreviewId, PreviewResult, - ProblemOwningLayer, ProblemTerminality, RUNTIME_MOUNTING_REASON_CODE, ReconciliationState, - RequestCostReceiptV1, ResultContractRef, ResumeToken, RetrievalEvidence, RetrieverContribution, - RetrieverContributionState, RetryDirective, RetryScope, SafeDiagnostic, ScoreId, - StorePointReadsV1, StreamEvent, StreamEventKind, StreamFrontier, StreamGap, StreamTermination, - StreamValidationError, TemporalState, validate_stream, + ApplicationOutcome, ApplicationProblem, ApplicationProblemDetailV1, ApplicationProblemEnvelope, + ApplicationProblemKind, ApplicationProblemRecord, ApplicationResult, + ApplicationUnavailableClassV1, AuthorityReceipt, BudgetClass, CancellationObservation, + CancellationStage, ContextMemoryAnalyticsV1, CoverageCompleteness, CoverageDomainState, + EffectId, EffectReceipt, EffectResult, EffectTermination, EvidenceAuthority, EvidenceCoverage, + EvidenceDomain, EvidenceIdentity, EvidencePacket, EvidenceScore, EvidenceScoreKind, + EvidenceScoreValue, FreshnessState, IdempotencyKey, InvocationAnalyticsV1, LegalAction, + Omission, OmissionReason, OpaqueCursor, OperationBudgetUsage, OperationReceipt, + OperationTermination, PageCursor, PageState, PolicyDecisionRef, PrContextAnalyticsV1, + PrContextStageTimingsV1, PreviewId, PreviewResult, ProblemOwningLayer, ProblemTerminality, + RUNTIME_MOUNTING_REASON_CODE, ReconciliationState, RequestCostReceiptV1, ResultContractRef, + ResumeToken, RetrievalEvidence, RetrieverContribution, RetrieverContributionState, + RetryDirective, RetryScope, SafeDiagnostic, ScoreId, StorePointReadsV1, StreamEvent, + StreamEventKind, StreamFrontier, StreamGap, StreamTermination, StreamValidationError, + TemporalState, validate_stream, }; pub use retained_receipts::{ PreparedRetainedEffect, authority_receipt, effective_memory_deadline, evidence_outcome, diff --git a/crates/tracedecay-contracts/src/remote/composition.rs b/crates/tracedecay-contracts/src/remote/composition.rs index b0c1024042..f08f335fd4 100644 --- a/crates/tracedecay-contracts/src/remote/composition.rs +++ b/crates/tracedecay-contracts/src/remote/composition.rs @@ -330,6 +330,7 @@ fn remote_unavailable(code: &str, message: &str) -> ApplicationProblem { .expect("static remote problem diagnostic is valid"), retry: RetryDirective::AfterRevalidate, legal_actions: vec![LegalAction::Refresh, LegalAction::Reconcile], + detail: None, } } diff --git a/crates/tracedecay-contracts/src/remote/protocol.rs b/crates/tracedecay-contracts/src/remote/protocol.rs index 6a20a65453..4f6b19f250 100644 --- a/crates/tracedecay-contracts/src/remote/protocol.rs +++ b/crates/tracedecay-contracts/src/remote/protocol.rs @@ -554,6 +554,7 @@ pub fn remote_protocol_problem( )?, retry: RetryDirective::AfterRevalidate, legal_actions: vec![LegalAction::Refresh], + detail: None, }, RemoteProtocolFailureV1::AuthorityReachable => ApplicationProblem::conflict( "remote.authority_reachable", @@ -571,6 +572,7 @@ pub fn remote_protocol_problem( )?, retry: RetryDirective::AfterDelay, legal_actions: vec![LegalAction::Retry], + detail: None, }, }; ApplicationProblemEnvelope::new(contract, request_id, problem) diff --git a/crates/tracedecay-contracts/src/remote/status.rs b/crates/tracedecay-contracts/src/remote/status.rs index 2d8df143c4..30cbab3c7d 100644 --- a/crates/tracedecay-contracts/src/remote/status.rs +++ b/crates/tracedecay-contracts/src/remote/status.rs @@ -168,6 +168,7 @@ fn invalid_status() -> ApplicationProblem { .expect("static Remote operational status diagnostic is valid"), retry: RetryDirective::AfterRevalidate, legal_actions: vec![LegalAction::Refresh, LegalAction::Reconcile], + detail: None, } } diff --git a/crates/tracedecay-contracts/src/result/envelope.rs b/crates/tracedecay-contracts/src/result/envelope.rs index d8accdf415..0d21281ca4 100644 --- a/crates/tracedecay-contracts/src/result/envelope.rs +++ b/crates/tracedecay-contracts/src/result/envelope.rs @@ -11,9 +11,9 @@ use crate::context::{RequestId, ResolvedScope}; use crate::error::ApplicationContractError; use super::{ - ApplicationExecutionFailureClassV1, ApplicationProblem, ApplicationProblemKind, - ApplicationUnavailableClassV1, CancellationStage, EffectReceipt, EffectResult, - EvidenceCoverage, EvidencePacket, LegalAction, PreviewResult, ProblemOwningLayer, + ApplicationExecutionFailureClassV1, ApplicationProblem, ApplicationProblemDetailV1, + ApplicationProblemKind, ApplicationUnavailableClassV1, CancellationStage, EffectReceipt, + EffectResult, EvidenceCoverage, EvidencePacket, LegalAction, PreviewResult, ProblemOwningLayer, ProblemTerminality, RUNTIME_MOUNTING_REASON_CODE, RetryDirective, RetryScope, SafeDiagnostic, }; @@ -196,6 +196,11 @@ pub struct ApplicationProblemRecord { pub code: String, pub message: String, pub diagnostic: Option, + /// The structured facts `message` is rendered from, when the problem has + /// any. Always serialized so a client never mistakes an omitted detail + /// for a problem without one. + #[schemars(with = "RequiredNullable")] + pub detail: Option, /// A committed effect is present only for an admitted partial effect. /// The nullable field is always serialized: omitting it would create a /// compatibility/default path that could hide a missing receipt. @@ -243,6 +248,7 @@ impl<'de> Deserialize<'de> for ApplicationProblemRecord { code: String, message: String, diagnostic: Option, + detail: RequiredNullable, committed_receipt: RequiredNullable, owning_layer: ProblemOwningLayer, terminality: ProblemTerminality, @@ -262,6 +268,18 @@ impl<'de> Deserialize<'de> for ApplicationProblemRecord { let wire = Wire::deserialize(deserializer)?; let legal_actions = wire.legal_actions; + let detail = wire.detail.0; + let detailed_kind = matches!( + wire.kind, + ApplicationProblemKind::Stale + | ApplicationProblemKind::Unavailable + | ApplicationProblemKind::Saturated + ); + if detail.is_some() && !detailed_kind { + return Err(serde::de::Error::custom( + "application problem kind carries no detail", + )); + } let source = match ( wire.kind, wire.diagnostic.clone(), @@ -299,6 +317,7 @@ impl<'de> Deserialize<'de> for ApplicationProblemRecord { diagnostic, retry: wire.retry, legal_actions: legal_actions.clone(), + detail: detail.clone().map(Box::new), }, (ApplicationProblemKind::Unsupported, Some(diagnostic), None) => { ApplicationProblem::Unsupported { @@ -317,6 +336,7 @@ impl<'de> Deserialize<'de> for ApplicationProblemRecord { diagnostic, retry: wire.retry, legal_actions: legal_actions.clone(), + detail: detail.clone().map(Box::new), } } (ApplicationProblemKind::ExecutionFailed, Some(diagnostic), None) => { @@ -343,6 +363,7 @@ impl<'de> Deserialize<'de> for ApplicationProblemRecord { diagnostic, retry: wire.retry, legal_actions: legal_actions.clone(), + detail: detail.clone().map(Box::new), } } (ApplicationProblemKind::Cancelled, None, None) => ApplicationProblem::Cancelled { @@ -371,6 +392,7 @@ impl<'de> Deserialize<'de> for ApplicationProblemRecord { code: wire.code, message: wire.message, diagnostic: wire.diagnostic, + detail, committed_receipt: wire.committed_receipt.0, owning_layer: wire.owning_layer, terminality: wire.terminality, @@ -492,6 +514,7 @@ impl ApplicationProblemRecord { RetryDirective::AfterReconcile => Some(RetryScope::SameOperation), }; let diagnostic = source.diagnostic().cloned(); + let detail = source.detail().cloned(); let committed_receipt = source.committed_receipt().cloned(); let code = diagnostic .as_ref() @@ -503,6 +526,7 @@ impl ApplicationProblemRecord { code, message: source.safe_message().to_owned(), diagnostic, + detail, committed_receipt, owning_layer: ProblemOwningLayer::Application, terminality: source.terminality(), @@ -560,6 +584,7 @@ impl ApplicationProblemRecord { .unwrap_or_else(|| self.source.canonical_code()) || self.message != self.source.safe_message() || self.diagnostic.as_ref() != self.source.diagnostic() + || self.detail.as_ref() != self.source.detail() || self.committed_receipt.as_ref() != self.source.committed_receipt() { return Err(ApplicationContractError::Inconsistent { @@ -1064,6 +1089,79 @@ mod tests { ); } + #[test] + fn a_detailed_problem_carries_its_facts_beside_the_rendered_message() { + let envelope = ApplicationProblemEnvelope::new( + contract(), + RequestId::new("request.detail.fixture").expect("request"), + ApplicationProblem::from_detail(ApplicationProblemDetailV1::Parked { + cause: "source unreadable".to_owned(), + remedy: "restore the mode, then run `tracedecay sync`".to_owned(), + retries_on_wake: false, + }), + ) + .expect("parked envelope"); + let wire = serde_json::to_value(&envelope).expect("envelope serializes"); + assert_eq!( + wire["problem"]["detail"], + serde_json::json!({ + "kind": "parked", + "cause": "source unreadable", + "remedy": "restore the mode, then run `tracedecay sync`", + "retries_on_wake": false, + }) + ); + assert_eq!( + ( + &wire["problem"]["code"], + &wire["problem"]["message"], + &wire["problem"]["retry"], + &wire["problem"]["legal_actions"], + ), + ( + &serde_json::json!("application.code-index.parked"), + &serde_json::json!( + "The code index for this worktree is parked; remedy: restore the mode, then \ + run `tracedecay sync`; cause: source unreadable" + ), + &serde_json::json!("never"), + &serde_json::json!(["reconcile"]), + ) + ); + assert_eq!( + serde_json::from_value::(wire.clone()) + .expect("detailed envelope decodes"), + envelope + ); + + let mut restated = wire.clone(); + restated["problem"]["detail"]["cause"] = serde_json::json!("another cause"); + assert!( + serde_json::from_value::(restated).is_err(), + "a message that no longer renders its detail must be refused" + ); + let mut omitted = wire.clone(); + omitted["problem"] + .as_object_mut() + .expect("problem object") + .remove("detail"); + assert!(serde_json::from_value::(omitted).is_err()); + + let plain = ApplicationProblemEnvelope::new( + contract(), + RequestId::new("request.detail.plain").expect("request"), + ApplicationProblem::invalid_request("result.invalid", "The request is invalid."), + ) + .expect("plain envelope"); + let mut grafted = serde_json::to_value(&plain).expect("plain envelope serializes"); + assert_eq!(grafted["problem"]["detail"], Value::Null); + grafted["problem"]["detail"] = wire["problem"]["detail"].clone(); + assert!( + serde_json::from_value::(grafted).is_err(), + "a kind that carries no detail must not accept one" + ); + } + fn retry_directed_record(code: &str, delay_millis: u64) -> ApplicationProblemRecord { let envelope = ApplicationProblemEnvelope::new( ResultContractRef::new( diff --git a/crates/tracedecay-contracts/src/result/mod.rs b/crates/tracedecay-contracts/src/result/mod.rs index 4b90978f55..cac16ea3d5 100644 --- a/crates/tracedecay-contracts/src/result/mod.rs +++ b/crates/tracedecay-contracts/src/result/mod.rs @@ -2,6 +2,7 @@ mod analytics; mod envelope; mod evidence; mod problem; +mod problem_detail; mod receipt; mod stream; @@ -25,6 +26,7 @@ pub use problem::{ ApplicationUnavailableClassV1, LegalAction, ProblemOwningLayer, ProblemTerminality, RUNTIME_MOUNTING_REASON_CODE, RetryDirective, RetryScope, SafeDiagnostic, }; +pub use problem_detail::ApplicationProblemDetailV1; pub use receipt::{ CancellationObservation, CancellationStage, EffectId, EffectReceipt, EffectResult, EffectTermination, IdempotencyKey, OperationBudgetUsage, OperationReceipt, diff --git a/crates/tracedecay-contracts/src/result/problem.rs b/crates/tracedecay-contracts/src/result/problem.rs index 2f1d82eeed..79748fd36a 100644 --- a/crates/tracedecay-contracts/src/result/problem.rs +++ b/crates/tracedecay-contracts/src/result/problem.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use tracedecay_domain::UtcMicros; use tracedecay_domain::errors::TraceDecayError; -use super::{CancellationStage, EffectReceipt, EffectTermination}; +use super::{ApplicationProblemDetailV1, CancellationStage, EffectReceipt, EffectTermination}; use crate::context::{RequestAdmission, RequestContext}; use crate::error::ApplicationContractError; @@ -189,6 +189,7 @@ pub enum ApplicationProblem { diagnostic: SafeDiagnostic, retry: RetryDirective, legal_actions: Vec, + detail: Option>, }, Unsupported { diagnostic: SafeDiagnostic, @@ -200,6 +201,7 @@ pub enum ApplicationProblem { diagnostic: SafeDiagnostic, retry: RetryDirective, legal_actions: Vec, + detail: Option>, }, ExecutionFailed { classification: ApplicationExecutionFailureClassV1, @@ -216,6 +218,7 @@ pub enum ApplicationProblem { diagnostic: SafeDiagnostic, retry: RetryDirective, legal_actions: Vec, + detail: Option>, }, Cancelled { stage: CancellationStage, @@ -256,6 +259,8 @@ enum ApplicationProblemWire { diagnostic: SafeDiagnostic, retry: RetryDirective, legal_actions: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + detail: Option>, }, Unsupported { diagnostic: SafeDiagnostic, @@ -267,6 +272,8 @@ enum ApplicationProblemWire { diagnostic: SafeDiagnostic, retry: RetryDirective, legal_actions: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + detail: Option>, }, ExecutionFailed { classification: ApplicationExecutionFailureClassV1, @@ -283,6 +290,8 @@ enum ApplicationProblemWire { diagnostic: SafeDiagnostic, retry: RetryDirective, legal_actions: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + detail: Option>, }, Cancelled { stage: CancellationStage, @@ -339,10 +348,12 @@ impl From for ApplicationProblemWire { diagnostic, retry, legal_actions, + detail, } => Self::Stale { diagnostic, retry, legal_actions, + detail, }, ApplicationProblem::Unsupported { diagnostic, @@ -358,11 +369,13 @@ impl From for ApplicationProblemWire { diagnostic, retry, legal_actions, + detail, } => Self::Unavailable { classification, diagnostic, retry, legal_actions, + detail, }, ApplicationProblem::ExecutionFailed { classification, @@ -388,10 +401,12 @@ impl From for ApplicationProblemWire { diagnostic, retry, legal_actions, + detail, } => Self::Saturated { diagnostic, retry, legal_actions, + detail, }, ApplicationProblem::Cancelled { stage, @@ -478,10 +493,12 @@ impl ApplicationProblem { diagnostic, retry, legal_actions, + detail, } => Self::Stale { diagnostic, retry, legal_actions, + detail, }, ApplicationProblemWire::Unsupported { diagnostic, @@ -497,11 +514,13 @@ impl ApplicationProblem { diagnostic, retry, legal_actions, + detail, } => Self::Unavailable { classification, diagnostic, retry, legal_actions, + detail, }, ApplicationProblemWire::ExecutionFailed { classification, @@ -527,10 +546,12 @@ impl ApplicationProblem { diagnostic, retry, legal_actions, + detail, } => Self::Saturated { diagnostic, retry, legal_actions, + detail, }, ApplicationProblemWire::Cancelled { stage, @@ -559,6 +580,13 @@ impl ApplicationProblem { if let Some(diagnostic) = self.diagnostic() { diagnostic.validate()?; } + if let Some(detail) = self.detail() + && *self != Self::from_detail(detail.clone()) + { + return Err(ApplicationContractError::Inconsistent { + field: "application problem detail", + }); + } match self { Self::PartialEffect { @@ -812,6 +840,7 @@ impl ApplicationProblem { diagnostic, retry: RetryDirective::AfterDelay, legal_actions: vec![LegalAction::Retry], + detail: None, } } @@ -824,19 +853,55 @@ impl ApplicationProblem { }) } - /// The refusal for a query whose code index is parked: the same request - /// cannot succeed until the operator applies the park's remedy, which - /// `message` names, so it is never retried and names reconcile as the way - /// forward. - pub fn code_index_parked(message: String) -> Self { - Self::Unavailable { - classification: ApplicationUnavailableClassV1::Authority, - diagnostic: SafeDiagnostic { - code: "application.code-index.parked".to_owned(), - message, + /// The one problem a typed detail names: its kind, code, retry, legal + /// actions, and rendered message all follow from the detail. + /// + /// A parked code index cannot answer until the operator applies the + /// park's remedy, so it is never retried and names reconcile. A stale + /// refresh frontier is revalidated from the committed frontier. A lock + /// deadline is capacity: the same request may succeed after a delay. + pub fn from_detail(detail: ApplicationProblemDetailV1) -> Self { + let diagnostic = SafeDiagnostic { + code: detail.code().to_owned(), + message: detail.message(), + }; + match detail { + ApplicationProblemDetailV1::Parked { .. } => Self::Unavailable { + classification: ApplicationUnavailableClassV1::Authority, + diagnostic, + retry: RetryDirective::Never, + legal_actions: vec![LegalAction::Reconcile], + detail: Some(Box::new(detail)), }, - retry: RetryDirective::Never, - legal_actions: vec![LegalAction::Reconcile], + ApplicationProblemDetailV1::StaleRefreshFrontier { .. } => Self::Stale { + diagnostic, + retry: RetryDirective::AfterRevalidate, + legal_actions: vec![LegalAction::Refresh], + detail: Some(Box::new(detail)), + }, + ApplicationProblemDetailV1::LockDeadline { .. } => Self::Saturated { + diagnostic, + retry: RetryDirective::AfterDelay, + legal_actions: vec![LegalAction::Retry], + detail: Some(Box::new(detail)), + }, + } + } + + pub fn detail(&self) -> Option<&ApplicationProblemDetailV1> { + match self { + Self::Stale { detail, .. } + | Self::Unavailable { detail, .. } + | Self::Saturated { detail, .. } => detail.as_deref(), + Self::InvalidRequest { .. } + | Self::NotFoundOrNotAuthorized { .. } + | Self::Conflict { .. } + | Self::PartialEffect { .. } + | Self::Unsupported { .. } + | Self::ExecutionFailed { .. } + | Self::ResetRequired { .. } + | Self::Cancelled { .. } + | Self::TimedOut { .. } => None, } } @@ -854,6 +919,7 @@ impl ApplicationProblem { diagnostic, retry: RetryDirective::AfterRevalidate, legal_actions: vec![LegalAction::Retry], + detail: None, }; problem.validate()?; Ok(problem) @@ -878,6 +944,7 @@ impl ApplicationProblem { diagnostic, retry: RetryDirective::AfterRevalidate, legal_actions: vec![LegalAction::Refresh], + detail: None, } } @@ -890,6 +957,7 @@ impl ApplicationProblem { }, retry: RetryDirective::AfterDelay, legal_actions: vec![LegalAction::Retry], + detail: None, } } diff --git a/crates/tracedecay-contracts/src/result/problem/tests.rs b/crates/tracedecay-contracts/src/result/problem/tests.rs index 942f05e0e8..a0d2938635 100644 --- a/crates/tracedecay-contracts/src/result/problem/tests.rs +++ b/crates/tracedecay-contracts/src/result/problem/tests.rs @@ -65,6 +65,7 @@ fn saturated_retries_after_delay() { diagnostic, retry: RetryDirective::AfterDelay, legal_actions: vec![LegalAction::Retry], + detail: None, } ); assert_eq!( diff --git a/crates/tracedecay-contracts/src/result/problem_detail.rs b/crates/tracedecay-contracts/src/result/problem_detail.rs new file mode 100644 index 0000000000..699c402ff2 --- /dev/null +++ b/crates/tracedecay-contracts/src/result/problem_detail.rs @@ -0,0 +1,112 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use tracedecay_domain::errors::TraceDecayError; + +/// Largest rendered problem message, the [`super::SafeDiagnostic`] bound. +const MAX_RENDERED_MESSAGE_BYTES: usize = 512; + +/// The structured facts behind a problem. Adapters read these fields; the +/// problem's `message` is only their one human rendering. +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum ApplicationProblemDetailV1 { + /// The worktree's code index is parked until the operator applies + /// `remedy`; repeating the request cannot change the answer. + Parked { + cause: String, + remedy: String, + retries_on_wake: bool, + }, + /// A session refresh asked to begin from a source frontier the + /// committed projection has already passed. + StaleRefreshFrontier { + requested: u64, + committed: u64, + active: u64, + }, + /// A writer lock stayed held by other writers past its admission + /// deadline. + LockDeadline { resource: String, deadline_ms: u64 }, +} + +impl ApplicationProblemDetailV1 { + /// The typed detail of a lock that missed its admission deadline. + pub fn from_lock_deadline(error: &TraceDecayError) -> Option { + match error { + TraceDecayError::LockDeadline { + resource, + deadline_ms, + } => Some(Self::LockDeadline { + resource: (*resource).to_owned(), + deadline_ms: *deadline_ms, + }), + _ => None, + } + } + + /// Stable diagnostic code of the problem this detail names. + pub const fn code(&self) -> &'static str { + match self { + Self::Parked { .. } => "application.code-index.parked", + Self::StaleRefreshFrontier { .. } => "application.retained.refresh-frontier-stale", + Self::LockDeadline { .. } => "application.lock-deadline", + } + } + + /// The one human rendering of this detail, folded and bounded to the + /// safe diagnostic message limit. + pub fn message(&self) -> String { + let text = match self { + // The remedy leads so a long cause is what the bound cuts. + Self::Parked { cause, remedy, .. } => format!( + "The code index for this worktree is parked; remedy: {remedy}; cause: {cause}" + ), + Self::StaleRefreshFrontier { active, .. } => format!( + "The refresh window no longer contains the committed projection frontier \ + {active}; begin again from source frontier {active}." + ), + Self::LockDeadline { + resource, + deadline_ms, + } => format!( + "The {resource} stayed busy past its {deadline_ms}ms admission deadline; retry \ + the operation." + ), + }; + let folded = tracedecay_domain::fold_control_characters(&text); + tracedecay_domain::utf8_prefix_at_or_before(folded.trim(), MAX_RENDERED_MESSAGE_BYTES) + .trim_end() + .to_owned() + } + + /// Labelled fields for line-oriented adapters, in display order. + pub fn labelled_fields(&self) -> Vec<(&'static str, String)> { + match self { + Self::Parked { + cause, + remedy, + retries_on_wake, + } => vec![ + ("Parked cause", cause.clone()), + ("Parked remedy", remedy.clone()), + ("Retries on wake", retries_on_wake.to_string()), + ], + Self::StaleRefreshFrontier { + requested, + committed, + active, + } => vec![ + ("Requested frontier", requested.to_string()), + ("Committed frontier", committed.to_string()), + ("Active frontier", active.to_string()), + ], + Self::LockDeadline { + resource, + deadline_ms, + } => vec![ + ("Lock resource", resource.clone()), + ("Lock deadline", format!("{deadline_ms}ms")), + ], + } + } +} diff --git a/crates/tracedecay-contracts/src/retained_surfaces/service.rs b/crates/tracedecay-contracts/src/retained_surfaces/service.rs index c62d2a1b8a..691c004e32 100644 --- a/crates/tracedecay-contracts/src/retained_surfaces/service.rs +++ b/crates/tracedecay-contracts/src/retained_surfaces/service.rs @@ -574,6 +574,7 @@ pub fn retained_surface_execution_problem( }, retry: RetryDirective::AfterDelay, legal_actions: vec![LegalAction::Retry], + detail: None, } } RetainedSurfaceExecutionErrorV1::ProfileResetRequired => { @@ -607,6 +608,7 @@ fn unavailable_problem(code: &'static str, message: &'static str) -> Application diagnostic: diagnostic(code, message), retry: RetryDirective::AfterDelay, legal_actions: vec![LegalAction::Retry], + detail: None, } } @@ -660,6 +662,7 @@ impl RetainedSurfaceExecutionErrorV1 { ), retry: RetryDirective::Never, legal_actions: vec![LegalAction::RestartWithoutCursor], + detail: None, }) } diff --git a/crates/tracedecay-contracts/src/work_attempt/product_admission.rs b/crates/tracedecay-contracts/src/work_attempt/product_admission.rs index 02bd583c1f..212005632b 100644 --- a/crates/tracedecay-contracts/src/work_attempt/product_admission.rs +++ b/crates/tracedecay-contracts/src/work_attempt/product_admission.rs @@ -526,6 +526,7 @@ mod product_problem_tests { }, retry: RetryDirective::AfterRevalidate, legal_actions: vec![LegalAction::Refresh], + detail: None, } ); } diff --git a/crates/tracedecay-daemon-service/src/application_surface/operation_events.rs b/crates/tracedecay-daemon-service/src/application_surface/operation_events.rs index ec02c3399a..668fbe5976 100644 --- a/crates/tracedecay-daemon-service/src/application_surface/operation_events.rs +++ b/crates/tracedecay-daemon-service/src/application_surface/operation_events.rs @@ -834,6 +834,7 @@ pub(super) fn operation_event_problem( }, retry: RetryDirective::AfterRevalidate, legal_actions: vec![LegalAction::Refresh], + detail: None, } } OperationEventError::InvalidFrontier => ApplicationProblem::conflict( diff --git a/crates/tracedecay-daemon-service/src/invocation/git.rs b/crates/tracedecay-daemon-service/src/invocation/git.rs index 35bd54b390..008a9f3d8f 100644 --- a/crates/tracedecay-daemon-service/src/invocation/git.rs +++ b/crates/tracedecay-daemon-service/src/invocation/git.rs @@ -482,6 +482,7 @@ pub(super) fn git_read_output_limit_problem() -> ApplicationProblem { }, retry: RetryDirective::Never, legal_actions: vec![tracedecay_contracts::LegalAction::CorrectRequest], + detail: None, } } diff --git a/crates/tracedecay-daemon-service/src/invocation/native_integration.rs b/crates/tracedecay-daemon-service/src/invocation/native_integration.rs index a74bb173de..20d49a3ff9 100644 --- a/crates/tracedecay-daemon-service/src/invocation/native_integration.rs +++ b/crates/tracedecay-daemon-service/src/invocation/native_integration.rs @@ -889,6 +889,7 @@ fn unavailable_native_integration() -> ApplicationProblem { }, retry: RetryDirective::AfterDelay, legal_actions: vec![tracedecay_contracts::LegalAction::Retry], + detail: None, } } diff --git a/crates/tracedecay-daemon-service/src/invocation/source_edit.rs b/crates/tracedecay-daemon-service/src/invocation/source_edit.rs index 224f3113c2..5881b49146 100644 --- a/crates/tracedecay-daemon-service/src/invocation/source_edit.rs +++ b/crates/tracedecay-daemon-service/src/invocation/source_edit.rs @@ -3,9 +3,9 @@ use std::sync::Arc; use tracedecay_contracts::{ - ApplicationExecutionFailureClassV1, ApplicationProblem, CancellationContext, Deadline, - RequestId, SafeDiagnostic, SourceEditInvocationV1, SourceEditReconciliationInvocationV1, - SourceEditRollbackInvocationV1, + ApplicationExecutionFailureClassV1, ApplicationProblem, ApplicationProblemDetailV1, + CancellationContext, Deadline, RequestId, SafeDiagnostic, SourceEditInvocationV1, + SourceEditReconciliationInvocationV1, SourceEditRollbackInvocationV1, }; use tracedecay_daemon_protocol::DaemonInvocationProblem; @@ -248,6 +248,9 @@ fn source_edit_safe_diagnostic( fn source_edit_execution_problem( error: TraceDecayError, ) -> Result { + if let Some(detail) = ApplicationProblemDetailV1::from_lock_deadline(&error) { + return Ok(ApplicationProblem::from_detail(detail)); + } let (code, message) = source_edit_kernel_cause(&error); let diagnostic = source_edit_safe_diagnostic(code, message)?; match diagnostic.code.as_str() { @@ -371,6 +374,44 @@ mod tests { assert!(retryable); } + #[test] + fn a_missed_writer_lock_deadline_is_retryable_capacity_with_typed_detail() { + let response = map_source_edit_error( + "request.source-edit.lock-deadline".to_owned(), + SourceEditOwnerError::ExecutionFailed(TraceDecayError::LockDeadline { + resource: "source-edit writer lock", + deadline_ms: 30_000, + }), + ); + let wire = serde_json::to_value(&response).expect("daemon response wire"); + let problem = &wire["problem"]; + assert_eq!( + ( + &problem["kind"], + &problem["retry"], + &problem["legal_actions"], + &problem["diagnostic"], + &problem["detail"], + ), + ( + &serde_json::json!("saturated"), + &serde_json::json!("after_delay"), + &serde_json::json!(["retry"]), + &serde_json::json!({ + "code": "application.lock-deadline", + "message": "The source-edit writer lock stayed busy past its 30000ms \ + admission deadline; retry the operation.", + }), + &serde_json::json!({ + "kind": "lock_deadline", + "resource": "source-edit writer lock", + "deadline_ms": 30_000, + }), + ), + "{wire}" + ); + } + #[test] fn source_edit_cancelled_request_is_cancelled_before_admission() { let problem = classified_problem(SourceEditOwnerError::Cancelled); diff --git a/crates/tracedecay-daemon-service/src/invocation/work/outcome.rs b/crates/tracedecay-daemon-service/src/invocation/work/outcome.rs index 390e568802..1e552cb664 100644 --- a/crates/tracedecay-daemon-service/src/invocation/work/outcome.rs +++ b/crates/tracedecay-daemon-service/src/invocation/work/outcome.rs @@ -527,6 +527,7 @@ mod work_product_problem_tests { }, retry: RetryDirective::AfterRevalidate, legal_actions: vec![LegalAction::Refresh], + detail: None, } ); } diff --git a/crates/tracedecay-daemon-service/src/session_refresh.rs b/crates/tracedecay-daemon-service/src/session_refresh.rs index d22bc2907c..b4345b7e98 100644 --- a/crates/tracedecay-daemon-service/src/session_refresh.rs +++ b/crates/tracedecay-daemon-service/src/session_refresh.rs @@ -234,9 +234,13 @@ impl DaemonSessionRefreshService { SessionRefreshOutcome::WrongScope => SessionRefreshServiceOutcome::WrongScope, SessionRefreshOutcome::Stale => SessionRefreshServiceOutcome::Stale, SessionRefreshOutcome::StaleFrontier { - active_projection_frontier, + requested, + committed, + active, } => SessionRefreshServiceOutcome::StaleFrontier { - active_projection_frontier, + requested, + committed, + active, }, SessionRefreshOutcome::NotFound => SessionRefreshServiceOutcome::NotFound, SessionRefreshOutcome::Aborted => SessionRefreshServiceOutcome::Aborted, diff --git a/crates/tracedecay-domain/src/errors.rs b/crates/tracedecay-domain/src/errors.rs index 8cd7034074..f229e1e1fa 100644 --- a/crates/tracedecay-domain/src/errors.rs +++ b/crates/tracedecay-domain/src/errors.rs @@ -89,6 +89,14 @@ pub enum TraceDecayError { #[error("sync lock: {message}")] SyncLock { message: String }, + /// A writer lock stayed held by other writers past its admission + /// deadline. `resource` names the lock class, never a filesystem path. + #[error("{resource} stayed busy past its {deadline_ms}ms admission deadline")] + LockDeadline { + resource: &'static str, + deadline_ms: u64, + }, + #[error("io error: {0}")] Io(#[from] std::io::Error), diff --git a/crates/tracedecay-mcp/src/application_output/view.rs b/crates/tracedecay-mcp/src/application_output/view.rs index d76f847c7d..beae3fd7eb 100644 --- a/crates/tracedecay-mcp/src/application_output/view.rs +++ b/crates/tracedecay-mcp/src/application_output/view.rs @@ -299,6 +299,11 @@ impl CanonicalHumanView { self.code("Request", problem.request_id.as_str()); self.code("Trace", problem.trace_id.as_str()); self.text("Message", problem.message.clone()); + if let Some(detail) = &problem.detail { + for (label, value) in detail.labelled_fields() { + self.text(label, value); + } + } self.code("Retryable", problem.retryable.to_string()); self.code("Retry", scalar(&problem.retry)?); self.code( diff --git a/crates/tracedecay-mcp/src/handlers/graph/search.rs b/crates/tracedecay-mcp/src/handlers/graph/search.rs index a77d090ecf..6285ca664c 100644 --- a/crates/tracedecay-mcp/src/handlers/graph/search.rs +++ b/crates/tracedecay-mcp/src/handlers/graph/search.rs @@ -7,7 +7,6 @@ use std::path::Path; use serde_json::{Value, json}; use tracedecay_code_index::graph_projection::CodeGraphSymbolSummaryV1; -use tracedecay_contracts::InvocationAnalyticsV1; use tracedecay_contracts::graph_tool::{GraphToolCompletionV1, GraphToolResultV1}; use tracedecay_contracts::retrieval::{ ContextCodeBlockV1, ContextLexicalAnchorV1, ContextModeV1, ContextResultV1, @@ -19,6 +18,7 @@ use tracedecay_contracts::retrieval::{ SimilarCoverageV1, SimilarFamilyV1, SimilarMatchClassV1, SimilarOccurrenceV1, SimilarResultV1, SimilarSurfaceRequestV1, SimilarTargetV1, }; +use tracedecay_contracts::{ApplicationProblemDetailV1, InvocationAnalyticsV1}; use tracedecay_domain::ExactClass; use tracedecay_domain::errors::{Result, TraceDecayError}; use tracedecay_query::retrieval::lexical::LexicalAnchorOutcomeV1; @@ -379,17 +379,22 @@ where if let Some(unavailable_graph) = graph_evidence.unavailable() { output["verified_graph_evidence"] = unavailable_graph.clone(); } - let failure = match freshness + let parked = freshness .indexing .as_ref() .and_then(|indexing| indexing.parked.as_ref()) - { - Some(parked) => format!( - "code-index search unavailable: parked: {}; remedy: {}", - parked.reason, parked.remediation - ), + .map(|parked| ApplicationProblemDetailV1::Parked { + cause: parked.reason.clone(), + remedy: parked.remediation.clone(), + retries_on_wake: parked.retries_on_wake, + }); + let failure = match &parked { + Some(detail) => detail.message(), None => format!("code-index search unavailable: {reason}"), }; + if let Some(detail) = parked { + output["detail"] = serde_json::to_value(detail)?; + } Ok(rendered_tool_result(ctx, &args, &output, Vec::new(), || { format!( "{}{}", diff --git a/crates/tracedecay-mcp/src/response_handles.rs b/crates/tracedecay-mcp/src/response_handles.rs index d083ee12b2..8b441b0e33 100644 --- a/crates/tracedecay-mcp/src/response_handles.rs +++ b/crates/tracedecay-mcp/src/response_handles.rs @@ -77,6 +77,7 @@ fn public_inventory_problem(error: &TraceDecayError) -> (&'static str, &'static | TraceDecayError::ResetRequired { .. } | TraceDecayError::ProjectRoute { .. } | TraceDecayError::SyncLock { .. } + | TraceDecayError::LockDeadline { .. } | TraceDecayError::Io(_) | TraceDecayError::Sqlite(_) | TraceDecayError::Json(_) @@ -111,6 +112,7 @@ pub fn public_retrieve_error(error: TraceDecayError) -> TraceDecayError { | TraceDecayError::ResetRequired { .. } | TraceDecayError::ProjectRoute { .. } | TraceDecayError::SyncLock { .. } + | TraceDecayError::LockDeadline { .. } | TraceDecayError::Io(_) | TraceDecayError::Sqlite(_) | TraceDecayError::Json(_) @@ -393,6 +395,7 @@ fn error_class(error: &TraceDecayError) -> &'static str { TraceDecayError::ProfileResetRequired { .. } => "profile_reset_required", TraceDecayError::ProjectRoute { .. } => "project_route", TraceDecayError::SyncLock { .. } => "sync_lock", + TraceDecayError::LockDeadline { .. } => "lock_deadline", TraceDecayError::Io(_) => "io", TraceDecayError::Sqlite(_) => "sqlite", TraceDecayError::Json(_) => "json", diff --git a/crates/tracedecay-mcp/src/tools/render.rs b/crates/tracedecay-mcp/src/tools/render.rs index f1c072344f..2850804949 100644 --- a/crates/tracedecay-mcp/src/tools/render.rs +++ b/crates/tracedecay-mcp/src/tools/render.rs @@ -384,7 +384,7 @@ fn run_blocking_handle_store(work: impl FnOnce() -> T) -> T { /// The adapter records the full typed error in internal telemetry. Public /// output must not disclose project-local filesystem paths. fn handle_store_failure_status(error: &TraceDecayError) -> Value { - if matches!(error, TraceDecayError::SyncLock { .. }) { + if matches!(error, TraceDecayError::LockDeadline { .. }) { return serde_json::json!({ "reason_code": "handle_store_busy", "message": "The local response-handle cache stayed busy with other writers past its admission deadline, so no retrieval handle is available.", diff --git a/crates/tracedecay-mcp/src/tools/render/tests.rs b/crates/tracedecay-mcp/src/tools/render/tests.rs index dbf52cd1d2..661a6dab37 100644 --- a/crates/tracedecay-mcp/src/tools/render/tests.rs +++ b/crates/tracedecay-mcp/src/tools/render/tests.rs @@ -206,11 +206,10 @@ fn truncated_json_envelope_reports_store_failure() { #[test] fn handle_store_busy_omits_paths() { - let busy = handle_store_failure_status(&TraceDecayError::SyncLock { - message: "response-handle writer lock at /private/profile/.response-handles.lock" - .to_owned(), + let busy = handle_store_failure_status(&TraceDecayError::LockDeadline { + resource: "response-handle writer lock", + deadline_ms: 10_000, }); assert_eq!(busy["reason_code"], "handle_store_busy"); assert_eq!(busy["retryable"], true); - assert!(!busy.to_string().contains("/private/profile")); } diff --git a/crates/tracedecay-sdk/src/codegen.rs b/crates/tracedecay-sdk/src/codegen.rs index a9ed6f0830..be019ed12d 100644 --- a/crates/tracedecay-sdk/src/codegen.rs +++ b/crates/tracedecay-sdk/src/codegen.rs @@ -243,6 +243,7 @@ const RESERVED_TYPESCRIPT_NAMES: &[&str] = &[ "ApplicationEnvelope", "ApplicationExecutionFailureClassV1", "ApplicationOutcome", + "ApplicationProblemDetailV1", "ApplicationProblemEnvelope", "ApplicationProblemKind", "ApplicationProblemRecord", @@ -1032,6 +1033,7 @@ export type CanonicalJsonSchema = Readonly>; export type CanonicalCancellation = Readonly>; export interface ContractRef { schema_id: string; schema_revision: number; [key: string]: unknown } export interface SafeDiagnostic { code: string; message: string; [key: string]: unknown } +export type ApplicationProblemDetailV1 = { kind: "parked"; cause: string; remedy: string; retries_on_wake: boolean; [key: string]: unknown } | { kind: "stale_refresh_frontier"; requested: number; committed: number; active: number; [key: string]: unknown } | { kind: "lock_deadline"; resource: string; deadline_ms: number; [key: string]: unknown }; export interface OperationReceipt { started_at: number; ended_at: number; effective_deadline: unknown; cancellation: unknown | null; budget: { units_consumed: number; bytes_consumed: number; elapsed_micros: number; [key: string]: unknown }; termination: OperationTermination; [key: string]: unknown } export interface EffectReceipt { operation: string; request_id: string; actor: string; scope: Record; effect_class: string; idempotency_key: string; input_digest: string; expected_state: string; policy_digest: string; configuration_digest: string; catalog_digest: string; privacy_digest: string; outcome: EffectTermination; committed_state: string | null; external_proof: string | null; [key: string]: unknown } export type PageCursor = { kind: "opaque"; cursor: string; [key: string]: unknown } | { kind: "fact_search"; cursor: { score_millionths: number; updated_at: number; fact_id: string; [key: string]: unknown }; [key: string]: unknown } | { kind: "fact_list_after"; fact_id: string; [key: string]: unknown }; export interface PageState { sort_contract_id: string; sort_revision: number; total: number | null; returned: number; cursor: PageCursor | null; expires_at: number | null; [key: string]: unknown } @@ -1040,7 +1042,7 @@ export interface PreviewResult { preview_id: string; preview_digest: string; export interface EffectResult { effect_id: string; effect_class: string; idempotency_key: string; authority: Record; expected_state: string; execution: OperationReceipt; reconciliation: string; receipt: Record; payload: T | null; [key: string]: unknown } export type ApplicationOutcome = { outcome: "evidence"; value: EvidencePacket; [key: string]: unknown } | { outcome: "preview"; value: PreviewResult; [key: string]: unknown } | { outcome: "effect"; value: EffectResult; [key: string]: unknown }; export interface ApplicationEnvelope { contract: ContractRef; request_id: string; scope: Record; outcome: ApplicationOutcome; [key: string]: unknown } -export interface ApplicationProblemRecord { revision: number; kind: ApplicationProblemKind; code: string; message: string; diagnostic: SafeDiagnostic | null; committed_receipt: EffectReceipt | null; owning_layer: string; terminality: string; retryable: boolean; retry: RetryDirective; retry_scope: string | null; retry_after_millis: number | null; cancellation_stage: string | null; unavailable_classification: ApplicationUnavailableClassV1 | null; execution_failure_classification: ApplicationExecutionFailureClassV1 | null; request_id: string; trace_id: string; details: SafeDiagnostic[]; legal_actions: LegalAction[]; coverage: unknown | null; [key: string]: unknown } +export interface ApplicationProblemRecord { revision: number; kind: ApplicationProblemKind; code: string; message: string; diagnostic: SafeDiagnostic | null; detail: ApplicationProblemDetailV1 | null; committed_receipt: EffectReceipt | null; owning_layer: string; terminality: string; retryable: boolean; retry: RetryDirective; retry_scope: string | null; retry_after_millis: number | null; cancellation_stage: string | null; unavailable_classification: ApplicationUnavailableClassV1 | null; execution_failure_classification: ApplicationExecutionFailureClassV1 | null; request_id: string; trace_id: string; details: SafeDiagnostic[]; legal_actions: LegalAction[]; coverage: unknown | null; [key: string]: unknown } export interface ApplicationProblemEnvelope { contract: ContractRef; request_id: string; problem: ApplicationProblemRecord; [key: string]: unknown } export type HttpSuccessEnvelope = ApplicationEnvelope & { binding_id: string }; export type HttpProblemEnvelope = ApplicationProblemEnvelope & { binding_id?: string }; diff --git a/crates/tracedecay-sdk/tests/sdk_suite/client.rs b/crates/tracedecay-sdk/tests/sdk_suite/client.rs index 22e719b176..0d28afa9eb 100644 --- a/crates/tracedecay-sdk/tests/sdk_suite/client.rs +++ b/crates/tracedecay-sdk/tests/sdk_suite/client.rs @@ -431,6 +431,7 @@ fn curate_rejects_a_problem_bound_to_a_foreign_replay_handle() { "problem": { "revision": 1, "kind": "conflict", "code": "retained.request_already_active", "message": "conflict", "diagnostic": {"code": "retained.request_already_active", "message": "conflict"}, + "detail": null, "committed_receipt": null, "owning_layer": "adapter", "terminality": "pre_admission", "retryable": true, "retry": "same_request", "retry_scope": "same_request", "retry_after_millis": null, "cancellation_stage": null, diff --git a/crates/tracedecay-session-memory/src/response_handles.rs b/crates/tracedecay-session-memory/src/response_handles.rs index f1447c3109..9d6cd168c4 100644 --- a/crates/tracedecay-session-memory/src/response_handles.rs +++ b/crates/tracedecay-session-memory/src/response_handles.rs @@ -459,14 +459,15 @@ fn validate_response_handle_path(path: &Path) -> Result<()> { } fn with_exclusive_lock(root: &Path, operation: impl FnOnce() -> Result) -> Result { - with_exclusive_lock_until(root, Instant::now() + WRITER_LOCK_DEADLINE, operation) + with_exclusive_lock_within(root, WRITER_LOCK_DEADLINE, operation) } -fn with_exclusive_lock_until( +fn with_exclusive_lock_within( root: &Path, - deadline: Instant, + budget: Duration, operation: impl FnOnce() -> Result, ) -> Result { + let deadline = Instant::now() + budget; validate_response_handle_path(root)?; let parent = root.parent().ok_or_else(|| TraceDecayError::File { message: "response-handle root has no stable lock parent".to_string(), @@ -489,11 +490,9 @@ fn with_exclusive_lock_until( .open(&path) .map_err(|error| file_error(&path, "open response-handle lock", error))?; lock_until(&lock, deadline).map_err(|error| match error { - LockAdmissionError::TimedOut => TraceDecayError::SyncLock { - message: format!( - "response-handle writer lock at {} stayed contended past its admission deadline; retry the operation", - path.display() - ), + LockAdmissionError::TimedOut => TraceDecayError::LockDeadline { + resource: "response-handle writer lock", + deadline_ms: u64::try_from(budget.as_millis()).unwrap_or(u64::MAX), }, LockAdmissionError::Io(error) => file_error(&path, "acquire response-handle lock", error), })?; @@ -673,21 +672,19 @@ mod tests { held.lock().unwrap(); let mut ran = false; - let result = with_exclusive_lock_until( - root.path(), - Instant::now() + Duration::from_millis(20), - || { - ran = true; - Ok(()) - }, - ); + let result = with_exclusive_lock_within(root.path(), Duration::from_millis(20), || { + ran = true; + Ok(()) + }); held.unlock().unwrap(); assert!(!ran, "a writer admitted past the deadline must not run"); assert!(matches!( result, - Err(TraceDecayError::SyncLock { message }) - if message.contains("response-handle writer lock") + Err(TraceDecayError::LockDeadline { + resource: "response-handle writer lock", + deadline_ms: 20, + }) )); inventory_response_handles(root.path()) .expect("the lock is admissible again once its holder releases it"); diff --git a/crates/tracedecay-session-memory/src/session/refresh.rs b/crates/tracedecay-session-memory/src/session/refresh.rs index 7c133428cd..1bf140d7b0 100644 --- a/crates/tracedecay-session-memory/src/session/refresh.rs +++ b/crates/tracedecay-session-memory/src/session/refresh.rs @@ -182,10 +182,13 @@ pub enum SessionRefreshOutcome { Denied, WrongScope, Stale, - /// The requested window no longer contains the committed projection - /// frontier; the caller rebuilds the request from that frontier. + /// The requested window (observed through `requested`, committed + /// through `committed`) no longer contains the committed projection + /// frontier `active`; the caller rebuilds the request from `active`. StaleFrontier { - active_projection_frontier: u64, + requested: u64, + committed: u64, + active: u64, }, NotFound, Aborted, @@ -353,11 +356,14 @@ where return SessionRefreshOutcome::Busy; } Ok(Err(SessionStoreError::StaleRefreshFrontier { + observed_through, + committed_through, active_projection_frontier, - .. })) => { return SessionRefreshOutcome::StaleFrontier { - active_projection_frontier, + requested: observed_through, + committed: committed_through, + active: active_projection_frontier, }; } Ok(Err(error)) => { diff --git a/crates/tracedecay-session-memory/src/session/refresh_service.rs b/crates/tracedecay-session-memory/src/session/refresh_service.rs index 9f42f3abf4..77a371303a 100644 --- a/crates/tracedecay-session-memory/src/session/refresh_service.rs +++ b/crates/tracedecay-session-memory/src/session/refresh_service.rs @@ -107,7 +107,9 @@ pub enum SessionRefreshServiceOutcome { WrongScope, Stale, StaleFrontier { - active_projection_frontier: u64, + requested: u64, + committed: u64, + active: u64, }, NotFound, Aborted, diff --git a/crates/tracedecay-session-runtime/src/retained.rs b/crates/tracedecay-session-runtime/src/retained.rs index 4c8acab8ef..caa406311a 100644 --- a/crates/tracedecay-session-runtime/src/retained.rs +++ b/crates/tracedecay-session-runtime/src/retained.rs @@ -8,8 +8,8 @@ use std::future::Future; use std::time::Duration; use tracedecay_contracts::{ - RequestAdmission, RetainedSurfaceExecutionContextV1, RetainedSurfaceExecutionErrorV1, - now_micros, + ApplicationProblem, ApplicationProblemDetailV1, RequestAdmission, + RetainedSurfaceExecutionContextV1, RetainedSurfaceExecutionErrorV1, now_micros, }; use tracedecay_domain::errors::TraceDecayError; @@ -115,6 +115,15 @@ pub fn map_execution_error(error: TraceDecayError) -> RetainedSurfaceExecutionEr TraceDecayError::ResetRequired { .. } => { RetainedSurfaceExecutionErrorV1::ProjectResetRequired } + TraceDecayError::LockDeadline { + resource, + deadline_ms, + } => RetainedSurfaceExecutionErrorV1::ApplicationProblem(ApplicationProblem::from_detail( + ApplicationProblemDetailV1::LockDeadline { + resource: resource.to_owned(), + deadline_ms, + }, + )), error @ (TraceDecayError::SyncLock { .. } | TraceDecayError::ProjectRoute { .. } | TraceDecayError::Database { .. } diff --git a/crates/tracedecay-session-runtime/src/retained/session/refresh.rs b/crates/tracedecay-session-runtime/src/retained/session/refresh.rs index 26fdfa0000..336b4ae876 100644 --- a/crates/tracedecay-session-runtime/src/retained/session/refresh.rs +++ b/crates/tracedecay-session-runtime/src/retained/session/refresh.rs @@ -6,7 +6,9 @@ use tracedecay_contracts::retained_surfaces::{ SessionRefreshReceiptV1, SessionRefreshScopeV1, SessionRefreshStatusResultV1, SessionRefreshTerminalStateResultV1, TemporalCoverageV1, }; -use tracedecay_contracts::{ApplicationProblem, RetainedSurfaceExecutionErrorV1, SafeDiagnostic}; +use tracedecay_contracts::{ + ApplicationProblem, ApplicationProblemDetailV1, RetainedSurfaceExecutionErrorV1, +}; use tracedecay_session_memory::session::{ SessionRefreshCoverageView, SessionRefreshFrontierView, SessionRefreshProgressView, @@ -56,11 +58,18 @@ pub(super) fn status_result( "the refresh handle does not belong to the requested scope", ), SessionRefreshServiceOutcome::StaleFrontier { - active_projection_frontier, + requested, + committed, + active, } => refresh_problem( RetainedOutcomeStatusV1::Stale, "refresh_frontier_stale", - &stale_frontier_message(active_projection_frontier), + &ApplicationProblemDetailV1::StaleRefreshFrontier { + requested, + committed, + active, + } + .message(), ), SessionRefreshServiceOutcome::Stale => refresh_problem( RetainedOutcomeStatusV1::Stale, @@ -244,11 +253,14 @@ fn effect_error(outcome: SessionRefreshServiceOutcome) -> RetainedSurfaceExecuti } SessionRefreshServiceOutcome::Stale => RetainedSurfaceExecutionErrorV1::Stale, SessionRefreshServiceOutcome::StaleFrontier { - active_projection_frontier, - } => RetainedSurfaceExecutionErrorV1::ApplicationProblem(ApplicationProblem::stale( - SafeDiagnostic { - code: "application.retained.refresh-frontier-stale".to_owned(), - message: stale_frontier_message(active_projection_frontier), + requested, + committed, + active, + } => RetainedSurfaceExecutionErrorV1::ApplicationProblem(ApplicationProblem::from_detail( + ApplicationProblemDetailV1::StaleRefreshFrontier { + requested, + committed, + active, }, )), SessionRefreshServiceOutcome::Aborted => RetainedSurfaceExecutionErrorV1::Cancelled( @@ -280,14 +292,6 @@ fn effect_error(outcome: SessionRefreshServiceOutcome) -> RetainedSurfaceExecuti } } -fn stale_frontier_message(active_projection_frontier: u64) -> String { - format!( - "The refresh window no longer contains the committed projection frontier \ - {active_projection_frontier}; begin again from source frontier \ - {active_projection_frontier}." - ) -} - fn refresh_problem( outcome: RetainedOutcomeStatusV1, code: &str, @@ -437,7 +441,9 @@ mod tests { fn a_stale_begin_frontier_is_a_stale_problem_naming_the_committed_frontier() { let error = begin_result( SessionRefreshServiceOutcome::StaleFrontier { - active_projection_frontier: 80, + requested: 120, + committed: 40, + active: 80, }, &profile_scope(), ) @@ -454,13 +460,23 @@ mod tests { problem.legal_actions(), &[tracedecay_contracts::LegalAction::Refresh] ); + let wire = serde_json::to_value(&problem).expect("serialized problem"); assert_eq!( - serde_json::to_value(&problem).expect("serialized problem")["diagnostic"], + wire["diagnostic"], serde_json::json!({ "code": "application.retained.refresh-frontier-stale", "message": "The refresh window no longer contains the committed projection frontier 80; begin again from source frontier 80." }) ); + assert_eq!( + wire["detail"], + serde_json::json!({ + "kind": "stale_refresh_frontier", + "requested": 120, + "committed": 40, + "active": 80 + }) + ); } #[test] diff --git a/crates/tracedecay-source-edit/src/journal.rs b/crates/tracedecay-source-edit/src/journal.rs index 2a7fb01cbe..188506c2a9 100644 --- a/crates/tracedecay-source-edit/src/journal.rs +++ b/crates/tracedecay-source-edit/src/journal.rs @@ -212,11 +212,10 @@ impl SourceEditDurability { pub(super) async fn lock(&self) -> Result { let deadline = Instant::now() + SOURCE_EDIT_ADMISSION_DEADLINE; let lock_path = self.root.join("source-edit.lock"); - let deadline_error = || TraceDecayError::SyncLock { - message: format!( - "source edit writer lock at {} stayed busy past its admission deadline; retry the edit", - lock_path.display() - ), + let deadline_error = || TraceDecayError::LockDeadline { + resource: "source-edit writer lock", + deadline_ms: u64::try_from(SOURCE_EDIT_ADMISSION_DEADLINE.as_millis()) + .unwrap_or(u64::MAX), }; let queued = tokio::time::timeout_at(deadline.into(), source_edit_owner(&self.root).lock_owned()) diff --git a/crates/tracedecay/src/daemon/profile_retained.rs b/crates/tracedecay/src/daemon/profile_retained.rs index d72a6356f6..35de1ed140 100644 --- a/crates/tracedecay/src/daemon/profile_retained.rs +++ b/crates/tracedecay/src/daemon/profile_retained.rs @@ -120,6 +120,7 @@ fn authority_problem(error: &TraceDecayError) -> ApplicationProblem { diagnostic, retry: tracedecay_contracts::RetryDirective::Never, legal_actions: Vec::new(), + detail: None, } } diff --git a/crates/tracedecay/src/daemon/tests/feedback_impact.rs b/crates/tracedecay/src/daemon/tests/feedback_impact.rs index 666e37bd64..3a49e58319 100644 --- a/crates/tracedecay/src/daemon/tests/feedback_impact.rs +++ b/crates/tracedecay/src/daemon/tests/feedback_impact.rs @@ -217,6 +217,7 @@ fn assert_concealed_impact(response: &JsonRpcResponse) { "code": "not_found_or_not_authorized", "message": "The requested resource was not found or is not authorized", "diagnostic": null, + "detail": null, "committed_receipt": null, "owning_layer": "application", "terminality": "pre_admission", diff --git a/crates/tracedecay/src/mcp/tools/handlers/application_surface.rs b/crates/tracedecay/src/mcp/tools/handlers/application_surface.rs index d863f235df..0ec81f958d 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/application_surface.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/application_surface.rs @@ -679,6 +679,7 @@ fn graph_tool_unavailable( diagnostic, retry: tracedecay_contracts::RetryDirective::Never, legal_actions: Vec::new(), + detail: None, } } diff --git a/crates/tracedecay/src/mcp/tools/handlers/retained_timeout_dispatch_tests.rs b/crates/tracedecay/src/mcp/tools/handlers/retained_timeout_dispatch_tests.rs index c09dd8d850..e167e9fa68 100644 --- a/crates/tracedecay/src/mcp/tools/handlers/retained_timeout_dispatch_tests.rs +++ b/crates/tracedecay/src/mcp/tools/handlers/retained_timeout_dispatch_tests.rs @@ -760,3 +760,171 @@ async fn fact_store_curate_pre_commit_cancellation_does_not_mutate() { assert_eq!(result.value["problem"]["kind"], "cancelled"); cg.close(); } + +/// Answers every invocation with one daemon response after a wire round trip, +/// so the problem the adapter renders is the one the daemon protocol carried. +struct WireProblemExecutor { + problem: ApplicationProblem, +} + +impl tracedecay_contracts::ApplicationInvocationExecutor for WireProblemExecutor { + fn invoke( + &self, + invocation: tracedecay_contracts::ApplicationInvocation, + ) -> tracedecay_contracts::ApplicationInvocationFuture< + '_, + std::result::Result< + tracedecay_contracts::ApplicationResponse, + tracedecay_contracts::InvocationError, + >, + > { + Box::pin(async move { + let (context, request) = invocation.into_parts(); + let tracedecay_contracts::ApplicationRequest::Surface { binding, payload } = request + else { + return Err(tracedecay_contracts::InvocationError::Unavailable); + }; + tracedecay_daemon_protocol::invoke_application_surface(self, context, binding, payload) + .await + }) + } +} + +impl tracedecay_daemon_protocol::DaemonInvocationExecutor for WireProblemExecutor { + fn invoke_controlled( + &self, + request: tracedecay_daemon_protocol::DaemonInvocationRequest, + _deadline: Deadline, + _cancellation: CancellationSignal, + _policy: tracedecay_daemon_protocol::InvocationCancellationPolicy, + ) -> tracedecay_daemon_protocol::DaemonInvocationExecutorFuture< + '_, + std::result::Result< + tracedecay_daemon_protocol::DaemonInvocationResponse, + tracedecay_daemon_protocol::DaemonInvocationError, + >, + > { + let response = tracedecay_daemon_protocol::DaemonInvocationResponse::application_problem( + &request.request_id, + self.problem.clone(), + ); + let response = serde_json::from_value( + serde_json::to_value(&response).expect("daemon response serializes"), + ) + .expect("daemon response decodes"); + Box::pin(async move { Ok(response) }) + } + + fn observe_feedback( + &self, + _subject_digest: ManifestDigest, + _observed_at: UtcMicros, + _event: tracedecay_contracts::feedback::observations::FeedbackSourceEventV1, + ) -> tracedecay_daemon_protocol::DaemonInvocationExecutorFuture< + '_, + tracedecay_domain::errors::Result<()>, + > { + Box::pin(async { Ok(()) }) + } +} + +#[tokio::test] +async fn a_stale_refresh_frontier_reaches_mcp_as_typed_detail() { + let _env_lock = lock_user_data_dir_test_env(); + let dir = TempDir::new().expect("fixture directory"); + let _env = SelectorEnv::new(dir.path()); + let project = dir.path().join("session-refresh-stale-frontier"); + std::fs::create_dir_all(project.join("src")).expect("fixture source directory"); + std::fs::write(project.join("src/lib.rs"), "pub fn refresh() {}\n").expect("fixture source"); + let (cg, _runtime) = TraceDecay::init_test_fixture_with_registered_runtime( + &project, + "project.mcp-session-refresh-stale-frontier", + ) + .await + .expect("registered retained fixture"); + let executor = WireProblemExecutor { + problem: ApplicationProblem::from_detail( + tracedecay_contracts::ApplicationProblemDetailV1::StaleRefreshFrontier { + requested: 120, + committed: 40, + active: 80, + }, + ), + }; + + for format in ["json", "markdown"] { + let result = handle_tool_call_with_registry_options( + &cg, + "tracedecay_session_refresh_begin", + json!({ + "scope": { "kind": "project" }, + "session": { "id": "session.stale-frontier" }, + "source": { "scope": "cursor" }, + "target": { + "temporal_mode": { "kind": "current" }, + "grain": "session", + "frontier": { "observed_through": 120, "committed_through": 40 } + }, + "format": format, + }), + None, + None, + ToolCallRegistryOptions { + application_invocation_executor: Some(&executor), + application_request_id: Some( + RequestId::new(format!("request.retained.mcp.stale-frontier.{format}")) + .expect("request id"), + ), + application_deadline: Some(deadline_from_now(Duration::from_secs(5))), + application_cancellation: Some( + CancellationSignal::active("cancel.retained.mcp.stale-frontier") + .expect("cancellation"), + ), + ..Default::default() + }, + ) + .await + .expect("a stale frontier renders a typed problem"); + + let problem = result.value["problem"].clone(); + assert_eq!( + ( + &problem["kind"], + &problem["code"], + &problem["message"], + &problem["detail"], + ), + ( + &json!("stale"), + &json!("application.retained.refresh-frontier-stale"), + &json!( + "The refresh window no longer contains the committed projection frontier \ + 80; begin again from source frontier 80." + ), + &json!({ + "kind": "stale_refresh_frontier", + "requested": 120, + "committed": 40, + "active": 80, + }), + ), + "{format}: {}", + result.value + ); + if format == "markdown" { + let text = result.value["content"][0]["text"] + .as_str() + .expect("markdown tool text"); + for line in [ + "- Requested frontier: 120", + "- Committed frontier: 40", + "- Active frontier: 80", + ] { + assert!(text.contains(line), "missing `{line}` in:\n{text}"); + } + } else { + assert_eq!(mcp_payload(result)["problem"], problem); + } + } + cg.close(); +} diff --git a/crates/tracedecay/tests/daemon_suite/code_index_park_test.rs b/crates/tracedecay/tests/daemon_suite/code_index_park_test.rs index 2094c55bdc..4e5f8e5714 100644 --- a/crates/tracedecay/tests/daemon_suite/code_index_park_test.rs +++ b/crates/tracedecay/tests/daemon_suite/code_index_park_test.rs @@ -4,18 +4,24 @@ //! A committed source file the daemon cannot read fails every reconcile the //! same way, so the worker parks convergence until the operator acts. A query //! that answers with a retryable refusal there is retried forever; it must -//! carry the park's cause and remedy and say that retrying cannot help. +//! carry the park's cause and remedy as typed `detail` on every surface (MCP, +//! the CLI, and HTTP) and say that retrying cannot help. use std::fs; use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::process::Stdio; use std::time::{Duration, Instant}; -use serde_json::json; +use serde_json::{Value, json}; use crate::code_index_journey::{ RECEIPT_TIMEOUT, commit_all, git, initialize_tracedecay, search, stop_daemon_gracefully, tool, }; -use crate::common::{EnvVarGuard, IsolatedEnv, daemon_socket_path, spawn_tracedecay_daemon_with}; +use crate::common::{ + EnvVarGuard, IsolatedEnv, daemon_authority_path, daemon_socket_path, http_agent_with_timeout, + response_to_json, spawn_tracedecay_daemon_with, tracedecay_command_with_home, +}; const CAUSE: &str = "code-index repository status failed: code-index classification: IO error \ while writing blob or reading file metadata or changing filetype"; @@ -49,7 +55,7 @@ async fn parked_worktree_queries_carry_the_park_and_are_not_retryable() { let log_path = environment.scratch().join("code-index-park-daemon.log"); let _daemon_log = EnvVarGuard::set("TRACEDECAY_TEST_DAEMON_LOG", &log_path); let mut daemon = spawn_tracedecay_daemon_with(environment.home(), |_| {}); - initialize_tracedecay(environment.home(), &project); + let project_id = initialize_tracedecay(environment.home(), &project); tracedecay_project::product_runtime::register_fixture_product_runtime(); let handshake = tracedecay::daemon::handshake_for_current_client(Some(project.clone()), None, false, false) @@ -78,18 +84,30 @@ async fn parked_worktree_queries_carry_the_park_and_are_not_retryable() { (&json!(CAUSE), &json!(REMEDY), &json!(false)), "search must carry the park, not only a retryable lane reason: {searched}" ); + let parked_detail = json!({ + "kind": "parked", + "cause": CAUSE, + "remedy": REMEDY, + "retries_on_wake": false, + }); + assert_eq!( + searched["detail"], parked_detail, + "search must carry the park as typed detail: {searched}" + ); + let request = json!({ + "query": "alpha", + "scope": { "path_prefix": null }, + "lazy_index_ignored_dependencies": false, + "meta": { "projection": "summary", "order": "relevance" }, + }); + let mut mcp_arguments = request.clone(); + mcp_arguments["format"] = json!("json"); let refused = tool( &socket, &handshake, "tracedecay_code_symbol_search", - json!({ - "query": "alpha", - "scope": { "path_prefix": null }, - "lazy_index_ignored_dependencies": false, - "meta": { "projection": "summary", "order": "relevance" }, - "format": "json", - }), + mcp_arguments.clone(), ) .await; let problem = &refused["problem"]; @@ -113,8 +131,112 @@ async fn parked_worktree_queries_carry_the_park_and_are_not_retryable() { format!("The code index for this worktree is parked; remedy: {REMEDY}; cause: {CAUSE}"), "the refusal must name the park's remedy and cause: {refused}" ); + assert_eq!(problem["detail"], parked_detail, "MCP content: {refused}"); + let structured = tracedecay::daemon::call_tool( + &socket, + &handshake, + "tracedecay_code_symbol_search", + mcp_arguments, + ) + .await + .expect("MCP symbol search transport"); + assert_eq!( + structured["structuredContent"]["problem"]["detail"], parked_detail, + "the structured MCP problem must carry the detail: {structured}" + ); + + let cli = cli_symbol_search(environment.home(), &project, &request, true); + assert_eq!( + (&cli["problem"]["detail"], &cli["problem"]["message"]), + (&parked_detail, &problem["message"]), + "CLI --json: {cli}" + ); + let cli_text = cli_symbol_search_text(environment.home(), &project, &request); + for line in [ + format!("- Parked cause: {CAUSE}"), + "- Retries on wake: false".to_owned(), + ] { + assert!( + cli_text.contains(&line), + "the CLI must print `{line}` as a labelled line:\n{cli_text}" + ); + } + + let (status, http) = http_symbol_search(environment.home(), &project_id, &request); + assert_eq!( + status, 503, + "a parked refusal is unavailable over HTTP: {http}" + ); + assert_eq!( + ( + &http["value"]["problem"]["detail"], + &http["value"]["problem"]["message"] + ), + (&parked_detail, &problem["message"]), + "HTTP problem body: {http}" + ); fs::set_permissions(&unreadable, fs::Permissions::from_mode(0o644)) .expect("restore the source mode"); stop_daemon_gracefully(&mut daemon); } + +fn cli_symbol_search_output(home: &Path, project: &Path, request: &Value, json: bool) -> String { + let project_arg = project.to_string_lossy().into_owned(); + let request = request.to_string(); + let mut command = tracedecay_command_with_home(home); + command.current_dir(project).args([ + "tool", + "--project", + project_arg.as_str(), + "code_symbol_search", + "--args", + request.as_str(), + ]); + if json { + command.arg("--json"); + } + let output = command + .stdin(Stdio::null()) + .output() + .expect("run tracedecay tool code_symbol_search"); + assert!( + !output.status.success(), + "a parked refusal must fail the CLI\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).expect("CLI stdout is UTF-8") +} + +fn cli_symbol_search(home: &Path, project: &Path, request: &Value, json: bool) -> Value { + let stdout = cli_symbol_search_output(home, project, request, json); + serde_json::from_str(stdout.lines().next().unwrap_or_default()) + .unwrap_or_else(|error| panic!("CLI --json printed no envelope ({error}):\n{stdout}")) +} + +fn cli_symbol_search_text(home: &Path, project: &Path, request: &Value) -> String { + cli_symbol_search_output(home, project, request, false) +} + +fn http_symbol_search(home: &Path, project_id: &str, request: &Value) -> (u16, Value) { + let authority: Value = serde_json::from_slice( + &fs::read(daemon_authority_path(&home.join(".tracedecay"))) + .expect("published daemon authority"), + ) + .expect("daemon authority JSON"); + let endpoint = authority["http_application_endpoint"] + .as_str() + .expect("daemon HTTP application endpoint"); + let token = authority["auth_token"].as_str().expect("daemon token"); + let base = format!("http://{endpoint}"); + let url = format!("{base}/projects/{project_id}/application/code/code_symbol_search"); + let response = http_agent_with_timeout(RECEIPT_TIMEOUT) + .post(&url) + .header("authorization", format!("Bearer {token}")) + .header("origin", base.as_str()) + .header("content-type", "application/json") + .send_json(request) + .unwrap_or_else(|error| panic!("POST {url} failed: {error}")); + response_to_json(response) +} diff --git a/crates/tracedecay/tests/mcp_suite/diagnostics_read_test.rs b/crates/tracedecay/tests/mcp_suite/diagnostics_read_test.rs index cacb77cca5..d5280e0d62 100644 --- a/crates/tracedecay/tests/mcp_suite/diagnostics_read_test.rs +++ b/crates/tracedecay/tests/mcp_suite/diagnostics_read_test.rs @@ -507,6 +507,7 @@ fn absent_producer_problem(request_id: &str, message: &str) -> Value { "code": ABSENT_PRODUCER_CODE, "message": message }, + "detail": null, "committed_receipt": null, "owning_layer": "application", "terminality": "pre_admission", diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/fact_store_add_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/fact_store_add_behavior_test.rs index d893098821..21e4fc622b 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/fact_store_add_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/fact_store_add_behavior_test.rs @@ -619,6 +619,7 @@ fn assert_invalid_request(response: &Value) { "code": "application.retained.invalid-request", "message": "The retained operation request is invalid." }, + "detail": null, "committed_receipt": null, "owning_layer": "application", "terminality": "pre_admission", diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/fact_store_remove_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/fact_store_remove_behavior_test.rs index 589d8ec602..35795bb83f 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/fact_store_remove_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/fact_store_remove_behavior_test.rs @@ -147,6 +147,7 @@ fn conflict_problem() -> Value { "code": "application.retained.conflict", "message": "The retained operation conflicts with current state." }, + "detail": null, "committed_receipt": null, "owning_layer": "application", "terminality": "pre_admission", @@ -170,6 +171,7 @@ fn hidden_fact_problem() -> Value { "code": "not_found_or_not_authorized", "message": "The requested resource was not found or is not authorized", "diagnostic": null, + "detail": null, "committed_receipt": null, "owning_layer": "application", "terminality": "pre_admission", diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/fact_store_search_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/fact_store_search_behavior_test.rs index 6ccdc9d1fc..e43276cde1 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/fact_store_search_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/fact_store_search_behavior_test.rs @@ -251,6 +251,7 @@ fn invalid_request_problem() -> Value { "code": "application.retained.invalid-request", "message": "The retained operation request is invalid." }, + "detail": null, "committed_receipt": null, "owning_layer": "application", "terminality": "pre_admission", diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/feedback_diagnostics_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/feedback_diagnostics_test.rs index dfee74936b..493b595e33 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/feedback_diagnostics_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/feedback_diagnostics_test.rs @@ -47,6 +47,7 @@ fn denied_problem(request_id: &str) -> Value { "code": "not_found_or_not_authorized", "message": "The requested resource was not found or is not authorized", "diagnostic": null, + "detail": null, "committed_receipt": null, "owning_layer": "application", "terminality": "pre_admission", diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/lcm_describe_behavior.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/lcm_describe_behavior.rs index 81ddc37231..10a69852ad 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/lcm_describe_behavior.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/lcm_describe_behavior.rs @@ -507,6 +507,7 @@ fn denied_document() -> Value { "problem": { "cancellation_stage": null, "code": "not_found_or_not_authorized", + "detail": null, "committed_receipt": null, "coverage": null, "details": [], diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/lcm_grep_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/lcm_grep_behavior_test.rs index 3b8f8834b3..135492d823 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/lcm_grep_behavior_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/lcm_grep_behavior_test.rs @@ -153,6 +153,7 @@ fn invalid_request_refusal() -> Value { "code": "application.retained.invalid-request", "message": "The retained operation request is invalid.", }, + "detail": null, "committed_receipt": null, "owning_layer": "application", "terminality": "pre_admission", @@ -187,6 +188,7 @@ fn unsupported_sort_refusal() -> Value { "code": "application.retained.unsupported", "message": "The retained authority does not support this request.", }, + "detail": null, "committed_receipt": null, "owning_layer": "application", "terminality": "pre_admission", diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/lcm_load_session_behavior.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/lcm_load_session_behavior.rs index 48f51a9de8..00314a8fc1 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/lcm_load_session_behavior.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/lcm_load_session_behavior.rs @@ -734,6 +734,7 @@ fn assert_not_found(payload: &Value) { "code": "not_found_or_not_authorized", "message": NOT_FOUND, "diagnostic": null, + "detail": null, "committed_receipt": null, "owning_layer": "application", "terminality": "pre_admission", diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/release_placement_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/release_placement_test.rs index 442af45477..0b5063c4d2 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/release_placement_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/release_placement_test.rs @@ -96,6 +96,7 @@ fn expected_conflict(code: &str, message: &str) -> Value { "code": code, "message": message }, + "detail": null, "committed_receipt": null, "owning_layer": "application", "terminality": "pre_admission", @@ -220,6 +221,7 @@ async fn release_placement_publishes_the_observed_state_without_deleting_bytes() "code": "not_found_or_not_authorized", "message": "The requested resource was not found or is not authorized", "diagnostic": null, + "detail": null, "committed_receipt": null, "owning_layer": "application", "terminality": "pre_admission", diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/session_refresh_cancel_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/session_refresh_cancel_test.rs index a60d13393c..e8fb1014bc 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/session_refresh_cancel_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/session_refresh_cancel_test.rs @@ -119,6 +119,7 @@ fn problem_record( "code": code, "message": message, "diagnostic": diagnostic, + "detail": null, "committed_receipt": null, "owning_layer": "application", "terminality": terminality, diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/work_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/work_test.rs index 313c473a26..b8909fb051 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/work_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/work_test.rs @@ -788,6 +788,7 @@ async fn generate_proposal_allows_a_ready_task_on_the_configured_route_and_refus "code": "not_found_or_not_authorized", "message": "The requested resource was not found or is not authorized", "diagnostic": null, + "detail": null, "committed_receipt": null, "owning_layer": "application", "terminality": "pre_admission", diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/workflow_activate_definition_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/workflow_activate_definition_test.rs index 575fd0f84f..c95bcca5bd 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/workflow_activate_definition_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/workflow_activate_definition_test.rs @@ -190,6 +190,7 @@ fn problem_record( "code": code, "message": message, "diagnostic": diagnostic, + "detail": null, "committed_receipt": null, "owning_layer": owning_layer, "terminality": "pre_admission", diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/workflow_get_definition_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/workflow_get_definition_test.rs index 16b00841ed..8ca00ac620 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/workflow_get_definition_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/workflow_get_definition_test.rs @@ -58,6 +58,7 @@ async fn workflow_get_definition_returns_the_stored_definition_and_conceals_a_mi "code": "workflow.invalid_request", "message": "The Workflow application request is invalid" }, + "detail": null, "committed_receipt": null, "owning_layer": "adapter", "terminality": "pre_admission", @@ -223,6 +224,7 @@ async fn concealed_miss(server: &tracedecay::mcp::McpServer, arguments: Value) - "code": "not_found_or_not_authorized", "message": "The requested resource was not found or is not authorized", "diagnostic": null, + "detail": null, "committed_receipt": null, "owning_layer": "runtime", "terminality": "pre_admission", diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/workflow_register_definition_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/workflow_register_definition_test.rs index 339b11cef2..c013cb7342 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/workflow_register_definition_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/workflow_register_definition_test.rs @@ -148,6 +148,7 @@ async fn register_definition_returns_the_submitted_definition_and_typed_refusals "code": "workflow.invalid_request", "message": "The Workflow application request is invalid" }, + "detail": null, "committed_receipt": null, "owning_layer": "adapter", "terminality": "pre_admission", @@ -199,6 +200,7 @@ async fn register_definition_returns_the_submitted_definition_and_typed_refusals "code": "not_found_or_not_authorized", "message": "The requested resource was not found or is not authorized", "diagnostic": null, + "detail": null, "committed_receipt": null, "owning_layer": "runtime", "terminality": "pre_admission", @@ -345,6 +347,7 @@ async fn register_definition_returns_the_submitted_definition_and_typed_refusals "code": "workflow.invalid_request", "message": "The Workflow application request is invalid" }, + "detail": null, "committed_receipt": null, "owning_layer": "runtime", "terminality": "pre_admission", diff --git a/crates/tracedecay/tests/mcp_suite/workflow_query_test.rs b/crates/tracedecay/tests/mcp_suite/workflow_query_test.rs index 6f22b079cc..c799a911db 100644 --- a/crates/tracedecay/tests/mcp_suite/workflow_query_test.rs +++ b/crates/tracedecay/tests/mcp_suite/workflow_query_test.rs @@ -544,6 +544,7 @@ fn invalid_request_problem() -> Value { "code": "application.retained.invalid-request", "message": "The retained operation request is invalid." }, + "detail": null, "committed_receipt": null, "owning_layer": "application", "terminality": "pre_admission", @@ -572,6 +573,7 @@ fn unbuilt_index_problem() -> Value { "code": "application.retained.authority-unavailable", "message": "The retained operation authority is unavailable: workflow_index_not_built: the workflow index has not been built for this project yet" }, + "detail": null, "committed_receipt": null, "owning_layer": "application", "terminality": "pre_admission", diff --git a/dashboard/codegen/schemas/dashboard-contracts.schema.json b/dashboard/codegen/schemas/dashboard-contracts.schema.json index a460d00071..50dd95f5e2 100644 --- a/dashboard/codegen/schemas/dashboard-contracts.schema.json +++ b/dashboard/codegen/schemas/dashboard-contracts.schema.json @@ -1334,6 +1334,93 @@ ], "type": "string" }, + "ApplicationProblemDetailV1": { + "description": "The structured facts behind a problem. Adapters read these fields; the\nproblem's `message` is only their one human rendering.", + "oneOf": [ + { + "additionalProperties": false, + "description": "The worktree's code index is parked until the operator applies\n`remedy`; repeating the request cannot change the answer.", + "properties": { + "cause": { + "type": "string" + }, + "kind": { + "const": "parked", + "type": "string" + }, + "remedy": { + "type": "string" + }, + "retries_on_wake": { + "type": "boolean" + } + }, + "required": [ + "kind", + "cause", + "remedy", + "retries_on_wake" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "A session refresh asked to begin from a source frontier the\ncommitted projection has already passed.", + "properties": { + "active": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "committed": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "kind": { + "const": "stale_refresh_frontier", + "type": "string" + }, + "requested": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "kind", + "requested", + "committed", + "active" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "A writer lock stayed held by other writers past its admission\ndeadline.", + "properties": { + "deadline_ms": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "kind": { + "const": "lock_deadline", + "type": "string" + }, + "resource": { + "type": "string" + } + }, + "required": [ + "kind", + "resource", + "deadline_ms" + ], + "type": "object" + } + ] + }, "ApplicationProblemEnvelope": { "additionalProperties": false, "description": "Stable application failure envelope. Partial effects and reset-required\nstates are admitted terminals; partial effects carry their committed\nreceipt directly while reset-required states carry an explicit action.", @@ -1378,13 +1465,13 @@ "description": "Stable application problem record shared verbatim by every adapter.", "properties": { "cancellation_stage": { - "$ref": "#/$defs/RequiredNullable2" + "$ref": "#/$defs/RequiredNullable3" }, "code": { "type": "string" }, "committed_receipt": { - "$ref": "#/$defs/RequiredNullable", + "$ref": "#/$defs/RequiredNullable2", "description": "A committed effect is present only for an admitted partial effect.\nThe nullable field is always serialized: omitting it would create a\ncompatibility/default path that could hide a missing receipt." }, "coverage": { @@ -1397,6 +1484,10 @@ } ] }, + "detail": { + "$ref": "#/$defs/RequiredNullable", + "description": "The structured facts `message` is rendered from, when the problem has\nany. Always serialized so a client never mistakes an omitted detail\nfor a problem without one." + }, "details": { "items": { "$ref": "#/$defs/SafeDiagnostic" @@ -1414,7 +1505,7 @@ ] }, "execution_failure_classification": { - "$ref": "#/$defs/RequiredNullable4" + "$ref": "#/$defs/RequiredNullable5" }, "kind": { "$ref": "#/$defs/ApplicationProblemKind" @@ -1470,7 +1561,7 @@ "type": "string" }, "unavailable_classification": { - "$ref": "#/$defs/RequiredNullable3" + "$ref": "#/$defs/RequiredNullable4" } }, "required": [ @@ -1479,6 +1570,7 @@ "code", "message", "diagnostic", + "detail", "committed_receipt", "owning_layer", "terminality", @@ -21228,7 +21320,7 @@ "RequiredNullable": { "anyOf": [ { - "$ref": "#/$defs/EffectReceipt" + "$ref": "#/$defs/ApplicationProblemDetailV1" }, { "type": "null" @@ -21239,7 +21331,7 @@ "RequiredNullable2": { "anyOf": [ { - "$ref": "#/$defs/CancellationStage" + "$ref": "#/$defs/EffectReceipt" }, { "type": "null" @@ -21250,7 +21342,7 @@ "RequiredNullable3": { "anyOf": [ { - "$ref": "#/$defs/ApplicationUnavailableClassV1" + "$ref": "#/$defs/CancellationStage" }, { "type": "null" @@ -21259,6 +21351,17 @@ "description": "Unlike `Option`, this wrapper distinguishes an explicit JSON `null`\nfrom an omitted field. New terminal-state fields must be present on every\nrecord so a missing committed receipt cannot be mistaken for `None`." }, "RequiredNullable4": { + "anyOf": [ + { + "$ref": "#/$defs/ApplicationUnavailableClassV1" + }, + { + "type": "null" + } + ], + "description": "Unlike `Option`, this wrapper distinguishes an explicit JSON `null`\nfrom an omitted field. New terminal-state fields must be present on every\nrecord so a missing committed receipt cannot be mistaken for `None`." + }, + "RequiredNullable5": { "anyOf": [ { "$ref": "#/$defs/ApplicationExecutionFailureClassV1" diff --git a/dashboard/src/contracts/generated.ts b/dashboard/src/contracts/generated.ts index 419c5de8d9..1606ddc0d2 100644 --- a/dashboard/src/contracts/generated.ts +++ b/dashboard/src/contracts/generated.ts @@ -383,6 +383,25 @@ export type AnalyticsUsageSummaryV1 = z.infer; +/** The structured facts behind a problem. Adapters read these fields; the +problem's `message` is only their one human rendering. */ +export const ApplicationProblemDetailV1Schema = z.discriminatedUnion("kind", [z.object({ + deadline_ms: z.number().int().safe().min(0), + kind: z.literal("lock_deadline"), + resource: z.string(), +}).strict(), z.object({ + cause: z.string(), + kind: z.literal("parked"), + remedy: z.string(), + retries_on_wake: z.boolean(), +}).strict(), z.object({ + active: z.number().int().safe().min(0), + committed: z.number().int().safe().min(0), + kind: z.literal("stale_refresh_frontier"), + requested: z.number().int().safe().min(0), +}).strict()]); +export type ApplicationProblemDetailV1 = z.infer; + /** Stable application failure envelope. Partial effects and reset-required states are admitted terminals; partial effects carry their committed receipt directly while reset-required states carry an explicit action. */ @@ -399,13 +418,14 @@ export type ApplicationProblemKind = z.infer RequiredNullable2Schema), + cancellation_stage: z.lazy(() => RequiredNullable3Schema), code: z.string(), - committed_receipt: z.lazy(() => RequiredNullableSchema), + committed_receipt: z.lazy(() => RequiredNullable2Schema), coverage: z.union([z.lazy(() => EvidenceCoverageSchema), z.null()]), + detail: z.lazy(() => RequiredNullableSchema), details: z.array(z.lazy(() => SafeDiagnosticSchema)), diagnostic: z.union([z.lazy(() => SafeDiagnosticSchema), z.null()]), - execution_failure_classification: z.lazy(() => RequiredNullable4Schema), + execution_failure_classification: z.lazy(() => RequiredNullable5Schema), kind: z.lazy(() => ApplicationProblemKindSchema), legal_actions: z.array(z.lazy(() => LegalActionSchema)), message: z.string(), @@ -418,7 +438,7 @@ export const ApplicationProblemRecordSchema = z.object({ revision: z.number().int().min(0), terminality: z.lazy(() => ProblemTerminalitySchema), trace_id: z.string(), - unavailable_classification: z.lazy(() => RequiredNullable3Schema), + unavailable_classification: z.lazy(() => RequiredNullable4Schema), }).strict(); export type ApplicationProblemRecord = z.infer; @@ -5057,27 +5077,33 @@ export type RequiredCheckV1 = z.infer; /** Unlike `Option`, this wrapper distinguishes an explicit JSON `null` from an omitted field. New terminal-state fields must be present on every record so a missing committed receipt cannot be mistaken for `None`. */ -export const RequiredNullableSchema = z.union([z.lazy(() => EffectReceiptSchema), z.null()]); +export const RequiredNullableSchema = z.union([z.lazy(() => ApplicationProblemDetailV1Schema), z.null()]); export type RequiredNullable = z.infer; /** Unlike `Option`, this wrapper distinguishes an explicit JSON `null` from an omitted field. New terminal-state fields must be present on every record so a missing committed receipt cannot be mistaken for `None`. */ -export const RequiredNullable2Schema = z.union([z.lazy(() => CancellationStageSchema), z.null()]); +export const RequiredNullable2Schema = z.union([z.lazy(() => EffectReceiptSchema), z.null()]); export type RequiredNullable2 = z.infer; /** Unlike `Option`, this wrapper distinguishes an explicit JSON `null` from an omitted field. New terminal-state fields must be present on every record so a missing committed receipt cannot be mistaken for `None`. */ -export const RequiredNullable3Schema = z.union([z.lazy(() => ApplicationUnavailableClassV1Schema), z.null()]); +export const RequiredNullable3Schema = z.union([z.lazy(() => CancellationStageSchema), z.null()]); export type RequiredNullable3 = z.infer; /** Unlike `Option`, this wrapper distinguishes an explicit JSON `null` from an omitted field. New terminal-state fields must be present on every record so a missing committed receipt cannot be mistaken for `None`. */ -export const RequiredNullable4Schema = z.union([z.lazy(() => ApplicationExecutionFailureClassV1Schema), z.null()]); +export const RequiredNullable4Schema = z.union([z.lazy(() => ApplicationUnavailableClassV1Schema), z.null()]); export type RequiredNullable4 = z.infer; +/** Unlike `Option`, this wrapper distinguishes an explicit JSON `null` +from an omitted field. New terminal-state fields must be present on every +record so a missing committed receipt cannot be mistaken for `None`. */ +export const RequiredNullable5Schema = z.union([z.lazy(() => ApplicationExecutionFailureClassV1Schema), z.null()]); +export type RequiredNullable5 = z.infer; + /** The resolved configuration scope is one exact project/repository/worktree root. Paths, CWDs, labels, and mutable branch spellings are deliberately absent. */ diff --git a/dashboard/src/data/query/automation.test.ts b/dashboard/src/data/query/automation.test.ts index 31ae004b02..dd4668ce14 100644 --- a/dashboard/src/data/query/automation.test.ts +++ b/dashboard/src/data/query/automation.test.ts @@ -822,6 +822,7 @@ function automaticProblem(kind: "conflict" | "partial_effect" | "reset_required" message: "The retained operation conflicts with current state.", } : null, + detail: null, committed_receipt: effectReceipt, owning_layer: "runtime", terminality: kind === "conflict" ? "pre_admission" : "admitted_terminal", diff --git a/dashboard/src/workspaces/knowledge/KnowledgeCuration.dom.test.tsx b/dashboard/src/workspaces/knowledge/KnowledgeCuration.dom.test.tsx index 7318eadf02..a28de326cb 100644 --- a/dashboard/src/workspaces/knowledge/KnowledgeCuration.dom.test.tsx +++ b/dashboard/src/workspaces/knowledge/KnowledgeCuration.dom.test.tsx @@ -664,6 +664,7 @@ function automaticProblem(kind: "partial_effect" | "reset_required") { ? "curation committed before projection failed" : "the retained memory store must be reset", diagnostic: null, + detail: null, committed_receipt: effectReceipt, owning_layer: "runtime", terminality: "admitted_terminal", diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts index 3385221f97..a054592b41 100644 --- a/sdks/typescript/src/client.ts +++ b/sdks/typescript/src/client.ts @@ -225,6 +225,30 @@ function isDiagnostic(value: unknown): boolean { ); } +function isProblemDetail(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + switch (value.kind) { + case "parked": + return ( + typeof value.cause === "string" && + typeof value.remedy === "string" && + typeof value.retries_on_wake === "boolean" + ); + case "stale_refresh_frontier": + return ( + isSafeUnsignedInteger(value.requested) && + isSafeUnsignedInteger(value.committed) && + isSafeUnsignedInteger(value.active) + ); + case "lock_deadline": + return typeof value.resource === "string" && isSafeUnsignedInteger(value.deadline_ms); + default: + return false; + } +} + function isReceipt(value: unknown): value is OperationReceipt { if ( !isRecord(value) || @@ -294,6 +318,7 @@ function isProblemEnvelope(value: unknown): value is HttpProblemEnvelope { const legalActions = Array.isArray(problem.legal_actions) ? problem.legal_actions : null; + const detailKinds = ["stale", "unavailable", "saturated"]; const diagnosticKinds = [ "invalid_request", "conflict", "partial_effect", "stale", "unsupported", "unavailable", "execution_failed", "reset_required", "saturated", @@ -314,6 +339,8 @@ function isProblemEnvelope(value: unknown): value is HttpProblemEnvelope { typeof problem.code === "string" && typeof problem.message === "string" && (problem.diagnostic === null || isDiagnostic(problem.diagnostic)) && + "detail" in problem && + (problem.detail === null || isProblemDetail(problem.detail)) && "committed_receipt" in problem && typeof problem.owning_layer === "string" && typeof problem.terminality === "string" && @@ -367,6 +394,8 @@ function isProblemEnvelope(value: unknown): value is HttpProblemEnvelope { (problem.cancellation_stage !== null) || (problem.diagnostic !== null) !== (typeof problem.kind === "string" && diagnosticKinds.includes(problem.kind)) || + (problem.detail !== null && + !(typeof problem.kind === "string" && detailKinds.includes(problem.kind))) || problem.retryable !== (problem.retry !== "never") || expectedRetryScope === undefined || problem.retry_scope !== expectedRetryScope || diff --git a/sdks/typescript/src/operations.ts b/sdks/typescript/src/operations.ts index 0c4a82dd9d..1fccdcf39b 100644 --- a/sdks/typescript/src/operations.ts +++ b/sdks/typescript/src/operations.ts @@ -592,7 +592,7 @@ export type ObservationSourceIdentityV1 = { readonly provider?: ProviderId; read export type ObservatoryReadModelV1 = { readonly analytics_mode: AnalyticsModeReadModelV1; readonly authorized_scope_ref: string; readonly comparison: PerformanceComparisonReadModelV1; readonly current: boolean; readonly horizon: ObservabilityHorizonV1; readonly metrics: readonly MetricValueV1[]; readonly observed_at_micros: number; readonly rejected_arguments: RejectedArgumentAnalyticsV1; readonly watermark: string; readonly [key: string]: unknown }; export type OccurrenceProvenance = { readonly evidence_role: EvidenceRole; readonly file_occurrence_id?: FileOccurrenceId | null; readonly freshness: SourceFreshness; readonly logical_copy_cluster_id?: LogicalCopyClusterId | null; readonly logical_copy_evidence_anchor?: RetrievalAnchorId | null; readonly repository_id?: RepositoryId | null; readonly retriever_evidence_anchor: RetrievalAnchorId; readonly session_or_thread_id?: SessionOrThreadId | null; readonly source_namespace: SourceNamespace; readonly source_occurrence_id: SourceOccurrenceId }; export type Omission = { readonly count: number; readonly domain: EvidenceDomain; readonly reason: OmissionReason }; -export type OmissionReason = "budget" | "redacted" | "unavailable" | "unsupported" | "stale" | "failed" | "cancelled" | "timed_out" | "conflict"; +export type OmissionReason = "budget" | "redacted" | "unavailable" | "unsupported" | "stale" | "failed" | "cancelled" | "timed_out" | "conflict" | "macro_body_unparsed"; export type OperationBudgetUsage = { readonly bytes_consumed: number; readonly elapsed_micros: number; readonly units_consumed: number }; export type OperationReceipt_2 = { readonly budget: OperationBudgetUsage; readonly cancellation?: CancellationObservation | null; readonly effective_deadline: Deadline; readonly ended_at: UtcMicros; readonly started_at: UtcMicros; readonly termination: OperationTermination_2 }; export type OperationTermination_2 = "completed" | "cancelled" | "timed_out" | "failed" | "unavailable" | "partial" | "effect_unknown"; @@ -2092,7 +2092,7 @@ const DEFINITIONS = { ObservatoryReadModelV1: {"properties":{"analytics_mode":{"$ref":"#/$defs/AnalyticsModeReadModelV1"},"authorized_scope_ref":{"type":"string"},"comparison":{"$ref":"#/$defs/PerformanceComparisonReadModelV1"},"current":{"type":"boolean"},"horizon":{"$ref":"#/$defs/ObservabilityHorizonV1"},"metrics":{"items":{"$ref":"#/$defs/MetricValueV1"},"type":"array"},"observed_at_micros":{"format":"int64","type":"integer"},"rejected_arguments":{"$ref":"#/$defs/RejectedArgumentAnalyticsV1"},"watermark":{"type":"string"}},"required":["authorized_scope_ref","horizon","watermark","observed_at_micros","current","metrics","analytics_mode","comparison","rejected_arguments"],"type":"object"}, OccurrenceProvenance: {"additionalProperties":false,"description":"Structured occurrence provenance retained through fusion. Fusion\npreserves each exact `(source_occurrence_id, retriever_evidence_anchor)`\npair; parallel unassociated provenance vectors are forbidden.","properties":{"evidence_role":{"$ref":"#/$defs/EvidenceRole"},"file_occurrence_id":{"anyOf":[{"$ref":"#/$defs/FileOccurrenceId"},{"type":"null"}]},"freshness":{"$ref":"#/$defs/SourceFreshness"},"logical_copy_cluster_id":{"anyOf":[{"$ref":"#/$defs/LogicalCopyClusterId"},{"type":"null"}]},"logical_copy_evidence_anchor":{"anyOf":[{"$ref":"#/$defs/RetrievalAnchorId"},{"type":"null"}]},"repository_id":{"anyOf":[{"$ref":"#/$defs/RepositoryId"},{"type":"null"}]},"retriever_evidence_anchor":{"$ref":"#/$defs/RetrievalAnchorId"},"session_or_thread_id":{"anyOf":[{"$ref":"#/$defs/SessionOrThreadId"},{"type":"null"}]},"source_namespace":{"$ref":"#/$defs/SourceNamespace"},"source_occurrence_id":{"$ref":"#/$defs/SourceOccurrenceId"}},"required":["source_occurrence_id","retriever_evidence_anchor","source_namespace","evidence_role","freshness"],"type":"object"}, Omission: {"additionalProperties":false,"properties":{"count":{"format":"uint64","minimum":0,"type":"integer"},"domain":{"$ref":"#/$defs/EvidenceDomain"},"reason":{"$ref":"#/$defs/OmissionReason"}},"required":["domain","count","reason"],"type":"object"}, - OmissionReason: {"description":"Safe reason why authorized requested evidence was omitted.","enum":["budget","redacted","unavailable","unsupported","stale","failed","cancelled","timed_out","conflict"],"type":"string"}, + OmissionReason: {"description":"Safe reason why authorized requested evidence was omitted.","oneOf":[{"enum":["budget","redacted","unavailable","unsupported","stale","failed","cancelled","timed_out","conflict"],"type":"string"},{"const":"macro_body_unparsed","description":"Evidence came from a macro body that was not expanded, so what the\nexpansion defines or calls is not covered.","type":"string"}]}, OperationBudgetUsage: {"additionalProperties":false,"description":"Bounded work accounting supplied by an owning port or transaction.","properties":{"bytes_consumed":{"format":"uint64","minimum":0,"type":"integer"},"elapsed_micros":{"format":"uint64","minimum":0,"type":"integer"},"units_consumed":{"format":"uint64","minimum":0,"type":"integer"}},"required":["units_consumed","bytes_consumed","elapsed_micros"],"type":"object"}, OperationReceipt_2: {"additionalProperties":false,"description":"Canonical operation evidence. An admitted failure remains represented here\nrather than being replaced by a transport exception.","properties":{"budget":{"$ref":"#/$defs/OperationBudgetUsage"},"cancellation":{"anyOf":[{"$ref":"#/$defs/CancellationObservation"},{"type":"null"}]},"effective_deadline":{"$ref":"#/$defs/Deadline"},"ended_at":{"$ref":"#/$defs/UtcMicros"},"started_at":{"$ref":"#/$defs/UtcMicros"},"termination":{"$ref":"#/$defs/OperationTermination"}},"required":["started_at","ended_at","effective_deadline","budget","termination"],"type":"object"}, OperationTermination_2: {"description":"Terminal state after an operation has been admitted.","enum":["completed","cancelled","timed_out","failed","unavailable","partial","effect_unknown"],"type":"string"}, diff --git a/sdks/typescript/src/types.ts b/sdks/typescript/src/types.ts index fc53a3bcb3..bd5a7f905b 100644 --- a/sdks/typescript/src/types.ts +++ b/sdks/typescript/src/types.ts @@ -15,6 +15,7 @@ export type CanonicalJsonSchema = Readonly>; export type CanonicalCancellation = Readonly>; export interface ContractRef { schema_id: string; schema_revision: number; [key: string]: unknown } export interface SafeDiagnostic { code: string; message: string; [key: string]: unknown } +export type ApplicationProblemDetailV1 = { kind: "parked"; cause: string; remedy: string; retries_on_wake: boolean; [key: string]: unknown } | { kind: "stale_refresh_frontier"; requested: number; committed: number; active: number; [key: string]: unknown } | { kind: "lock_deadline"; resource: string; deadline_ms: number; [key: string]: unknown }; export interface OperationReceipt { started_at: number; ended_at: number; effective_deadline: unknown; cancellation: unknown | null; budget: { units_consumed: number; bytes_consumed: number; elapsed_micros: number; [key: string]: unknown }; termination: OperationTermination; [key: string]: unknown } export interface EffectReceipt { operation: string; request_id: string; actor: string; scope: Record; effect_class: string; idempotency_key: string; input_digest: string; expected_state: string; policy_digest: string; configuration_digest: string; catalog_digest: string; privacy_digest: string; outcome: EffectTermination; committed_state: string | null; external_proof: string | null; [key: string]: unknown } export type PageCursor = { kind: "opaque"; cursor: string; [key: string]: unknown } | { kind: "fact_search"; cursor: { score_millionths: number; updated_at: number; fact_id: string; [key: string]: unknown }; [key: string]: unknown } | { kind: "fact_list_after"; fact_id: string; [key: string]: unknown }; export interface PageState { sort_contract_id: string; sort_revision: number; total: number | null; returned: number; cursor: PageCursor | null; expires_at: number | null; [key: string]: unknown } @@ -23,7 +24,7 @@ export interface PreviewResult { preview_id: string; preview_digest: string; export interface EffectResult { effect_id: string; effect_class: string; idempotency_key: string; authority: Record; expected_state: string; execution: OperationReceipt; reconciliation: string; receipt: Record; payload: T | null; [key: string]: unknown } export type ApplicationOutcome = { outcome: "evidence"; value: EvidencePacket; [key: string]: unknown } | { outcome: "preview"; value: PreviewResult; [key: string]: unknown } | { outcome: "effect"; value: EffectResult; [key: string]: unknown }; export interface ApplicationEnvelope { contract: ContractRef; request_id: string; scope: Record; outcome: ApplicationOutcome; [key: string]: unknown } -export interface ApplicationProblemRecord { revision: number; kind: ApplicationProblemKind; code: string; message: string; diagnostic: SafeDiagnostic | null; committed_receipt: EffectReceipt | null; owning_layer: string; terminality: string; retryable: boolean; retry: RetryDirective; retry_scope: string | null; retry_after_millis: number | null; cancellation_stage: string | null; unavailable_classification: ApplicationUnavailableClassV1 | null; execution_failure_classification: ApplicationExecutionFailureClassV1 | null; request_id: string; trace_id: string; details: SafeDiagnostic[]; legal_actions: LegalAction[]; coverage: unknown | null; [key: string]: unknown } +export interface ApplicationProblemRecord { revision: number; kind: ApplicationProblemKind; code: string; message: string; diagnostic: SafeDiagnostic | null; detail: ApplicationProblemDetailV1 | null; committed_receipt: EffectReceipt | null; owning_layer: string; terminality: string; retryable: boolean; retry: RetryDirective; retry_scope: string | null; retry_after_millis: number | null; cancellation_stage: string | null; unavailable_classification: ApplicationUnavailableClassV1 | null; execution_failure_classification: ApplicationExecutionFailureClassV1 | null; request_id: string; trace_id: string; details: SafeDiagnostic[]; legal_actions: LegalAction[]; coverage: unknown | null; [key: string]: unknown } export interface ApplicationProblemEnvelope { contract: ContractRef; request_id: string; problem: ApplicationProblemRecord; [key: string]: unknown } export type HttpSuccessEnvelope = ApplicationEnvelope & { binding_id: string }; export type HttpProblemEnvelope = ApplicationProblemEnvelope & { binding_id?: string }; diff --git a/sdks/typescript/test/client.test.ts b/sdks/typescript/test/client.test.ts index 2e056f003f..46c693b877 100644 --- a/sdks/typescript/test/client.test.ts +++ b/sdks/typescript/test/client.test.ts @@ -24,6 +24,7 @@ import { TraceDecayPartialEffectError, TraceDecayProtocolError, TraceDecayResetRequiredError, + TraceDecayUnavailableError, createClient, type OperationRequestOptions, } from "../src/client"; @@ -243,6 +244,7 @@ function problemEnvelope( unavailableClassification?: string | null; executionFailureClassification?: string | null; diagnostic?: unknown; + detail?: unknown; terminality?: string; } = {}, ) { @@ -284,6 +286,7 @@ function problemEnvelope( code, message: code, diagnostic, + detail: options.detail ?? null, committed_receipt: options.committedReceipt ?? null, owning_layer: "application", terminality: @@ -1000,6 +1003,46 @@ describe("TraceDecayClient transport envelopes", () => { ); }); + it("surfaces a problem's typed detail and refuses one on a kind without detail", async () => { + const detail = { + kind: "parked", + cause: "source unreadable", + remedy: "restore the mode, then run `tracedecay sync`", + retries_on_wake: false, + }; + const parked = problemEnvelope("unavailable", "application.code-index.parked", { + bindingId: "binding.http.workflow.list_definitions", + legalActions: ["reconcile"], + detail, + }); + const grafted = problemEnvelope("invalid_request", "request.invalid", { + bindingId: "binding.http.workflow.list_definitions", + legalActions: ["correct_request"], + detail, + }); + + await withServer( + [ + (_request, response) => json(response, 503, parked), + (_request, response) => json(response, 400, grafted), + ], + async (baseUrl) => { + const client = createClient({ + baseUrl, + projectId: "project.sdk", + token: "sdk-secret", + }); + + const refusal = await requestThroughTransport(client).catch((error: unknown) => error); + expect(refusal).toBeInstanceOf(TraceDecayUnavailableError); + expect((refusal as TraceDecayUnavailableError).problem.detail).toEqual(detail); + await expect(requestThroughTransport(client)).rejects.toBeInstanceOf( + TraceDecayMalformedResponseError, + ); + }, + ); + }); + it("rejects classified admitted terminals that Rust would reject", async () => { const unavailable = problemEnvelope("unavailable", "backend.unavailable", { unavailableClassification: "backend_unavailable",