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 @@ -1439,12 +1439,30 @@ fn validate_workspace_coordination_host()
// text-only turn or a surface with only read-only tools needs no writer host.
async fn validate_local_workspace_host_for_tool_surface(
executor: &ToolExecutor,
) -> Result<(), String> {
) -> Result<(), astra_core::ClassifiedError> {
if executor
.current_surface_requires_workspace_coordination()
.await
{
validate_workspace_coordination_host().map_err(|error| error.to_string())?;
validate_workspace_coordination_host().map_err(|error| {
use astra_tools::workspace_observation::WorkspaceLeaseFailure;
match error {
WorkspaceLeaseFailure::CoordinationHostUnsupported => {
astra_core::ClassifiedError::new(
astra_core::ErrorKind::ToolUnavailable,
error.to_string(),
)
.with_details_json(
serde_json::json!({
"code": astra_tools::TOOL_ERROR_KIND_WORKSPACE_HOST_UNSUPPORTED,
"retryable": false,
})
.to_string(),
)
}
other => astra_core::ClassifiedError::from(other.to_string()),
}
})?;
}
Ok(())
}
Expand All @@ -1457,7 +1475,7 @@ async fn chat_turn_post_payload_after_prepare(
ChatTurnPrepLineGuard,
Option<crate::cli::stream::stream_json::StreamJsonExchange>,
),
String,
astra_core::ClassifiedError,
> {
let ChatTurnPostPayloadRequest {
server_admission_dispatched,
Expand Down Expand Up @@ -1578,7 +1596,7 @@ async fn chat_turn_post_payload_after_prepare(

pub(crate) async fn fetch_chat_turn_sse(
ctx: ChatTurnSseFetchRequest<'_>,
) -> Result<TurnResult, String> {
) -> Result<TurnResult, astra_core::ClassifiedError> {
let ChatTurnSseFetchRequest {
server_admission_dispatched,
completion_checks,
Expand Down Expand Up @@ -1731,7 +1749,8 @@ pub(crate) async fn fetch_chat_turn_sse(
status.as_u16(),
body.as_str(),
compact_or_raw,
));
)
.into());
}

// Paired with `Acquire` load in `PlanAssembleLineSpinner`: publish "HTTP response ready"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ pub(super) async fn consume_remote_turn(
host.last_error_metadata.as_ref(),
host.last_physical_run_id.as_deref(),
);
if rejected {
if rejected || (result.is_err() && !*host.server_admission_dispatched) {
state.turn_event_buffer = None;
state.step_recorder.discard_uncommitted();
state.interruption = None;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1097,7 +1097,19 @@ impl CliServerAdmissionHost<'_> {
refresh_root_permission_context(&mut state.permission_context, self.perm_manager).await;
state.approval_overrides = self.perm_manager.export_session_overrides();

let mut turn_result = turn_result?;
let mut turn_result = turn_result.inspect_err(|error| {
if let Some(details) = error
.details_json
.as_deref()
.and_then(|details| serde_json::from_str::<Value>(details).ok())
{
self.last_error_code = details
.get("code")
.and_then(Value::as_str)
.map(str::to_owned);
self.last_error_metadata = Some(details);
}
})?;

// Capture observed coordination before ingest can reject an incomplete
// remote terminal. The existing success/failure journal commit owns
Expand Down
66 changes: 59 additions & 7 deletions crates/astra-cli/src/cli/command_router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2841,15 +2841,19 @@ async fn execute_cli_command_impl(
print_one_shot_completion_warning(&sr, exit_code, args.json);
return Ok(exit_code);
}
let failure_exit = hard_failure_exit_code(&e);
eprintln!(
"{}",
crate::cli::turn::turn_failure_reporting::turn_failure_diagnostic(&e)
);
if args.json {
let error = e.error.clone();
write_headless_stdout_line(
&serde_json::to_string_pretty(&hard_failure_json_output(e))
.map_err(|error| error.to_string())?,
)?;
return Err(error);
return Ok(failure_exit);
Comment thread
XuPeng-SH marked this conversation as resolved.
}
return Err(e.error);
return Ok(failure_exit);
}
};
if wall_deadline_reached || user_interrupted {
Expand Down Expand Up @@ -3790,10 +3794,21 @@ fn final_json_output(sr: &StreamResult, exit_code: ExitCode) -> serde_json::Valu
final_json_output_with_context(sr, exit_code, trace_id, request_id)
}

fn hard_failure_exit_code(failure: &crate::TurnFailure) -> ExitCode {
if failure.partial.server_admission_not_dispatched
&& failure.partial.failure_kind == Some(astra_core::ErrorKind::ToolUnavailable)
{
ExitCode::ToolFailure
} else {
ExitCode::ApiError
}
}

