Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ use tracedecay_automation::backend::AgentTaskFailureClass;
use tracedecay_contracts::retained_surfaces::{AutomationRunProblemV1, AutomationRunRequestV1};
use tracedecay_contracts::{
ApplicationExecutionFailureClassV1, ApplicationProblem, ApplicationProblemEnvelope,
ApplicationUnavailableClassV1, CancellationSignal, CancellationStage, LegalAction,
ProblemOwningLayer, RequestAdmission, RequestContext, RetryDirective, SafeDiagnostic,
ApplicationUnavailableClassV1, CancellationSignal, CancellationStage, ProblemOwningLayer,
RequestAdmission, RequestContext, SafeDiagnostic,
};

use crate::automation::run_ledger::AutomationRunLedgerRecord;
Expand All @@ -23,15 +23,13 @@ pub fn reset_required_problem(
operation,
context,
request,
ApplicationProblem::ResetRequired {
diagnostic: SafeDiagnostic::new(
ApplicationProblem::reset_required(
SafeDiagnostic::new(
"application.automation-run.reset-required",
"The interrupted automation run has no reconcilable terminal; preserve its exact run identity and reset it explicitly before reuse.",
)
.map_err(contract_error)?,
retry: RetryDirective::Never,
legal_actions: vec![LegalAction::Reset],
},
),
)
}

Expand All @@ -44,15 +42,13 @@ pub fn indeterminate_external_effect_problem(
operation,
context,
request,
ApplicationProblem::ResetRequired {
diagnostic: SafeDiagnostic::new(
ApplicationProblem::reset_required(
SafeDiagnostic::new(
"application.automation-run.external-effect-indeterminate",
"An external automation effect may have committed before its terminal was published; the effect will not be replayed and its exact run identity requires explicit reconciliation or reset.",
)
.map_err(contract_error)?,
retry: RetryDirective::Never,
legal_actions: vec![LegalAction::Reset],
},
),
)
}

