diff --git a/apps/decodex/src/agent/app_server.rs b/apps/decodex/src/agent/app_server.rs index fa420868..4fd1fb12 100644 --- a/apps/decodex/src/agent/app_server.rs +++ b/apps/decodex/src/agent/app_server.rs @@ -163,7 +163,7 @@ impl AppServerCapabilityPreflightReport { .checks .iter() .filter(|check| check.status == AppServerCapabilityPreflightStatus::Blocked) - .map(|check| format!("{}: {}", check.name, check.summary)) + .map(preflight_check_blocker_summary) .collect::>(); if blockers.is_empty() { String::from("no blockers recorded") } else { blockers.join("; ") } @@ -219,6 +219,19 @@ impl AppServerCapabilityPreflightFailure { Self::blocked(report) } + #[cfg(test)] + pub(crate) fn blocked_for_test_with_details( + check: &'static str, + summary: &str, + details: BTreeMap, + ) -> Self { + let mut report = AppServerCapabilityPreflightReport::new(); + + report.push_blocked(check, summary, details); + + Self::blocked(report) + } + #[cfg(test)] pub(crate) fn method_timed_out_for_test(method: &'static str, error: String) -> Self { let mut report = AppServerCapabilityPreflightReport::new(); @@ -264,10 +277,16 @@ impl AppServerCapabilityPreflightFailure { } => format!( "inspect local app_server_preflight_failed evidence for the `{method}` timeout, restart `decodex serve` if the app-server is stale, run `decodex probe` to confirm app-server preflight recovers, {recovery_gate}" ), - AppServerCapabilityPreflightFailureKind::MethodFailed { .. } - | AppServerCapabilityPreflightFailureKind::BlockedState => format!( + AppServerCapabilityPreflightFailureKind::MethodFailed { .. } => format!( "inspect the Codex app-server preflight status, repair the local Codex runtime configuration, restart `decodex serve`, {recovery_gate}" ), + AppServerCapabilityPreflightFailureKind::BlockedState => { + let blocker_summary = self.blocker_summary(); + + format!( + "inspect local app_server_preflight_failed evidence for `{blocker_summary}`, repair the local Codex runtime configuration, restart `decodex serve`, {recovery_gate}" + ) + }, } } @@ -1031,6 +1050,24 @@ pub(crate) fn probe_app_server(listen: &str) -> crate::prelude::Result String { + let first_error_path = check.details.get("first_error_path"); + let first_error = check.details.get("first_error"); + let mut summary = format!("{}: {}", check.name, check.summary); + + if first_error_path.is_some() || first_error.is_some() { + let path = first_error_path.map_or("unknown", String::as_str); + let error = first_error.map_or("unknown", String::as_str); + + summary.push_str(" first_error_path="); + summary.push_str(path); + summary.push_str("; first_error="); + summary.push_str(error); + } + + summary +} + fn archive_app_server_thread_after_success_inner( request: &AppServerThreadArchiveRequest<'_>, ) -> crate::prelude::Result<()> { @@ -2453,6 +2490,7 @@ fn record_skills_preflight( details.insert(String::from("entry_count"), skills.data.len().to_string()); details.insert(String::from("skill_count"), all_skill_count.to_string()); details.insert(String::from("enabled_skill_count"), enabled_skill_count.to_string()); + details.insert(String::from("error_count"), errors.len().to_string()); if let Some(first_error) = errors.first() { details.insert(String::from("first_error_path"), first_error.path.clone()); @@ -2465,20 +2503,20 @@ fn record_skills_preflight( "skills/list did not return an entry for the run cwd.", details, ); - } else if !errors.is_empty() { - report.push_blocked( - PREFLIGHT_CHECK_SKILLS, - "skills/list returned skill scan errors.", - details, - ); } else if enabled_skill_count == 0 { report.push_blocked( PREFLIGHT_CHECK_SKILLS, "skills/list returned no enabled skills.", details, ); - } else { + } else if errors.is_empty() { report.push_ok(PREFLIGHT_CHECK_SKILLS, "skills/list returned enabled skills.", details); + } else { + report.push_ok( + PREFLIGHT_CHECK_SKILLS, + "skills/list returned enabled skills with scan diagnostics.", + details, + ); } } diff --git a/apps/decodex/src/agent/app_server/tests.rs b/apps/decodex/src/agent/app_server/tests.rs index a27eda7e..defbab7b 100644 --- a/apps/decodex/src/agent/app_server/tests.rs +++ b/apps/decodex/src/agent/app_server/tests.rs @@ -754,6 +754,42 @@ fn capability_preflight_report_accepts_available_runtime_state() { assert_eq!(serialized["checks"][1]["details"]["configured_model"], "gpt-5.4"); } +#[test] +fn capability_preflight_report_allows_enabled_skills_with_scan_diagnostics() { + let skills = SkillsListResponse { + data: vec![super::protocol::SkillsListEntry { + cwd: String::from("/tmp/worktree"), + errors: vec![super::protocol::SkillErrorInfo { + message: String::from("name: exceeds maximum length of 64 characters"), + path: String::from( + "/tmp/plugins/build-web-data-visualization/skills/chart/SKILL.md", + ), + }], + skills: vec![super::protocol::SkillMetadata { + enabled: true, + name: String::from("playbook:rust"), + scope: String::from("user"), + }], + }], + }; + let mut report = AppServerCapabilityPreflightReport::new(); + + super::record_skills_preflight(&mut report, "/tmp/worktree", &skills); + + assert!(!report.has_blockers()); + assert_eq!(report.checks()[0].status, super::AppServerCapabilityPreflightStatus::Ok); + assert_eq!( + report.checks()[0].summary, + "skills/list returned enabled skills with scan diagnostics." + ); + assert_eq!(report.checks()[0].details["enabled_skill_count"], "1"); + assert_eq!(report.checks()[0].details["error_count"], "1"); + assert_eq!( + report.checks()[0].details["first_error"], + "name: exceeds maximum length of 64 characters" + ); +} + #[test] fn capability_preflight_report_blocks_missing_runtime_state() { let config = RuntimeConfigSummary { @@ -801,7 +837,7 @@ fn capability_preflight_report_blocks_missing_runtime_state() { assert!(report.has_blockers()); assert_eq!( report.blocker_summary(), - "model: configured model was not present in model/list.; skills: skills/list returned skill scan errors.; plugins: plugin/list returned marketplace load errors.; mcp: mcpServerStatus/list returned MCP servers that are not logged in." + "model: configured model was not present in model/list.; skills: skills/list returned no enabled skills. first_error_path=/tmp/worktree/.codex/skills/bad/SKILL.md; first_error=bad skill metadata; plugins: plugin/list returned marketplace load errors. first_error_path=/tmp/plugins.json; first_error=invalid marketplace; mcp: mcpServerStatus/list returned MCP servers that are not logged in." ); } diff --git a/apps/decodex/src/orchestrator/tests/runtime/failure.rs b/apps/decodex/src/orchestrator/tests/runtime/failure.rs index bb020066..d6c47519 100644 --- a/apps/decodex/src/orchestrator/tests/runtime/failure.rs +++ b/apps/decodex/src/orchestrator/tests/runtime/failure.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; + use orchestrator::{ AgentGitCredentialEnvironment, AgentGitCredentialsUnavailable, RepoGateFailureKind, }; @@ -518,6 +520,35 @@ fn app_server_terminal_failures_preserve_specific_error_classes() { } } +#[test] +fn app_server_preflight_terminal_action_surfaces_first_scan_error() { + let mut details = BTreeMap::new(); + + details.insert( + String::from("first_error_path"), + String::from("/tmp/plugins/build-web-data-visualization/skills/chart/SKILL.md"), + ); + details.insert( + String::from("first_error"), + String::from("name: exceeds maximum length of 64 characters"), + ); + + let error = Report::new(AppServerCapabilityPreflightFailure::blocked_for_test_with_details( + "skills", + "skills/list returned no enabled skills.", + details, + )); + let (error_class, next_action) = orchestrator::terminal_failure_comment_details( + false, + &error, + "clear label `decodex:needs-attention`, then move the issue back to a startable state if another automated run is desired", + ); + + assert_eq!(error_class, "app_server_runtime_preflight_failed"); + assert!(next_action.contains("first_error_path=/tmp/plugins/build-web-data-visualization")); + assert!(next_action.contains("first_error=name: exceeds maximum length of 64 characters")); +} + #[test] fn repo_gate_runtime_failures_require_manual_attention_without_retry_budget_wait() { let error = Report::new(orchestrator::RepoGateFailure::new( diff --git a/docs/spec/app-server.md b/docs/spec/app-server.md index d4577df2..934420d0 100644 --- a/docs/spec/app-server.md +++ b/docs/spec/app-server.md @@ -106,6 +106,10 @@ model, personality, sandbox, or approval-policy overrides on behalf of `WORKFLOW.md`. `plugin/list` preflight must pass `marketplaceKinds = ["local"]` so remote catalog, featured-plugin, or marketplace-discovery failures do not gate a business lane before its thread is created. +`skills/list` scan errors are diagnostics when the response still includes the run +cwd and at least one enabled skill. Decodex must preserve the scan error count and +first error details in local preflight evidence, but it must not block the lane solely +because unrelated installed skill metadata failed to scan. Because `plugin/list` is observational and local-marketplace-only, Decodex may retry one app-server output timeout before failing the lane. If the retry is exhausted, the terminal failure must remain an app-server preflight failure, report