/// Project observed failure facts without committing a successful turn or
/// inferring a durable terminal from a transport error. Run identity comes
/// only from the physical stream's validated root binding.
fn hard_failure_json_output(failure: crate::TurnFailure) -> serde_json::Value {
let exit_code = hard_failure_exit_code(&failure);
let partial = failure.partial;
let classes = partial
.tool_outcomes
Expand Down Expand Up @@ -3826,7 +3841,13 @@ fn hard_failure_json_output(failure: crate::TurnFailure) -> serde_json::Value {
tool_record_coverage_partial: true,
..Default::default()
};
let mut output = final_json_output(&result, ExitCode::ApiError);
let mut output = final_json_output(&result, exit_code);
if partial.server_admission_not_dispatched {
output["server_admission_not_dispatched"] = serde_json::json!(true);
if let Some(kind) = partial.failure_kind {
output["error_kind"] = serde_json::json!(kind.as_str());
}
}
output["tool_result_class_counts"] = classes;
output["error"] = serde_json::json!(failure.error);
output["error_code"] = serde_json::json!(partial.error_code);
Expand Down Expand Up @@ -4083,8 +4104,12 @@ pub(crate) async fn run_print_mode(
return Ok(ExitCode::Success);
}
Err(e) => {
let failure_exit = hard_failure_exit_code(&e);
eprintln!(
"{}",
crate::cli::turn::turn_failure_reporting::turn_failure_diagnostic(&e)
);
if output_format == "json" || stream_json_emitter.is_some() {
let error = e.error.clone();
let mut output = hard_failure_json_output(e);
if let Some(emitter) = stream_json_emitter.as_ref() {
let result_session_id = output["session_id"].as_str().map(str::to_string);
Expand All @@ -4099,9 +4124,9 @@ pub(crate) async fn run_print_mode(
.map_err(|error| error.to_string())?,
)?;
}
return Err(error);
return Ok(failure_exit);
}
return Err(e.error);
return Ok(failure_exit);
}
};

Expand Down Expand Up @@ -4354,6 +4379,33 @@ mod exit_code_tests {
};
use crate::cli::stream::streaming_types::VerdictEvent;

#[test]
fn local_host_failure_json_and_process_exit_share_classification() {
let failure = crate::TurnFailure {
error: "Local workspace execution requires an administrator to repair /tmp".into(),
partial: crate::PartialTurnData {
failure_kind: Some(astra_core::ErrorKind::ToolUnavailable),
error_code: Some(astra_tools::TOOL_ERROR_KIND_WORKSPACE_HOST_UNSUPPORTED.into()),
error_metadata: Some(serde_json::json!({"retryable":false})),
server_admission_not_dispatched: true,
..Default::default()
},
};
assert_eq!(
super::hard_failure_exit_code(&failure),
ExitCode::ToolFailure
);
let output = super::hard_failure_json_output(failure);
assert_eq!(output["exit_code"], 1);
assert_eq!(output["error_kind"], "tool_unavailable");
assert_eq!(output["error_code"], "workspace_host_unsupported");
assert_eq!(output["server_admission_not_dispatched"], true);
assert_eq!(output["success"], false);
assert!(output["run_id"].is_null());
assert_eq!(output["prompt_tokens"], 0);
assert_eq!(output["tool_calls_count"], 0);
}

