fix: preserve tool classification and accurate Workdir failure stages

This commit is contained in:
2026-10-09 22:55:53 +09:00
parent f9d70e2ae2
commit 10c55874e3
5 changed files with 259 additions and 17 deletions
+91 -10
View File
@@ -38,17 +38,28 @@ pub enum ToolsError {
impl From<ToolsError> for ToolError {
fn from(err: ToolsError) -> Self {
match &err {
ToolsError::WorkdirSession(
workdir::WorkdirError::NotFound(_)
| workdir::WorkdirError::Io { .. }
| workdir::WorkdirError::Unavailable(_)
| workdir::WorkdirError::OperationFailed
| workdir::WorkdirError::Transport(_)
| workdir::WorkdirError::Conflict(_)
| workdir::WorkdirError::OutcomeUnknown(_),
) => ToolError::ExecutionFailed(err.to_string()),
ToolsError::WorkdirSession(error) => {
// Diagnostic context is transparent to the established normal-tool
// classification. Preserve the original display, including unknown
// outcome guidance, while classifying through nested wrappers.
let mut classification = error;
while let workdir::WorkdirError::DenialContext { source, .. } = classification {
classification = source.as_ref();
}
match classification {
workdir::WorkdirError::NotFound(_)
| workdir::WorkdirError::Io { .. }
| workdir::WorkdirError::Unavailable(_)
| workdir::WorkdirError::OperationFailed
| workdir::WorkdirError::Transport(_)
| workdir::WorkdirError::Conflict(_)
| workdir::WorkdirError::OutcomeUnknown(_) => {
ToolError::ExecutionFailed(err.to_string())
}
_ => ToolError::InvalidArgument(err.to_string()),
}
}
ToolsError::FileSystem(_)
| ToolsError::WorkdirSession(_)
| ToolsError::NotRead(_)
| ToolsError::ExternallyModified(_)
| ToolsError::StringNotFound { .. }
@@ -134,6 +145,76 @@ mod tests {
}
}
#[test]
fn transport_diagnostic_reasons_preserve_normal_tool_classification_and_display() {
use WorkdirTransportErrorCode as Code;
use workdir::{WorkdirDenialReason as Reason, WorkdirError};
for (code, execution_failure) in [
(Code::NotFound, true),
(Code::Conflict, true),
(Code::OutcomeUnknown, true),
(Code::Unsupported, false),
(Code::InvalidRequest, false),
(Code::Denied, false),
(Code::OutOfScope, false),
(Code::SymlinkOutOfScope, false),
(Code::BrokenSymlink, false),
(Code::SymlinkTargetIsDirectory, false),
(Code::ReadOnly, false),
(Code::IsDirectory, false),
(Code::SymlinkDirectoryNotTraversed, false),
(Code::UnknownCommand, false),
(Code::Unavailable, true),
(Code::Io, true),
(Code::Transport, true),
(Code::Internal, true),
] {
// Wire data may carry a reason independently of its public code.
// Neither enrichment nor nested wrappers may change normal-tool behavior.
let baseline_message = serde_json::from_value::<WorkdirTransportError>(
serde_json::json!({"code": code, "message": "safe fixture message"}),
)
.unwrap()
.into_workdir_error()
.to_string();
for depth in [0, 1, 2] {
let wire = serde_json::json!({
"code": code,
"message": "safe fixture message",
"denial_reason": (depth > 0).then_some(Reason::OsPermissionDenied),
});
let mut error = serde_json::from_value::<WorkdirTransportError>(wire)
.unwrap()
.into_workdir_error();
if depth == 2 {
error = WorkdirError::DenialContext {
reason: Reason::ReadOnlySession,
source: Box::new(error),
};
}
let expected_message = error.to_string();
assert_eq!(
expected_message, baseline_message,
"{code:?}, depth {depth}"
);
let tool_error = ToolError::from(ToolsError::from(error));
let (actual_execution_failure, actual_message) = match tool_error {
ToolError::ExecutionFailed(message) => (true, message),
ToolError::InvalidArgument(message) => (false, message),
other => panic!("unexpected classification for {code:?}: {other:?}"),
};
assert_eq!(
actual_execution_failure, execution_failure,
"{code:?}, depth {depth}"
);
assert_eq!(actual_message, expected_message, "{code:?}, depth {depth}");
if code == Code::OutcomeUnknown {
assert!(actual_message.contains("inspect before retrying"));
}
}
}
}
#[test]
fn remote_workdir_content_conflict_is_retryable_without_host_path() {
let transport = WorkdirTransportError::from_workdir_error(
+4 -1
View File
@@ -18354,6 +18354,7 @@ async fn scoped_execute_current_worker_workdir_operation(
.start_command(command)
.await
.map_err(workdir_operation_transport_error)?;
context.stage = "command_registration";
let registered_source = api
.workdir_sessions
.lock()
@@ -32702,7 +32703,9 @@ impl ApiErrorLog {
// Existing public API details are unchanged; do not copy request-derived
// alias/handle text or session-open errors into operational diagnostics.
log.kind = format!("workdir_session_operation_api_{}", status.as_u16());
log.message = "Workdir operation was rejected before provider dispatch".to_string();
// This can also fail after a command was dispatched. Do not claim
// a pre-dispatch refusal or absence of effects in the safe message.
log.message = "Workdir operation failed".to_string();
log.diagnostics.clear();
}
log
@@ -454,10 +454,7 @@ async fn generated_workdir_api_errors_preserve_response_and_log_details() {
assert_eq!(body, serde_json::to_value(expected).unwrap());
assert_eq!(events.len(), 1, "{events:?}");
assert_eq!(events[0]["kind"], "workdir_session_operation_api_403");
assert_eq!(
events[0]["message"],
"Workdir operation was rejected before provider dispatch"
);
assert_eq!(events[0]["message"], "Workdir operation failed");
assert_eq!(events[0]["operation"], "command_status");
assert_eq!(events[0]["workdir_stage"], "worker_identity");
assert_eq!(events[0]["runtime_id_hash"], Value::Null);
@@ -680,3 +677,103 @@ async fn self_workdir_session_open_refusals_keep_reason_before_registry_conversi
assert!(!body.to_string().contains("/host/private"));
}
}
#[tokio::test]
async fn self_workdir_post_start_registration_failure_does_not_claim_pre_dispatch_refusal() {
let mut fixture = manual_worker_assignment_fixture().await;
let identity =
worker_runtime::auth::RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id)
.unwrap();
configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id);
let (sender, mut commands) = tokio::sync::mpsc::channel(1);
let connection = Arc::new(ExternalProviderConnection {
grant_id: "logging-grant".to_string(),
workdir_id: fixture.main_workdir_id.clone(),
provider_instance_id: "logging-provider".to_string(),
generation: 1,
expires_at: None,
capabilities: workdir::WorkdirSessionCapabilities::ALL,
admission: Arc::new(tokio::sync::Semaphore::new(1)),
shutdown_confirmed: Arc::new(std::sync::atomic::AtomicBool::new(false)),
sender,
});
fixture.runtime.workdir_session_factory = Some(Arc::new(move |_| {
Ok(Arc::new(ExternalProviderWorkdirSession::new(
connection.clone(),
None,
)) as WorkdirSessionHandle)
}));
fixture
.api
.runtime
.register_or_replace(fixture.runtime.clone());
let path = format!("/api/w/{TEST_WORKSPACE_ID}/workers/self/workdir-session/operations");
let request = runtime_source_request(
&identity,
Some(&fixture.worker.worker_id),
"POST",
&path,
serde_json::to_vec(&json!({
"target_workdir": "checkout",
"operation": {"operation": "command_start", "request": {
"command": "body-secret", "timeout_secs": 1, "output_limit": 1024,
"cwd": "", "spill_dir": null, "tool_call_id": "body-secret"
}}
}))
.unwrap(),
);
// Observe command dispatch, then lose only the fixture registry entry before
// completing start. No OS process is launched, no sleep or retry is needed.
let provider = async {
let Some(ExternalProviderCommand::Operation {
operation,
response,
..
}) = commands.recv().await
else {
panic!("expected provider start operation");
};
assert!(matches!(
operation,
WorkdirSessionOperation::CommandStart(_)
));
fixture
.api
.workdir_sessions
.lock()
.unwrap()
.remove_attachment(&fixture.worker)
.unwrap();
response
.send(Ok(WorkdirSessionOperationResult::CommandStart(
CommandHandle("fixture-provider-handle".into()),
)))
.unwrap();
};
let ((status, body, events), ()) = tokio::join!(
request_with_logs(build_router(fixture.api.clone()), request),
provider
);
assert_eq!(status, StatusCode::BAD_GATEWAY, "{body}");
assert!(
body["message"]
.as_str()
.unwrap()
.starts_with("workdir_session_registration_failed:")
);
assert_eq!(events.len(), 1, "{events:?}");
assert_eq!(events[0]["operation"], "command_start");
assert_eq!(events[0]["workdir_stage"], "command_registration");
assert_eq!(events[0]["message"], "Workdir operation failed");
assert_eq!(events[0]["denial_reason"], Value::Null);
assert_workdir_log_identity(
&events[0],
&fixture,
"checkout",
Some(&fixture.main_workdir_id),
);
assert!(
commands.try_recv().is_err(),
"post-dispatch failure was retried"
);
}