diff --git a/crates/webcodex-runner/src/webcodex_runner/job_manager/local_shell.rs b/crates/webcodex-runner/src/webcodex_runner/job_manager/local_shell.rs index 60c412d56..373beb1b4 100644 --- a/crates/webcodex-runner/src/webcodex_runner/job_manager/local_shell.rs +++ b/crates/webcodex-runner/src/webcodex_runner/job_manager/local_shell.rs @@ -228,7 +228,18 @@ impl JobManager { let mut command = match configured { Ok(command) => command, Err(error) => { - self.fail_job(&operation, error, None); + // The command has not started. Preserve the canonical empty + // validation progress so the Server can accept the Runner's + // tool-unavailable evidence rather than reject its protocol. + self.fail_job( + &operation, + error, + validation.then_some(ShellJobValidationProgress { + completed: 0, + current_step: None, + failed_step: None, + }), + ); return; } }; diff --git a/crates/webcodex-runner/src/webcodex_runner/job_manager_tests/project_validation.rs b/crates/webcodex-runner/src/webcodex_runner/job_manager_tests/project_validation.rs index c5f1940c0..12fede174 100644 --- a/crates/webcodex-runner/src/webcodex_runner/job_manager_tests/project_validation.rs +++ b/crates/webcodex-runner/src/webcodex_runner/job_manager_tests/project_validation.rs @@ -867,6 +867,120 @@ fn project_validation_rechecks_provenance_after_shell_profile_preparation() { } } +#[test] +fn project_validation_missing_clippy_keeps_prestart_progress() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project"); + let registry = temp.path().join("registry"); + let bin = temp.path().join("bin"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(®istry).unwrap(); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write( + root.join("Cargo.toml"), + "[package]\nname='missing_clippy_fixture'\nversion='0.1.0'\nedition='2021'\n[workspace]\n", + ) + .unwrap(); + std::fs::write( + registry.join("demo.toml"), + format!( + "id='demo'\nname='Demo'\npath={}\nallow_patch=true\n", + serde_json::to_string(root.to_str().unwrap()).unwrap() + ), + ) + .unwrap(); + let policy = RunnerPolicy { + allowed_roots: vec![root.clone()], + ..Default::default() + }; + let request = ProjectValidationRequest { + project_id: "demo".into(), + cwd: None, + action: ProjectValidationAction::Lint, + adapter: ProjectValidationAdapter::Rust, + scope: None, + dependency_policy: None, + dependency_resolution_policy: None, + test: None, + }; + let (plan, cwd) = + crate::webcodex_runner::validation::project::plan(&policy, ®istry, &request).unwrap(); + let metadata = ShellJobValidationMetadata { + project_validation: Some(plan.provenance), + source_fence: None, + tool: "project_validate".into(), + kind: "lint".into(), + steps: vec![plan.step.clone()], + effective_timeout_secs: 30, + sync_wait_secs: 8, + adapter: plan.adapter, + validation_target_id: Some(plan.validation_target_id), + minimum_tests: None, + require_tests: None, + no_run: None, + }; + let context = ShellJobContext { + runtime_project_id: Some("agent:validation-agent:demo".into()), + workflow_session_id: None, + ssh_resource: None, + project_cwd: Some(".".into()), + cwd: Some(cwd.to_string_lossy().into_owned()), + purpose: Some("validation".into()), + shell: Some("configured".into()), + command_preview: "validation: lint".into(), + validation_steps: vec![plan.step.name.clone()], + validation: Some(metadata), + structured_execution: None, + }; + let operation = RunnerJobOperation::StartValidation(RunnerJobValidationOperation { + job_id: "clippy-missing-job".into(), + cwd: Some(cwd.to_string_lossy().into_owned()), + steps: vec![plan.step], + timeout_secs: 30, + context, + }); + let wire = RunnerRequest::from_operation( + RunnerInvocationMetadata { + request_id: "clippy-missing-request".into(), + client_id: "validation-agent".into(), + requested_by: "test".into(), + created_at: chrono::Utc::now().timestamp(), + }, + RunnerOperation::Job(operation), + ) + .unwrap(); + let mut shell = ShellConfig::default(); + shell + .env + .insert("PATH".into(), bin.to_string_lossy().into_owned()); + let manager = JobManager::new(1); + let (sink, _rx) = structured_test_sink("validation-agent", "validation-instance"); + manager.enqueue( + sink, + PendingJobStart::from_wire(1, policy, shell, SshConfig::default(), registry, wire), + ); + assert!(manager.wait_for_workers(Instant::now() + Duration::from_secs(12))); + let snapshot = lock_unpoison(&manager.jobs)["clippy-missing-job"] + .snapshot + .clone(); + manager.stop_all(); + assert_eq!( + snapshot.command_execution_state, + Some(ShellCommandExecutionState::NotStarted) + ); + assert_eq!(snapshot.exit_code, None); + assert_eq!( + snapshot.error.as_deref(), + Some(VALIDATION_TOOL_UNAVAILABLE_CODE) + ); + let progress = snapshot + .validation_progress + .expect("prestart tool failure must retain canonical validation progress"); + assert_eq!(progress.completed, 0); + assert!(progress.current_step.is_none()); + assert!(progress.failed_step.is_none()); +} + #[cfg(feature = "runner-real-process-tests")] #[test] #[ignore = "requires installed Cargo Clippy; run explicitly with --ignored"] diff --git a/crates/webcodex-tool-contracts/src/registry/output_schemas/testing.rs b/crates/webcodex-tool-contracts/src/registry/output_schemas/testing.rs index 37d964bca..87b2c54bf 100644 --- a/crates/webcodex-tool-contracts/src/registry/output_schemas/testing.rs +++ b/crates/webcodex-tool-contracts/src/registry/output_schemas/testing.rs @@ -243,6 +243,7 @@ fn cargo_output_schema(tool_name: &str) -> Value { "cwd_invalid", "sandbox_unavailable", "executor_unavailable", + "validation_tool_unavailable", "validation_plan_stale", "validation_unavailable" ]) diff --git a/crates/webcodex-tool-contracts/src/tests/output_schemas.rs b/crates/webcodex-tool-contracts/src/tests/output_schemas.rs index 863866307..ea0d16762 100644 --- a/crates/webcodex-tool-contracts/src/tests/output_schemas.rs +++ b/crates/webcodex-tool-contracts/src/tests/output_schemas.rs @@ -3720,7 +3720,11 @@ fn project_validate_schema_admits_canonical_planning_and_replan_failures() { }); } - for failure_kind in ["validation_plan_stale", "validation_unavailable"] { + for failure_kind in [ + "validation_plan_stale", + "validation_tool_unavailable", + "validation_unavailable", + ] { let result = json!({ "success": false, "output": { diff --git a/src/tool_runtime/tests/validation_handoff/project_validation.rs b/src/tool_runtime/tests/validation_handoff/project_validation.rs index 041b9325d..e2f420b7f 100644 --- a/src/tool_runtime/tests/validation_handoff/project_validation.rs +++ b/src/tool_runtime/tests/validation_handoff/project_validation.rs @@ -1209,6 +1209,61 @@ async fn node_json_project_validation_handoff_owns_counts_and_keeps_freshness_un } } +/// The Runner's missing-Clippy prestart failure must survive the validated +/// Registry Job update and the model-facing project_validate MCP result. +#[tokio::test] +async fn project_validation_prestart_clippy_preserves_runner_failure_kind() { + let runtime = setup(1_000).await; + let task = tokio::spawn({ + let runtime = runtime.clone(); + async move { + let auth = auth_context(None, true); + let (result, _, _) = runtime + .dispatch_canonical( + call(ProjectValidationAction::Lint, None), + DispatchContext::internal(Some(&auth), SessionTransport::Mcp, None), + ) + .await; + result + } + }); + let (request, job_id) = reply_plan(&runtime, "rust", ProjectValidationAction::Lint).await; + let mut update = cargo_test_update( + "project-validation", + &request.request_id, + &job_id, + "failed", + "", + "", + None, + ShellJobValidationProgress { + completed: 0, + current_step: None, + failed_step: None, + }, + true, + ); + update.error = Some(VALIDATION_TOOL_UNAVAILABLE_CODE.to_string()); + update.command_execution_state = Some(ShellCommandExecutionState::NotStarted); + runtime.runner_registry.update_job(update).await.unwrap(); + let result = task.await.unwrap(); + assert!(!result.success, "{result:?}"); + assert_eq!(result.output["execution_state"], "not_started"); + assert_eq!(result.output["command_started"], false); + assert_eq!(result.output["command_completed"], false); + assert_eq!( + result.output["failure_kind"], + VALIDATION_TOOL_UNAVAILABLE_CODE + ); + assert_eq!(result.output["terminal"], true); + assert!(result + .error + .as_deref() + .unwrap_or_default() + .contains(VALIDATION_TOOL_UNAVAILABLE_CODE)); + assert_model_cargo_result_matches_schema("project_validate", &result); +} + #[tokio::test] async fn rust_clippy_pending_observes_same_job_and_exit_status_controls_outcome() { for offline in [false, true] { diff --git a/src/tool_runtime/validation.rs b/src/tool_runtime/validation.rs index fb794f75a..5e79d43a2 100644 --- a/src/tool_runtime/validation.rs +++ b/src/tool_runtime/validation.rs @@ -1129,16 +1129,53 @@ impl ToolRuntime { .as_ref() .and_then(|job| job.error.as_deref()) .is_some_and(|error| error.contains("validation_plan_stale")); + let tool_unavailable = !stale + && job + .as_ref() + .and_then(|job| job.error.as_deref()) + .and_then(validation_infrastructure_failure_code) + == Some(VALIDATION_TOOL_UNAVAILABLE_CODE); + let failure_kind = if stale { + "validation_plan_stale" + } else if tool_unavailable { + VALIDATION_TOOL_UNAVAILABLE_CODE + } else { + "validation_unavailable" + }; self.runner_registry .remove_projected_hidden_terminal_job_record(&job_id) .await; return ToolResult::err_with_output( if stale { "Project validation plan changed before execution; resolve project_validate again. No command started." + } else if tool_unavailable { + "Runner reported validation_tool_unavailable before project validation started; check the configured validation tool and retry." } else { "Runner rejected validation before execution; inspect project availability and Runner policy, then retry." }, - json!({"execution_source": "project_validate", "execution_state": "not_started", "command_started": false, "command_completed": false, "failure_kind": if stale { "validation_plan_stale" } else { "validation_unavailable" }, "terminal": true}), + json!({ + "project": handoff.project, + "command_summary": handoff.command_summary, + "cwd": handoff.cwd, + "execution_source": "project_validate", + "execution_state": "not_started", + "exit_code": exit_code, + "duration_ms": job.as_ref().and_then(|job| job.duration_ms), + "stdout_tail": stdout_tail, + "stderr_tail": stderr_tail, + "stdout_lines": stdout.lines().count(), + "stderr_lines": stderr.lines().count(), + "stdout_truncated": stdout_truncated, + "stderr_truncated": stderr_truncated, + "promoted_to_job": false, + "terminal": true, + "command_started": false, + "command_completed": false, + "passed": false, + "effective_timeout_secs": handoff.effective_timeout_secs, + "sync_wait_secs": handoff.sync_wait_secs, + "failure_kind": failure_kind, + }), ); } let lifecycle = RunnerJobLifecycle::from_wire(job_status).ok();