diff --git a/apps/decodex/src/agent/app_server.rs b/apps/decodex/src/agent/app_server.rs index 2a249a6c..f2eabed6 100644 --- a/apps/decodex/src/agent/app_server.rs +++ b/apps/decodex/src/agent/app_server.rs @@ -57,6 +57,7 @@ pub(crate) const ACTIVE_RUN_IDLE_TIMEOUT: Duration = Duration::from_secs(300); const PROBE_TIMEOUT: Duration = Duration::from_secs(30); const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); +const MCP_PREFLIGHT_REQUEST_TIMEOUT: Duration = Duration::from_secs(15); const PROBE_RUN_ID: &str = "protocol-probe-run"; const PROBE_ISSUE_ID: &str = "protocol-probe"; const PROBE_EXPECTED_OUTPUT: &str = "PROBE_OK"; @@ -67,7 +68,7 @@ const PROBE_DEVELOPER_INSTRUCTIONS: &str = "You are a protocol probe. You must c const PROBE_USER_INPUT: &str = "Call `echo_probe` with `{\\\"text\\\":\\\"PROBE_OK\\\"}`. After the tool succeeds, reply with the exact text PROBE_OK."; const PREFLIGHT_EVENT_TYPE: &str = "app-server/preflight"; const PREFLIGHT_MODEL_PAGE_LIMIT: u32 = 200; -const PREFLIGHT_MCP_PAGE_LIMIT: u32 = 200; +const PREFLIGHT_MCP_PAGE_LIMIT: u32 = 50; const PREFLIGHT_MCP_DETAIL: &str = "toolsAndAuthOnly"; const PREFLIGHT_CHECK_CONFIG: &str = "config"; const PREFLIGHT_CHECK_MODEL: &str = "model"; @@ -1892,9 +1893,16 @@ fn run_app_server_capability_preflight( record_plugin_preflight(&mut report, &plugins); - let mcp_servers = list_all_mcp_servers_for_preflight(client, recorder, &report)?; + match list_all_mcp_servers_for_preflight(client) { + Ok(mcp_servers) => record_mcp_preflight(&mut report, &mcp_servers), + Err(error) if mcp_preflight_can_degrade(&error) => { + record_mcp_preflight_degraded(&mut report, &error); + }, + Err(error) => { + return preflight_method_failure(recorder, &report, "mcpServerStatus/list", error); + }, + } - record_mcp_preflight(&mut report, &mcp_servers); record_app_server_preflight_report(recorder, &report)?; if report.has_blockers() { @@ -1904,6 +1912,33 @@ fn run_app_server_capability_preflight( Ok(report) } +fn preflight_method_failure( + recorder: &mut RunRecorder<'_>, + report: &AppServerCapabilityPreflightReport, + method: &'static str, + error: Report, +) -> crate::prelude::Result { + let error_message = error.to_string(); + let mut failed_report = report.clone(); + let mut details = BTreeMap::new(); + + details.insert(String::from("method"), method.to_owned()); + details.insert(String::from("error"), error_message.clone()); + failed_report.push_blocked( + check_name_for_method(method), + format!("`{method}` failed before thread/start."), + details, + ); + + record_app_server_preflight_report(recorder, &failed_report)?; + + Err(Report::new(AppServerCapabilityPreflightFailure::method_failed( + method, + error_message, + failed_report, + ))) +} + fn preflight_request( recorder: &mut RunRecorder<'_>, report: &AppServerCapabilityPreflightReport, @@ -1915,26 +1950,7 @@ where { match request() { Ok(response) => Ok(response), - Err(error) => { - let mut failed_report = report.clone(); - let mut details = BTreeMap::new(); - - details.insert(String::from("method"), method.to_owned()); - details.insert(String::from("error"), error.to_string()); - failed_report.push_blocked( - check_name_for_method(method), - format!("`{method}` failed before thread/start."), - details, - ); - - record_app_server_preflight_report(recorder, &failed_report)?; - - Err(Report::new(AppServerCapabilityPreflightFailure::method_failed( - method, - error.to_string(), - failed_report, - ))) - }, + Err(error) => preflight_method_failure(recorder, report, method, error), } } @@ -1968,21 +1984,19 @@ fn list_all_models_for_preflight( fn list_all_mcp_servers_for_preflight( client: &mut AppServerClient, - recorder: &mut RunRecorder<'_>, - report: &AppServerCapabilityPreflightReport, ) -> crate::prelude::Result> { let mut cursor = None; let mut servers = Vec::new(); loop { - let response: ListMcpServerStatusResponse = - preflight_request(recorder, report, "mcpServerStatus/list", || { - client.list_mcp_server_status(&ListMcpServerStatusParams { - cursor: cursor.clone(), - detail: Some(PREFLIGHT_MCP_DETAIL.to_owned()), - limit: Some(PREFLIGHT_MCP_PAGE_LIMIT), - }) - })?; + let response: ListMcpServerStatusResponse = client.list_mcp_server_status( + &ListMcpServerStatusParams { + cursor: cursor.clone(), + detail: Some(PREFLIGHT_MCP_DETAIL.to_owned()), + limit: Some(PREFLIGHT_MCP_PAGE_LIMIT), + }, + MCP_PREFLIGHT_REQUEST_TIMEOUT, + )?; servers.extend(response.data); @@ -2212,6 +2226,27 @@ fn record_mcp_preflight( } } +fn mcp_preflight_can_degrade(error: &Report) -> bool { + error.downcast_ref::().is_some() +} + +fn record_mcp_preflight_degraded(report: &mut AppServerCapabilityPreflightReport, error: &Report) { + let mut details = BTreeMap::new(); + + details.insert(String::from("method"), String::from("mcpServerStatus/list")); + details.insert(String::from("degraded_reason"), String::from("timeout")); + details.insert(String::from("error"), error.to_string()); + details.insert( + String::from("timeout_seconds"), + MCP_PREFLIGHT_REQUEST_TIMEOUT.as_secs().to_string(), + ); + report.push_ok( + PREFLIGHT_CHECK_MCP, + "mcpServerStatus/list timed out during optional MCP inventory; continuing after core app-server capability checks passed.", + details, + ); +} + fn record_app_server_preflight_report( recorder: &mut RunRecorder<'_>, report: &AppServerCapabilityPreflightReport, @@ -3378,8 +3413,9 @@ fn handle_dynamic_tool_call( "Dynamic tool `{}` was called under namespace `{namespace}`, but this run did not declare that tool namespace.", payload.tool ), - None => - format!("Dynamic tool `{}` is not declared for this run attempt.", payload.tool), + None => { + format!("Dynamic tool `{}` is not declared for this run attempt.", payload.tool) + }, }; return DynamicToolCallDispatch::protocol_failure( diff --git a/apps/decodex/src/agent/app_server/protocol.rs b/apps/decodex/src/agent/app_server/protocol.rs index 1e576c1b..248cba84 100644 --- a/apps/decodex/src/agent/app_server/protocol.rs +++ b/apps/decodex/src/agent/app_server/protocol.rs @@ -187,8 +187,9 @@ impl AppServerClient { pub(super) fn list_mcp_server_status( &mut self, params: &ListMcpServerStatusParams, + timeout: Duration, ) -> Result { - self.connection.request("mcpServerStatus/list", params, REQUEST_TIMEOUT) + self.connection.request("mcpServerStatus/list", params, timeout) } pub(super) fn recv(&mut self, timeout: Option) -> Result { diff --git a/apps/decodex/src/agent/app_server/tests.rs b/apps/decodex/src/agent/app_server/tests.rs index 902227a8..4d87b9e8 100644 --- a/apps/decodex/src/agent/app_server/tests.rs +++ b/apps/decodex/src/agent/app_server/tests.rs @@ -5,6 +5,7 @@ use std::{ time::{Duration, Instant}, }; +use color_eyre::Report; use serde_json::{self, Value}; use tempfile::TempDir; @@ -19,8 +20,9 @@ use crate::{ TurnContinuationGuard, UserInput, }, json_rpc::{ - AppServerHomePreflightFailure, AppServerProcessEnv, JsonRpcMessage, - JsonRpcNotification, JsonRpcRequest, ResolvedAppServerCodexHomeEnv, WireMessage, + AppServerHomePreflightFailure, AppServerOutputTimeout, AppServerProcessEnv, + JsonRpcMessage, JsonRpcNotification, JsonRpcRequest, ResolvedAppServerCodexHomeEnv, + WireMessage, }, tracker_tool_bridge::{ DynamicToolCallResponse, DynamicToolContentItem, DynamicToolHandler, DynamicToolSpec, @@ -587,6 +589,26 @@ fn capability_preflight_request_error_records_method_blocker() { assert_eq!(state_store.event_count("run-1").expect("event count should load"), 1); } +#[test] +fn mcp_preflight_timeout_degrades_to_recorded_ok_check() { + let error = Report::new(AppServerOutputTimeout); + let mut report = AppServerCapabilityPreflightReport::new(); + + assert!(super::mcp_preflight_can_degrade(&error)); + + super::record_mcp_preflight_degraded(&mut report, &error); + + assert!(!report.has_blockers()); + assert_eq!(report.checks().len(), 1); + assert_eq!(report.checks()[0].name, "mcp"); + assert_eq!(report.checks()[0].status, super::AppServerCapabilityPreflightStatus::Ok); + assert_eq!( + report.checks()[0].details.get("degraded_reason").map(String::as_str), + Some("timeout") + ); + assert!(report.checks()[0].summary.contains("continuing")); +} + #[test] fn remaining_idle_budget_resets_from_latest_activity() { let now = Instant::now(); diff --git a/apps/decodex/src/cli.rs b/apps/decodex/src/cli.rs index 4ad67b5a..446e5268 100644 --- a/apps/decodex/src/cli.rs +++ b/apps/decodex/src/cli.rs @@ -221,12 +221,22 @@ struct RunCommand { /// Skip external side effects where the later implementation allows it. #[arg(long)] dry_run: bool, + /// Explain current queued candidates without preparing or dispatching a lane. + #[arg(long, requires = "dry_run")] + explain: bool, } impl RunCommand { fn run(&self, config_path: Option<&Path>) -> crate::prelude::Result<()> { + if self.explain && self.issue.is_some() { + eyre::bail!( + "`decodex run --dry-run --explain` explains the project queue and does not accept a positional issue." + ); + } + orchestrator::run_once(RunOnceRequest { config_path, dry_run: self.dry_run, + explain_queue: self.explain, preferred_issue_id: self.issue.as_deref(), preferred_issue_state: None, preferred_initial_issue_state: None, @@ -516,6 +526,7 @@ impl AttemptCommand { orchestrator::run_once(RunOnceRequest { config_path, dry_run: request.dry_run, + explain_queue: false, preferred_issue_id: Some(request.issue_id.as_str()), preferred_issue_state: Some(request.issue_state.as_str()), preferred_initial_issue_state: request.initial_issue_state.as_deref(), @@ -685,14 +696,35 @@ mod tests { fn parses_run_with_positional_issue_and_dry_run() { let cli = Cli::parse_from(["decodex", "run", "issue-1", "--dry-run"]); - assert!(matches!(cli.command, Command::Run(RunCommand { issue: Some(_), dry_run: true }))); + assert!(matches!( + cli.command, + Command::Run(RunCommand { issue: Some(_), dry_run: true, explain: false }) + )); } #[test] fn parses_run_without_issue() { let cli = Cli::parse_from(["decodex", "run"]); - assert!(matches!(cli.command, Command::Run(RunCommand { issue: None, dry_run: false }))); + assert!(matches!( + cli.command, + Command::Run(RunCommand { issue: None, dry_run: false, explain: false }) + )); + } + + #[test] + fn parses_run_dry_run_explain() { + let cli = Cli::parse_from(["decodex", "run", "--dry-run", "--explain"]); + + assert!(matches!( + cli.command, + Command::Run(RunCommand { issue: None, dry_run: true, explain: true }) + )); + + let error = Cli::try_parse_from(["decodex", "run", "--explain"]) + .expect_err("explain should require dry-run"); + + assert!(error.to_string().contains("--dry-run")); } #[test] diff --git a/apps/decodex/src/manual.rs b/apps/decodex/src/manual.rs index d85ab4a5..89a34dd0 100644 --- a/apps/decodex/src/manual.rs +++ b/apps/decodex/src/manual.rs @@ -3,6 +3,7 @@ use std::{ io::ErrorKind, path::{Path, PathBuf}, process::{Command, Stdio}, + thread, time::Duration, }; @@ -26,6 +27,8 @@ use crate::{ const MANUAL_LAND_CLOSEOUT_MARKER_GIT_PATH: &str = "decodex/manual-land-closeout"; const MANUAL_LAND_MERGE_VISIBILITY_TIMEOUT: Duration = Duration::from_secs(15 * 60); +const MANUAL_LAND_MERGEABILITY_RETRY_ATTEMPTS: usize = 4; +const MANUAL_LAND_MERGEABILITY_RETRY_DELAY: Duration = Duration::from_secs(2); #[derive(Debug)] pub(crate) struct ManualCommitRequest { @@ -181,7 +184,7 @@ pub(crate) fn run_land(config_path: Option<&Path>, request: &ManualLandRequest) } let default_branch = context.repository.default_branch.clone(); - let landing_state = github::inspect_pull_request_landing_state( + let landing_state = inspect_pull_request_landing_state_for_manual_land( &context.canonical_repo_root, &context.pr_url, &context.github_token, @@ -226,6 +229,41 @@ pub(crate) fn run_land(config_path: Option<&Path>, request: &ManualLandRequest) Ok(()) } +fn inspect_pull_request_landing_state_for_manual_land( + cwd: &Path, + pr_url: &str, + github_token: &str, +) -> Result { + let mut last_landing_state = None; + + for attempt in 1..=MANUAL_LAND_MERGEABILITY_RETRY_ATTEMPTS { + let landing_state = github::inspect_pull_request_landing_state(cwd, pr_url, github_token)?; + + if landing_state.state == "MERGED" + || !pull_request::mergeability_unknown(landing_state.gate_view()) + { + return Ok(landing_state); + } + + last_landing_state = Some(landing_state); + + if attempt < MANUAL_LAND_MERGEABILITY_RETRY_ATTEMPTS { + tracing::info!( + pr_url = %pr_url, + attempt, + mergeable = "UNKNOWN", + merge_state_status = "UNKNOWN", + "Pull request mergeability is unresolved; waiting for GitHub to recompute before validating manual land gates." + ); + + thread::sleep(MANUAL_LAND_MERGEABILITY_RETRY_DELAY); + } + } + + last_landing_state + .ok_or_else(|| eyre::eyre!("Pull request `{pr_url}` landing state was unavailable.")) +} + fn prepare_manual_land_context( config_path: Option<&Path>, request: &ManualLandRequest, @@ -768,6 +806,11 @@ fn validate_landing_state( ) { eyre::bail!("Pull request `{pr_url}` has failed required checks that need repair."); } + if pull_request::mergeability_unknown(gate_view) { + eyre::bail!( + "Pull request `{pr_url}` mergeability is still unknown after retry; wait for GitHub to recompute mergeability and retry `decodex land`." + ); + } if !pull_request::merge_state_allows_ready_to_land(gate_view.merge_state_status) { eyre::bail!( "Pull request `{pr_url}` is not ready to land: mergeStateStatus=`{}`.", @@ -2046,6 +2089,26 @@ exit 1\n", assert_eq!(mode, LandExecutionMode::CloseoutOnly); } + #[test] + fn landing_state_validation_explains_unknown_mergeability_after_retry() { + let mut landing_state = sample_landing_state(); + + landing_state.base_ref_name = String::from("main"); + landing_state.mergeable = String::from("UNKNOWN"); + + let error = manual::validate_landing_state( + &landing_state, + "https://github.com/hack-ink/decodex/pull/64", + "main", + "XY-225", + "deadbeef", + ) + .expect_err("unknown mergeability should not land"); + + assert!(error.to_string().contains("mergeability is still unknown after retry")); + assert!(error.to_string().contains("retry `decodex land`")); + } + #[test] fn execute_land_merge_uses_admin_merge() { let temp_dir = TempDir::new().expect("temp dir should create"); diff --git a/apps/decodex/src/orchestrator/entrypoints.rs b/apps/decodex/src/orchestrator/entrypoints.rs index 5ad84c81..6a91df2a 100644 --- a/apps/decodex/src/orchestrator/entrypoints.rs +++ b/apps/decodex/src/orchestrator/entrypoints.rs @@ -53,6 +53,25 @@ pub(crate) fn run_once(request: RunOnceRequest<'_>) -> Result<()> { _ => eyre::bail!("preferred run identity requires both `run_id` and `attempt_number`."), }; + if request.explain_queue { + if !request.dry_run { + eyre::bail!("queue explanation is only supported for dry-run execution."); + } + if request.preferred_issue_id.is_some() { + eyre::bail!("queue explanation does not accept a preferred issue."); + } + + let config = ServiceConfig::from_path(&config_path)?; + let workflow = load_configured_cycle_workflow(&config, request.preferred_workflow_snapshot)?; + let tracker = LinearClient::new(config.tracker().resolve_api_key()?)?; + let queued_candidates = + build_queued_candidate_statuses(&tracker, &config, &workflow, &state_store)?; + + print!("{}", render_queue_explain(&config, &queued_candidates)); + + return Ok(()); + } + if let Some(summary) = run_configured_cycle(RunCycleRequest { config_path: &config_path, state_store: &state_store, @@ -170,13 +189,14 @@ pub(crate) fn print_status( Ok(recovered_state) => hydrate_status_snapshot_state(&config, &state_store, recovered_state)?, Err(error) => { - let _ = error; + let warning = runtime_recovery_warning("runtime_recovery_unavailable", &error); tracing::warn!( + recovery_error_class = runtime_recovery_error_class(&error), "Skipped runtime recovery for operator status; sensitive runtime details were withheld." ); - snapshot_warnings.push("runtime_recovery_unavailable"); + snapshot_warnings.push(warning); }, } @@ -184,7 +204,7 @@ pub(crate) fn print_status( build_live_operator_status_snapshot(&tracker, &config, &workflow, &state_store, limit)?; for warning in snapshot_warnings { - add_operator_snapshot_warning(&mut snapshot, warning); + add_operator_snapshot_warning(&mut snapshot, &warning); } refresh_operator_project_summary(&mut snapshot); @@ -267,6 +287,26 @@ pub(crate) fn run_diagnose(request: DiagnoseRequest<'_>) -> Result<()> { Ok(()) } +fn runtime_recovery_warning(prefix: &str, error: &Report) -> String { + format!("{prefix}:{}", runtime_recovery_error_class(error)) +} + +fn runtime_recovery_error_class(error: &Report) -> &'static str { + let message = error.to_string().to_ascii_lowercase(); + + if message.contains("linear") || message.contains("tracker") { + return "tracker"; + } + if message.contains("worktree") || message.contains("work tree") { + return "worktree"; + } + if message.contains("runtime") || message.contains("sqlite") || message.contains("database") { + return "runtime_store"; + } + + "unknown" +} + fn build_diagnose_live_snapshot( tracker: &T, config: &ServiceConfig, @@ -283,14 +323,16 @@ where Ok(recovered_state) => hydrate_status_snapshot_state(config, state_store, recovered_state)?, Err(error) => { - let _ = error; + let warning = + runtime_recovery_warning("diagnose_runtime_recovery_unavailable", &error); tracing::warn!( project_id = config.service_id(), + recovery_error_class = runtime_recovery_error_class(&error), "Skipped runtime recovery for diagnose; sensitive runtime details were withheld." ); - snapshot_warnings.push("diagnose_runtime_recovery_unavailable"); + snapshot_warnings.push(warning); }, } @@ -310,14 +352,14 @@ where "Fell back to local diagnose snapshot; sensitive runtime details were withheld." ); - snapshot_warnings.push("diagnose_live_observer_unavailable"); + snapshot_warnings.push(String::from("diagnose_live_observer_unavailable")); build_operator_status_snapshot(config, state_store, limit)? }, }; for warning in snapshot_warnings { - add_operator_snapshot_warning(&mut snapshot, warning); + add_operator_snapshot_warning(&mut snapshot, &warning); } Ok(snapshot) diff --git a/apps/decodex/src/orchestrator/status.rs b/apps/decodex/src/orchestrator/status.rs index 007b5aac..56e947f4 100644 --- a/apps/decodex/src/orchestrator/status.rs +++ b/apps/decodex/src/orchestrator/status.rs @@ -730,14 +730,12 @@ fn append_merged_worktree_cleanup_debts( return; } - warnings.push(String::from("merged_worktree_cleanup_pending")); - - if debts.iter().any(|debt| debt.cleanliness.is_dirty()) { - warnings.push(String::from("merged_dirty_worktree")); - } + let mut surfaced_cleanup_debt = false; + let mut surfaced_dirty_cleanup_debt = false; for debt in debts { let relative_path = relative_worktree_path_for_path(project, &debt.path); + let is_dirty = debt.cleanliness.is_dirty(); let debt_status = operator_worktree_status_from_cleanup_debt(debt, relative_path.clone()); if !seen_paths.insert(relative_path.clone()) { @@ -750,8 +748,18 @@ fn append_merged_worktree_cleanup_debts( continue; } + surfaced_cleanup_debt = true; + surfaced_dirty_cleanup_debt |= is_dirty; + worktrees.push(debt_status); } + + if surfaced_cleanup_debt { + warnings.push(String::from("merged_worktree_cleanup_pending")); + } + if surfaced_dirty_cleanup_debt { + warnings.push(String::from("merged_dirty_worktree")); + } } fn operator_worktree_status_from_cleanup_debt( @@ -3888,6 +3896,66 @@ fn render_operator_status(snapshot: &OperatorStatusSnapshot) -> String { output } +fn render_queue_explain( + config: &ServiceConfig, + queued_candidates: &[OperatorQueuedIssueStatus], +) -> String { + let mut output = String::new(); + + output.push_str(&format!("Project: {}\n", config.service_id())); + output.push_str("Mode: dry-run queue explain\n"); + output.push_str(&format!("Queued candidates: {}\n", queued_candidates.len())); + output.push_str(&format!( + "Ready: {}\n", + queued_candidates + .iter() + .filter(|candidate| candidate.classification == "ready") + .count() + )); + output.push_str(&format!( + "Waiting: {}\n", + queued_candidates + .iter() + .filter(|candidate| candidate.classification == "waiting") + .count() + )); + output.push_str(&format!( + "Blocked: {}\n", + queued_candidates + .iter() + .filter(|candidate| candidate.classification == "blocked") + .count() + )); + output.push_str(&format!( + "Claimed: {}\n", + queued_candidates + .iter() + .filter(|candidate| candidate.classification == "claimed") + .count() + )); + output.push_str(&format!( + "Closed: {}\n", + queued_candidates + .iter() + .filter(|candidate| candidate.classification == "closed") + .count() + )); + output.push_str("\nQueued Candidate Reasons\n"); + + if queued_candidates.is_empty() { + output.push_str("- none\n"); + output.push_str(&format!(" {}\n", format_status_no_eligible_issue_hint(config.service_id()))); + + return output; + } + + for queued_issue in queued_candidates { + append_rendered_queued_issue(&mut output, queued_issue, None); + } + + output +} + fn rendered_backlog_queue_groups( queued_candidates: Vec<&OperatorQueuedIssueStatus>, ) -> (Vec<&OperatorQueuedIssueStatus>, Vec<&OperatorQueuedIssueStatus>) { diff --git a/apps/decodex/src/orchestrator/tests/operator/status/running_lanes.rs b/apps/decodex/src/orchestrator/tests/operator/status/running_lanes.rs index 7b80c49d..68c688f4 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/running_lanes.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/running_lanes.rs @@ -124,6 +124,58 @@ fn operator_status_snapshot_surfaces_merged_dirty_ad_hoc_worktree() { assert!(error.to_string().contains("Post-land worktree cleanup is pending")); } +#[test] +fn operator_status_snapshot_updates_owned_merged_worktree_hygiene_without_global_warning() { + let (_temp_dir, config, _workflow) = temp_project_layout(); + let state_store = StateStore::open_in_memory().expect("state store should open"); + let issue = sample_issue("Done", &[]); + let worktree_path = config.worktree_root().join("PUB-101"); + + git_status_success( + config.repo_root(), + &[ + "worktree", + "add", + "-b", + "xy/pub-101-cleanup", + worktree_path.to_str().expect("worktree path should be UTF-8"), + "main", + ], + ); + commit_worktree_change(&worktree_path, "README.md", "feature work\n", "feature work"); + git_status_success( + config.repo_root(), + &["merge", "--no-ff", "xy/pub-101-cleanup", "-m", "land feature"], + ); + + fs::write(worktree_path.join("README.md"), "dirty after land\n") + .expect("worktree file should become dirty"); + + state_store + .upsert_worktree( + "pubfi", + &issue.id, + "xy/pub-101-cleanup", + &worktree_path.display().to_string(), + ) + .expect("worktree mapping should record"); + + let snapshot = orchestrator::build_operator_status_snapshot(&config, &state_store, 10) + .expect("snapshot should build"); + let worktree = snapshot + .worktrees + .iter() + .find(|worktree| worktree.worktree_path == ".worktrees/PUB-101") + .expect("owned merged worktree should still be visible"); + + assert!(!snapshot.warnings.contains(&String::from("merged_worktree_cleanup_pending"))); + assert!(!snapshot.warnings.contains(&String::from("merged_dirty_worktree"))); + assert!( + worktree.hygiene.as_ref().is_some_and(|hygiene| hygiene.dirty), + "hygiene should still surface on the owned worktree row" + ); +} + #[test] fn live_operator_status_snapshot_hydrates_active_run_issue_display_metadata() { let (_temp_dir, config, workflow) = temp_project_layout(); diff --git a/apps/decodex/src/orchestrator/tests/operator/status/text.rs b/apps/decodex/src/orchestrator/tests/operator/status/text.rs index 0267095a..0451ad4f 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/text.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/text.rs @@ -92,6 +92,47 @@ fn operator_status_text_renders_human_readable_sections() { assert_recovery_worktree_roles_are_grouped(&rendered); } +#[test] +fn queue_explain_renders_candidate_reasons_without_running_dispatch() { + let (_temp_dir, config, _workflow) = temp_project_layout(); + let candidates = operator_status_text_queued_candidates(); + let rendered = orchestrator::render_queue_explain(&config, &candidates); + + assert!(rendered.contains("Mode: dry-run queue explain")); + assert!(rendered.contains("Queued candidates: 3")); + assert!(rendered.contains("Ready: 1")); + assert!(rendered.contains("Claimed: 1")); + assert!(rendered.contains("Closed: 1")); + assert!(rendered.contains("issue: PUB-102")); + assert!(rendered.contains("classification: ready")); + assert!(rendered.contains("reason: eligible_for_dispatch")); +} + +#[test] +fn runtime_recovery_warning_keeps_safe_error_class() { + assert_eq!( + orchestrator::runtime_recovery_warning( + "runtime_recovery_unavailable", + &eyre::eyre!("Linear tracker request failed"), + ), + "runtime_recovery_unavailable:tracker" + ); + assert_eq!( + orchestrator::runtime_recovery_warning( + "runtime_recovery_unavailable", + &eyre::eyre!("worktree scan failed"), + ), + "runtime_recovery_unavailable:worktree" + ); + assert_eq!( + orchestrator::runtime_recovery_warning( + "runtime_recovery_unavailable", + &eyre::eyre!("sqlite runtime store locked"), + ), + "runtime_recovery_unavailable:runtime_store" + ); +} + #[test] fn operator_status_text_explains_empty_backlog_checks() { let snapshot = OperatorStatusSnapshot { diff --git a/apps/decodex/src/orchestrator/types.rs b/apps/decodex/src/orchestrator/types.rs index 7feeca45..5adc2dd8 100644 --- a/apps/decodex/src/orchestrator/types.rs +++ b/apps/decodex/src/orchestrator/types.rs @@ -12,6 +12,7 @@ trait PullRequestReviewStateInspector { pub(crate) struct RunOnceRequest<'a> { pub(crate) config_path: Option<&'a Path>, pub(crate) dry_run: bool, + pub(crate) explain_queue: bool, pub(crate) preferred_issue_id: Option<&'a str>, pub(crate) preferred_issue_state: Option<&'a str>, pub(crate) preferred_initial_issue_state: Option<&'a str>, diff --git a/apps/decodex/src/pull_request.rs b/apps/decodex/src/pull_request.rs index ae9a72ae..cf7c1b8a 100644 --- a/apps/decodex/src/pull_request.rs +++ b/apps/decodex/src/pull_request.rs @@ -85,8 +85,11 @@ pub(crate) fn retained_landing_requires_agent_fallback( review_and_check_gates_ready && ((retained_landing_gates_satisfied(view) && !retained_clean_path_landing_gates_satisfied(view)) - || view.mergeable == "UNKNOWN" - || view.merge_state_status == "UNKNOWN") + || mergeability_unknown(view)) +} + +pub(crate) fn mergeability_unknown(view: PullRequestLandingGateView<'_>) -> bool { + view.mergeable == "UNKNOWN" || view.merge_state_status == "UNKNOWN" } pub(crate) fn merge_state_allows_ready_to_land(merge_state_status: &str) -> bool { @@ -215,4 +218,20 @@ mod tests { assert!(pull_request::failed_checks_require_repair(Some("FAILURE"), "BLOCKED")); assert!(!pull_request::failed_checks_require_repair(Some("FAILURE"), "CLEAN")); } + + #[test] + fn landing_gate_helpers_detect_unknown_mergeability() { + let mut view = sample_gate_view(); + + view.mergeable = "UNKNOWN"; + + assert!(pull_request::mergeability_unknown(view)); + + let mut view = sample_gate_view(); + + view.merge_state_status = "UNKNOWN"; + + assert!(pull_request::mergeability_unknown(view)); + assert!(!pull_request::mergeability_unknown(sample_gate_view())); + } }