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
58 changes: 48 additions & 10 deletions apps/decodex/src/agent/app_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>();

if blockers.is_empty() { String::from("no blockers recorded") } else { blockers.join("; ") }
Expand Down Expand Up @@ -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<String, String>,
) -> 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();
Expand Down Expand Up @@ -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}"
)
},
}
}

Expand Down Expand Up @@ -1031,6 +1050,24 @@ pub(crate) fn probe_app_server(listen: &str) -> crate::prelude::Result<AppServer
Ok(result)
}

fn preflight_check_blocker_summary(check: &AppServerCapabilityPreflightCheck) -> 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<()> {
Expand Down Expand Up @@ -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());
Expand All @@ -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,
);
}
}

Expand Down
38 changes: 37 additions & 1 deletion apps/decodex/src/agent/app_server/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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."
);
}

Expand Down
31 changes: 31 additions & 0 deletions apps/decodex/src/orchestrator/tests/runtime/failure.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::collections::BTreeMap;

use orchestrator::{
AgentGitCredentialEnvironment, AgentGitCredentialsUnavailable, RepoGateFailureKind,
};
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions docs/spec/app-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down