Expand Down Expand Up @@ -116,15 +112,13 @@ pub fn runtime_problem(
return Ok(problem);
}
if error.reset_required_context().is_some() {
return Ok(ApplicationProblem::ResetRequired {
diagnostic: SafeDiagnostic::new(
return Ok(ApplicationProblem::reset_required(
SafeDiagnostic::new(
"application.automation-run.reset-required",
"The admitted automation authority requires an explicit reset before reuse.",
)
.map_err(contract_error)?,
retry: RetryDirective::Never,
legal_actions: vec![LegalAction::Reset],
});
));
}
ApplicationProblem::execution_failed(
ApplicationExecutionFailureClassV1::Permanent,
Expand Down
1 change: 1 addition & 0 deletions crates/tracedecay-cli/src/work_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,7 @@ mod tests {
diagnostic,
retry,
legal_actions,
..
} = problem
else {
panic!("work reset must remain a typed reset-required problem");
Expand Down
1 change: 1 addition & 0 deletions crates/tracedecay-cli/src/workflow_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,7 @@ mod reset_problem_tests {
diagnostic,
retry,
legal_actions,
..
} = problem
else {
panic!("workflow reset must remain a typed reset-required problem");
Expand Down
2 changes: 2 additions & 0 deletions crates/tracedecay-contracts/src/result/envelope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ impl<'de> Deserialize<'de> for ApplicationProblemRecord {
wire.kind,
ApplicationProblemKind::Stale
| ApplicationProblemKind::Unavailable
| ApplicationProblemKind::ResetRequired
| ApplicationProblemKind::Saturated
);
if detail.is_some() && !detailed_kind {
Expand Down Expand Up @@ -356,6 +357,7 @@ impl<'de> Deserialize<'de> for ApplicationProblemRecord {
diagnostic,
retry: wire.retry,
legal_actions: legal_actions.clone(),
detail: detail.clone().map(Box::new),
}
}
(ApplicationProblemKind::Saturated, Some(diagnostic), None) => {
Expand Down
16 changes: 15 additions & 1 deletion crates/tracedecay-contracts/src/result/problem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ pub enum ApplicationProblem {
diagnostic: SafeDiagnostic,
retry: RetryDirective,
legal_actions: Vec<LegalAction>,
detail: Option<Box<ApplicationProblemDetailV1>>,
},
Saturated {
diagnostic: SafeDiagnostic,
Expand Down Expand Up @@ -285,6 +286,8 @@ enum ApplicationProblemWire {
diagnostic: SafeDiagnostic,
retry: RetryDirective,
legal_actions: Vec<LegalAction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
detail: Option<Box<ApplicationProblemDetailV1>>,
},
Saturated {
diagnostic: SafeDiagnostic,
Expand Down Expand Up @@ -392,10 +395,12 @@ impl From<ApplicationProblem> for ApplicationProblemWire {
diagnostic,
retry,
legal_actions,
detail,
} => Self::ResetRequired {
diagnostic,
retry,
legal_actions,
detail,
},
ApplicationProblem::Saturated {
diagnostic,
Expand Down Expand Up @@ -537,10 +542,12 @@ impl ApplicationProblem {
diagnostic,
retry,
legal_actions,
detail,
} => Self::ResetRequired {
diagnostic,
retry,
legal_actions,
detail,
},
ApplicationProblemWire::Saturated {
diagnostic,
Expand Down Expand Up @@ -885,21 +892,27 @@ impl ApplicationProblem {
legal_actions: vec![LegalAction::Retry],
detail: Some(Box::new(detail)),
},
ApplicationProblemDetailV1::ResetRequired { .. } => Self::ResetRequired {
diagnostic,
retry: RetryDirective::Never,
legal_actions: vec![LegalAction::Reset],
detail: Some(Box::new(detail)),
},
}
}

pub fn detail(&self) -> Option<&ApplicationProblemDetailV1> {
match self {
Self::Stale { detail, .. }
| Self::Unavailable { detail, .. }
| Self::ResetRequired { 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,
}
Expand Down Expand Up @@ -966,6 +979,7 @@ impl ApplicationProblem {
diagnostic,
retry: RetryDirective::Never,
legal_actions: vec![LegalAction::Reset],
detail: None,
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/tracedecay-contracts/src/result/problem/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ fn direct_serialization_rejects_an_invalid_terminal() {
.expect("fixture diagnostic is valid"),
retry: RetryDirective::AfterDelay,
legal_actions: vec![LegalAction::Retry],
detail: None,
};
assert!(serde_json::to_value(invalid).is_err());
}
68 changes: 68 additions & 0 deletions crates/tracedecay-contracts/src/result/problem_detail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ pub enum ApplicationProblemDetailV1 {
/// A writer lock stayed held by other writers past its admission
/// deadline.
LockDeadline { resource: String, deadline_ms: u64 },
/// A persisted store whose shape this binary does not open. It is served
/// in this typed state until the operator runs `remedy`, which deletes
/// the old data; nothing is migrated or backed up.
ResetRequired {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Regenerate the TypeScript SDK detail union

Adding this wire variant without updating crates/tracedecay-sdk/src/codegen.rs and regenerating sdks/typescript/src/types.ts leaves the public SDK's ApplicationProblemDetailV1 union limited to parked, stale_refresh_frontier, and lock_deadline. Responses from the new profile-retained and MCP paths therefore carry a reset_required discriminant that TypeScript declares impossible, preventing callers from narrowing the detail or accessing fields such as authority and remedy with their intended types. Update the generator and regenerate the SDK sources alongside the dashboard contracts.

AGENTS.md reference: AGENTS.md:L63-L68

Useful? React with 👍 / 👎.

authority: String,
found_version: Option<i64>,
required_version: Option<i64>,
reason: String,
remedy: String,
},
}

impl ApplicationProblemDetailV1 {
Expand All @@ -44,12 +54,41 @@ impl ApplicationProblemDetailV1 {
}
}

/// The typed detail of a persisted-shape refusal, reset by `remedy`.
pub fn from_reset_required(error: &TraceDecayError, remedy: impl Into<String>) -> Option<Self> {
let (authority, found_version, required_version) = match error {
TraceDecayError::ResetRequired { authority, .. } => (authority.clone(), None, None),
TraceDecayError::ProfileResetRequired {
component,
found_version,
required_version,
} => (
(*component).to_owned(),
*found_version,
Some(*required_version),
),
_ => return None,
};
let reason = match error {
TraceDecayError::ResetRequired { reason, .. } => reason.clone(),
_ => error.to_string(),
};
Some(Self::ResetRequired {
authority,
found_version,
required_version,
reason,
remedy: remedy.into(),
})
}

/// 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",
Self::ResetRequired { .. } => "application.reset-required",
}
}

Expand All @@ -72,6 +111,13 @@ impl ApplicationProblemDetailV1 {
"The {resource} stayed busy past its {deadline_ms}ms admission deadline; retry \
the operation."
),
// The remedy leads so a long reason is what the bound cuts.
Self::ResetRequired {
authority,
reason,
remedy,
..
} => format!("The {authority} requires an explicit reset with `{remedy}`: {reason}"),
};
let folded = tracedecay_domain::fold_control_characters(&text);
tracedecay_domain::utf8_prefix_at_or_before(folded.trim(), MAX_RENDERED_MESSAGE_BYTES)
Expand Down Expand Up @@ -107,6 +153,28 @@ impl ApplicationProblemDetailV1 {
("Lock resource", resource.clone()),
("Lock deadline", format!("{deadline_ms}ms")),
],
Self::ResetRequired {
authority,
found_version,
required_version,
reason,
remedy,
} => {
let mut fields = vec![("Reset authority", authority.clone())];
if let Some(required_version) = required_version {
fields.push((
"Found version",
found_version.map_or_else(
|| "unversioned".to_owned(),
|version| version.to_string(),
),
));
fields.push(("Required version", required_version.to_string()));
}
fields.push(("Reset reason", reason.clone()));
fields.push(("Reset remedy", remedy.clone()));
fields
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use crate::retained_surfaces::{
use crate::{
ApplicationExecutionFailureClassV1, ApplicationProblem, ApplicationProblemEnvelope,
ApplicationUnavailableClassV1, CancellationStage, EffectReceipt, EffectTermination,
IdempotencyKey, LegalAction, RequestId, ResolvedScope, RetryDirective, SafeDiagnostic,
IdempotencyKey, RequestId, ResolvedScope, SafeDiagnostic,
};

fn zero_terminal(status: &str) -> Value {
Expand Down Expand Up @@ -693,15 +693,13 @@ fn non_partial_problem_rejects_committed_memory_receipts() {
let operation =
retained_surface_application_operation(RetainedSurfaceOperation::FactStoreCurate)
.expect("automation operation");
let problem = ApplicationProblem::ResetRequired {
diagnostic: SafeDiagnostic::new(
let problem = ApplicationProblem::reset_required(
SafeDiagnostic::new(
"application.automation-run.reset-required",
"The exact admitted run requires reconciliation before it can resume",
)
.expect("diagnostic"),
retry: RetryDirective::Never,
legal_actions: vec![LegalAction::Reset],
};
);
let problem = ApplicationProblemEnvelope::new(
operation.result_contract().clone(),
request_id.clone(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,6 @@ fn maintenance_window_waits_out_the_lease_of_the_daemon_it_just_stopped() {
#[cfg(target_os = "linux")]
#[test]
fn restore_check_accepts_a_reset_required_daemon_and_returns_its_pending_reset() {
let _env_lock = lock_user_data_dir_test_env();
let fixture = DrainingDaemonFixture::new();
let authority = super::tests::seed_socket_authority(&fixture.socket_path);
let listener = UnixListener::bind(&fixture.socket_path).expect("bind restored daemon");
Expand All @@ -292,9 +291,12 @@ fn restore_check_accepts_a_reset_required_daemon_and_returns_its_pending_reset()
authority.auth_token().to_owned(),
);

let (state, _, socket, protocol) =
super::installed_service_status_snapshot(&fixture.runner, super::tests::TEST_BUILD_VERSION)
.expect("restored service snapshot");
let (state, _, socket, protocol) = super::installed_service_status_snapshot(
&fixture.profile,
&fixture.runner,
super::tests::TEST_BUILD_VERSION,
)
.expect("restored service snapshot");
server.join().expect("join restored daemon");

assert!(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use tracedecay_contracts::{
ApplicationContractError, ApplicationProblem, ApplicationProblemEnvelope, LegalAction,
ProblemOwningLayer, RequestId, ResultContractRef, RetryDirective, SafeDiagnostic,
ApplicationContractError, ApplicationProblem, ApplicationProblemDetailV1,
ApplicationProblemEnvelope, LegalAction, ProblemOwningLayer, RequestId, ResultContractRef,
RetryDirective, SafeDiagnostic,
};
use tracedecay_daemon_protocol::{
ApplicationSurfaceAdapterError, CatalogBindingResolver, DispatchError,
Expand Down Expand Up @@ -118,27 +119,20 @@ pub(super) fn http_adapter_problem(
/// The refusal settles before any project server exists, so the MCP boundary
/// cannot route the call to its handler; the truthful answer for the named
/// operation is the reset-required terminal under its own mounted MCP result
/// contract, naming `reset_command`, the exact command that performs the one
/// legal action, so the agent can relay it. Returns `None` for tools without
/// a mounted application binding.
/// contract, whose typed detail names the refused authority, its versions,
/// and the exact command that performs the one legal action, so the agent can
/// relay it. Returns `None` for tools without a mounted application binding.
pub fn mcp_project_open_reset_refusal(
tool_name: &str,
request_id: RequestId,
authority: &str,
reason: &str,
reset_command: &str,
detail: ApplicationProblemDetailV1,
) -> Option<ApplicationProblemEnvelope> {
let operation = ApplicationSurfaceOperation::from_tool_name(tool_name)?;
let catalog = application_surface_catalog_ref().ok()?;
let resolver = CatalogBindingResolver::new(catalog);
let binding = resolve_application_binding(&resolver, BindingSurface::Mcp, operation)?;
let contract = ResultContractRef::from_schema(&binding.result_schema);
let problem = ApplicationProblem::reset_required(SafeDiagnostic {
code: "application.surface.reset_required".to_owned(),
message: format!(
"The {authority} requires an explicit reset: {reason}. Reset it with `{reset_command}`"
),
});
let problem = ApplicationProblem::from_detail(detail);
ApplicationProblemEnvelope::new(contract, request_id, problem)
.ok()
.map(|envelope| envelope.with_owning_layer(ProblemOwningLayer::Runtime))
Expand Down
1 change: 1 addition & 0 deletions crates/tracedecay-mcp/src/handlers/multi_root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ mod tests {
diagnostic,
retry,
legal_actions,
..
} = daemon_problem(DaemonInvocationProblem::ResetRequired)
else {
panic!("multi-root reset must remain reset-required");
Expand Down
Loading
Loading