fn empty_stream_result() -> StreamResult {
StreamResult::default()
}
Expand Down
2 changes: 1 addition & 1 deletion crates/astra-cli/src/cli/exit_code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
pub(crate) enum ExitCode {
/// Success (0)
Success = 0,
/// Tool execution failure (1) - at least one tool call failed
/// Tool failure (1) - a tool call failed or a required local capability is unavailable
ToolFailure = 1,
/// Cancellation (2) - the task was explicitly cancelled
Cancelled = 2,
Expand Down
153 changes: 152 additions & 1 deletion crates/astra-cli/src/cli/turn/turn_entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,18 @@ async fn run_chat_turn(request: TurnExecutionRequest<'_>) -> TurnAttempt {
return TurnAttempt::NotStarted(Box::new(failure));
}
prepare_turn_adaptation(state, input.api, input.token, input.message).await;
let attempt = execute_stream_turn(TurnExecutionRequest { state, input }).await;
let not_started = match &attempt {
TurnAttempt::NotStarted(_) => true,
TurnAttempt::Interrupted(result) => result
.as_ref()
.as_ref()
.is_err_and(|failure| failure.partial.server_admission_not_dispatched),
TurnAttempt::Completed(_) => false,
};
if not_started {
return attempt;
}
if state
.resume_guidance
.as_ref()
Expand All @@ -259,7 +271,7 @@ async fn run_chat_turn(request: TurnExecutionRequest<'_>) -> TurnAttempt {
{
state.diagnostics_context = None;
}
execute_stream_turn(TurnExecutionRequest { state, input }).await
attempt
}

/// Establish the canonical identity transaction before an interactive turn
Expand Down Expand Up @@ -884,6 +896,145 @@ mod tests {
use crate::cli::session::session_state::SessionState;
use crate::cli::stream::streaming_types::UsageAttribution;

#[tokio::test]
#[serial_test::serial]
async fn unsupported_host_preserves_recovery_through_turn_execution_and_settlement() {
use crate::cli::chat_stream::WORKSPACE_HOST_VALIDATION_FOR_TEST;
use crate::cli::turn::turn_settlement::TurnDispatch;
use astra_tools::workspace_observation::WorkspaceLeaseFailure;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

let _home = crate::test_utils::HomeGuard::temp();
let _credentials = crate::test_utils::isolate_credentials();
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/models"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(crate::test_utils::mock_model_catalog_json(&["test-model"])),
)
.mount(&server)
.await;
let api = astra_thin_client::ThinClient::new(&server.uri(), None).unwrap();
let session_id = uuid::Uuid::new_v4().to_string();
let mut state = SessionState {
session_id: Some(session_id.clone()),
model: Some("test-model".into()),
turn: 7,
history: vec![("original task".into(), "unfinished".into())],
resume_guidance: Some("verify unfinished work".into()),
diagnostics_context: Some("existing diagnostic".into()),
resume_restricted_tools: vec!["bash".into()],
pending_bg_notifications: vec!["child completed".into()],
session_lessons_loaded: true,
perm_manager: crate::cli::permission_manager::PermissionManager::new(true),
tui_render_policy: Some(crate::cli::stream::stream_render::RenderPolicy::Silent),
unified_skill_registry: astra_runtime::skills::empty_unified_registry().clone(),
active_conversation: Some(
astra_turn_core::active_conversation::ActiveConversation::empty(
&crate::cli::cli_config::cli_utils::cli_user_id(),
&session_id,
)
.unwrap(),
),
..SessionState::default()
};
let required = vec![
"verify unfinished work".into(),
"existing diagnostic".into(),
];
let input = super::TurnExecutionInput {
api: &api,
profile: None,
token: "synthetic-token",
message: "continue",
user_intent: "continue",
input_runtime_required_texts: &required,
input_runtime_volatile_texts: &[],
session_id: &session_id,
admitted_server_turn: None,
semantic_query_override: None,
explain_analyze_terminal_degraded: None,
};
let attempt = WORKSPACE_HOST_VALIDATION_FOR_TEST
.scope(
Err(WorkspaceLeaseFailure::CoordinationHostUnsupported),
super::run_chat_turn(super::TurnExecutionRequest {
state: &mut state,
input,
}),
)
.await;
let super::TurnAttempt::NotStarted(failure) = &attempt else {
panic!("unsupported host must remain an unstarted attempt");
};
assert_eq!(
failure.partial.error_code.as_deref(),
Some("workspace_host_unsupported")
);
assert!(failure.partial.run_id.is_none());
let usage = std::sync::Arc::new(std::sync::Mutex::new(None));
let mut ui = crate::tests::TestUi::default();
let context = TurnContext {
api: &api,
profile: None,
post_commit_tx: None,
admission: None,
explain_analyze_terminal_degraded: None,
};
let mut dispatch = TurnDispatch {
ctx: &context,
line: "continue",
effective_line: "continue",
user_intent: "continue",
input_runtime_required_texts: &required,
input_runtime_volatile_texts: &[],
token: "synthetic-token",
session_id: &session_id,
admitted_server_turn: None,
semantic_query_override: None,
turn_start: std::time::Instant::now(),
ui: &mut ui,
turn_usage_sink: Some(&usage),
};
let settled = super::settle_turn_attempt(&mut state, &mut dispatch, attempt, |_| {
Box::pin(async { panic!("unsupported host must not retry") })
})
.await
.unwrap();
assert_eq!(settled, TurnSettlementOutcome::NotStarted);
assert_eq!(state.turn, 7);
assert_eq!(
state.history,
[("original task".into(), "unfinished".into())]
);
assert_eq!(
state.resume_guidance.as_deref(),
Some("verify unfinished work")
);
assert_eq!(
state.diagnostics_context.as_deref(),
Some("existing diagnostic")
);
assert_eq!(state.resume_restricted_tools, ["bash"]);
assert_eq!(state.pending_bg_notifications, ["child completed"]);
assert!(state.journal.is_none());
assert!(usage.lock().unwrap().is_none());
assert_eq!(ui.restored_inputs, ["continue"]);
assert_eq!(ui.errors.len(), 1);
assert!(ui.errors[0].contains("workspace_host_unsupported"));
assert!(ui.errors[0].contains("administrator"));
let requests = server.received_requests().await.unwrap();
assert!(!requests.is_empty());
assert!(
requests
.iter()
.all(|request| request.method.as_str() == "GET" && request.url.path() == "/models"),
"no execution or refresh request is allowed"
);
}

#[tokio::test]
async fn conversation_input_cannot_execute_local_shell_syntax() {
let directory = tempfile::tempdir().unwrap();
Expand Down
Loading
Loading