From 0d0b6e9b7480a071b6559be3284ff4a676f6c769 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Tue, 2 Jun 2026 18:10:15 +0800 Subject: [PATCH] {"schema":"decodex/commit/1","summary":"Clarify worktree hygiene warning details","authority":"manual"} --- apps/decodex/src/orchestrator/entrypoints.rs | 2 + .../src/orchestrator/operator_dashboard.html | 24 +++++- apps/decodex/src/orchestrator/status.rs | 80 +++++++++++++++++-- .../tests/operator/status/agent_evidence.rs | 1 + .../tests/operator/status/dashboard.rs | 7 +- .../tests/operator/status/running_lanes.rs | 39 +++++++++ .../tests/operator/status/text.rs | 6 ++ apps/decodex/src/orchestrator/types.rs | 10 +++ docs/reference/operator-control-plane.md | 5 ++ 9 files changed, 166 insertions(+), 8 deletions(-) diff --git a/apps/decodex/src/orchestrator/entrypoints.rs b/apps/decodex/src/orchestrator/entrypoints.rs index 65103f9f..32d4a9c9 100644 --- a/apps/decodex/src/orchestrator/entrypoints.rs +++ b/apps/decodex/src/orchestrator/entrypoints.rs @@ -922,6 +922,7 @@ fn append_control_plane_project_snapshot( add_operator_snapshot_warning(snapshot, &warning); } + snapshot.warning_details.extend(project_snapshot.warning_details); snapshot.connector_backoffs.extend(project_snapshot.connector_backoffs); snapshot.accounts.extend(project_snapshot.accounts); snapshot.active_runs.extend(project_snapshot.active_runs); @@ -1375,6 +1376,7 @@ fn empty_control_plane_snapshot(limit: usize) -> OperatorStatusSnapshot { project_id: String::from("all"), run_limit: limit, warnings: Vec::new(), + warning_details: Vec::new(), connector_backoffs: Vec::new(), projects: Vec::new(), account_control: OperatorCodexAccountControlStatus { diff --git a/apps/decodex/src/orchestrator/operator_dashboard.html b/apps/decodex/src/orchestrator/operator_dashboard.html index 0f46128a..dc059417 100644 --- a/apps/decodex/src/orchestrator/operator_dashboard.html +++ b/apps/decodex/src/orchestrator/operator_dashboard.html @@ -8562,7 +8562,20 @@

Run History

return notices; } - function warningNotice(warning) { + function warningDetailsFor(warning, snapshot) { + return (snapshot?.warning_details ?? []).filter((detail) => detail?.warning === warning); + } + + function warningNotice(warning, snapshot) { + const details = warningDetailsFor(warning, snapshot); + if (warning === "worktree_hygiene_unavailable" && details.length) { + return { + tone: "warning", + title: "Worktree hygiene unavailable", + copy: details.map(worktreeHygieneWarningCopy).join(" "), + }; + } + return { tone: "warning", title: "Snapshot warning", @@ -8570,6 +8583,15 @@

Run History

}; } + function worktreeHygieneWarningCopy(detail) { + const project = detail.project_id || "project"; + const repo = detail.repo_root ? ` Repo: ${detail.repo_root}.` : ""; + const reason = detail.reason || "Worktree hygiene scan failed."; + const nextAction = detail.next_action ? ` ${detail.next_action}` : ""; + + return `${project}: ${reason}.${repo}${nextAction}`; + } + function renderNoticeDock(notices) { const hasNotices = notices.length > 0; nodes.noticeDock.classList.toggle("visible", hasNotices); diff --git a/apps/decodex/src/orchestrator/status.rs b/apps/decodex/src/orchestrator/status.rs index 48dd3db3..7b6215d9 100644 --- a/apps/decodex/src/orchestrator/status.rs +++ b/apps/decodex/src/orchestrator/status.rs @@ -235,12 +235,14 @@ fn build_operator_status_snapshot_with_account_mode( } let history_lanes = operator_history_lanes(&active_runs, &recent_runs); - let (worktrees, mut warnings) = operator_status_worktrees(project, state_store)?; + let (worktrees, mut warnings, warning_details) = + operator_status_worktrees(project, state_store)?; let accounts = codex_account_activity_summaries(project, &mut warnings, account_activity_mode); let mut snapshot = OperatorStatusSnapshot { project_id: project.service_id().to_owned(), run_limit: limit, warnings, + warning_details, connector_backoffs: Vec::new(), projects: vec![OperatorProjectStatus { project_id: project.service_id().to_owned(), @@ -970,7 +972,11 @@ fn project_last_activity_at(snapshot: &OperatorStatusSnapshot) -> Option fn operator_status_worktrees( project: &ServiceConfig, state_store: &StateStore, -) -> crate::prelude::Result<(Vec, Vec)> { +) -> crate::prelude::Result<( + Vec, + Vec, + Vec, +)> { let mut worktrees = state_store .list_worktrees(project.service_id())? .into_iter() @@ -991,6 +997,7 @@ fn operator_status_worktrees( let mut seen_paths = worktrees.iter().map(|worktree| worktree.worktree_path.clone()).collect::>(); let mut warnings = Vec::new(); + let mut warning_details = Vec::new(); for issue_identifier in recoverable_worktree_identifiers(project.worktree_root())? { let worktree_path = project.worktree_root().join(&issue_identifier); @@ -1019,7 +1026,13 @@ fn operator_status_worktrees( }); } - append_merged_worktree_cleanup_debts(project, &mut worktrees, &mut seen_paths, &mut warnings); + append_merged_worktree_cleanup_debts( + project, + &mut worktrees, + &mut seen_paths, + &mut warnings, + &mut warning_details, + ); worktrees.sort_by(|left, right| { left.issue_id @@ -1028,7 +1041,7 @@ fn operator_status_worktrees( .then_with(|| left.worktree_path.cmp(&right.worktree_path)) }); - Ok((worktrees, warnings)) + Ok((worktrees, warnings, warning_details)) } fn append_merged_worktree_cleanup_debts( @@ -1036,6 +1049,7 @@ fn append_merged_worktree_cleanup_debts( worktrees: &mut Vec, seen_paths: &mut HashSet, warnings: &mut Vec, + warning_details: &mut Vec, ) { let debts = match project_merged_worktree_cleanup_debts(project) { Ok(debts) => debts, @@ -1047,6 +1061,7 @@ fn append_merged_worktree_cleanup_debts( ); warnings.push(String::from("worktree_hygiene_unavailable")); + warning_details.push(worktree_hygiene_unavailable_warning_detail(project, &error)); return; }, @@ -1088,6 +1103,21 @@ fn append_merged_worktree_cleanup_debts( } } +fn worktree_hygiene_unavailable_warning_detail( + project: &ServiceConfig, + error: &Report, +) -> OperatorSnapshotWarningDetail { + OperatorSnapshotWarningDetail { + warning: String::from("worktree_hygiene_unavailable"), + project_id: Some(project.service_id().to_owned()), + repo_root: Some(project.repo_root().display().to_string()), + reason: format!("Worktree hygiene scan failed: {error}"), + next_action: Some(String::from( + "Remove the stale project registration or restore the Git checkout before running automation.", + )), + } +} + fn operator_worktree_status_from_cleanup_debt( debt: MergedWorktreeCleanupDebt, relative_path: String, @@ -4762,7 +4792,7 @@ fn render_operator_status(snapshot: &OperatorStatusSnapshot) -> String { output.push_str(&format!("Warnings: {}\n", snapshot.warnings.len())); if !snapshot.warnings.is_empty() { - output.push_str(&format!("Warning details: {}\n", snapshot.warnings.join(", "))); + output.push_str(&format!("Warning details: {}\n", render_warning_details(snapshot))); } output.push_str(&format!("Running lanes: {}\n", snapshot.active_runs.len())); @@ -4859,6 +4889,46 @@ fn render_operator_status(snapshot: &OperatorStatusSnapshot) -> String { output } +fn render_warning_details(snapshot: &OperatorStatusSnapshot) -> String { + snapshot + .warnings + .iter() + .flat_map(|warning| { + let details = snapshot + .warning_details + .iter() + .filter(|detail| &detail.warning == warning) + .collect::>(); + + if details.is_empty() { + return vec![warning.clone()]; + } + + details.into_iter().map(format_warning_detail).collect() + }) + .collect::>() + .join("; ") +} + +fn format_warning_detail(detail: &OperatorSnapshotWarningDetail) -> String { + let mut parts = vec![detail.warning.clone()]; + + if let Some(project_id) = detail.project_id.as_deref() { + parts.push(format!("project={project_id}")); + } + if let Some(repo_root) = detail.repo_root.as_deref() { + parts.push(format!("repo_root={repo_root}")); + } + + parts.push(format!("reason={}", detail.reason)); + + if let Some(next_action) = detail.next_action.as_deref() { + parts.push(format!("next_action={next_action}")); + } + + parts.join(" ") +} + fn render_queue_explain( config: &ServiceConfig, queued_candidates: &[OperatorQueuedIssueStatus], diff --git a/apps/decodex/src/orchestrator/tests/operator/status/agent_evidence.rs b/apps/decodex/src/orchestrator/tests/operator/status/agent_evidence.rs index 545496b1..9676c00f 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/agent_evidence.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/agent_evidence.rs @@ -27,6 +27,7 @@ fn agent_evidence_snapshot_writes_index_blockers_capsules_and_event_stream() { project_id: String::from(TEST_SERVICE_ID), run_limit: 10, warnings: Vec::new(), + warning_details: Vec::new(), connector_backoffs: Vec::new(), projects: Vec::new(), account_control: OperatorCodexAccountControlStatus { diff --git a/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs b/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs index 29be372e..040f8683 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs @@ -1257,10 +1257,13 @@ fn operator_dashboard_projects_show_compact_activity_work_and_location() { assert!(response.contains("label: \"sync degraded\"")); assert!(response.contains("label: \"sync degraded\", tone: \"tone-muted\"")); assert!(response.contains("project.connector_state === \"config_error\"")); - assert!(response.contains("function warningNotice(warning)")); + assert!(response.contains("function warningDetailsFor(warning, snapshot)")); + assert!(response.contains("function warningNotice(warning, snapshot)")); + assert!(response.contains("title: \"Worktree hygiene unavailable\"")); + assert!(response.contains("worktree_hygiene_unavailable")); assert!(response.contains("copy: displayToken(warning)")); assert!(!response.contains("title: projectSummary")); - assert!(!response.contains("remove it or re-register the project")); + assert!(response.contains("const nextAction = detail.next_action ?")); assert!(response.contains("return { label: \"ok\", tone: \"tone-ready\"")); assert!(!response.contains("function projectSyncMeta(project, health)")); assert!(!response.contains("const connectorCopy = projectSyncMeta(project, health);")); 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 6e618694..a030945d 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/running_lanes.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/running_lanes.rs @@ -136,6 +136,44 @@ 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_explains_unavailable_worktree_hygiene() { + let (_temp_dir, config, _workflow) = temp_project_layout(); + let state_store = StateStore::open_in_memory().expect("state store should open"); + + fs::remove_dir_all(config.repo_root().join(".git")) + .expect("repo metadata should be removable for the fixture"); + + let snapshot = orchestrator::build_operator_status_snapshot(&config, &state_store, 10) + .expect("snapshot should degrade instead of failing"); + let detail = snapshot + .warning_details + .iter() + .find(|detail| detail.warning == "worktree_hygiene_unavailable") + .expect("hygiene warning should include operator-facing detail"); + + assert!(snapshot.warnings.contains(&String::from("worktree_hygiene_unavailable"))); + assert_eq!(detail.project_id.as_deref(), Some("pubfi")); + + let repo_root = config.repo_root().display().to_string(); + + assert_eq!(detail.repo_root.as_deref(), Some(repo_root.as_str())); + assert!(detail.reason.contains("not a git repository")); + assert!( + detail + .next_action + .as_deref() + .is_some_and(|action| action.contains("Remove the stale project registration")), + "detail should tell the operator how to clear a stale project registration" + ); + + let rendered = orchestrator::render_operator_status(&snapshot); + + assert!(rendered.contains("project=pubfi")); + assert!(rendered.contains("repo_root=")); + assert!(rendered.contains("Remove the stale project registration")); +} + #[test] fn operator_status_snapshot_updates_owned_merged_worktree_hygiene_without_global_warning() { let (_temp_dir, config, _workflow) = temp_project_layout(); @@ -309,6 +347,7 @@ fn idle_operator_status_snapshot_has_no_runtime_or_recovery_noise() { for field in [ "warnings", + "warning_details", "active_runs", "recent_runs", "history_lanes", diff --git a/apps/decodex/src/orchestrator/tests/operator/status/text.rs b/apps/decodex/src/orchestrator/tests/operator/status/text.rs index d82a8d14..1506661e 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/text.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/text.rs @@ -5,6 +5,7 @@ fn operator_status_text_renders_human_readable_sections() { project_id: String::from("pubfi"), run_limit: 10, warnings: Vec::new(), + warning_details: Vec::new(), connector_backoffs: Vec::new(), projects: Vec::new(), account_control: OperatorCodexAccountControlStatus { @@ -139,6 +140,7 @@ fn operator_status_text_explains_empty_backlog_checks() { project_id: String::from("pubfi"), run_limit: 10, warnings: Vec::new(), + warning_details: Vec::new(), connector_backoffs: Vec::new(), projects: Vec::new(), account_control: OperatorCodexAccountControlStatus { @@ -173,6 +175,7 @@ fn operator_status_text_surfaces_cleanup_blocker_pr_url() { project_id: String::from("pubfi"), run_limit: 10, warnings: Vec::new(), + warning_details: Vec::new(), connector_backoffs: Vec::new(), projects: Vec::new(), account_control: OperatorCodexAccountControlStatus { @@ -237,6 +240,7 @@ fn operator_status_text_terminal_run_freshness_uses_terminal_update() { project_id: String::from("pubfi"), run_limit: 10, warnings: Vec::new(), + warning_details: Vec::new(), connector_backoffs: Vec::new(), projects: Vec::new(), account_control: OperatorCodexAccountControlStatus { @@ -274,6 +278,7 @@ fn operator_status_text_active_run_without_live_activity_does_not_promote_update project_id: String::from("pubfi"), run_limit: 10, warnings: Vec::new(), + warning_details: Vec::new(), connector_backoffs: Vec::new(), projects: Vec::new(), account_control: OperatorCodexAccountControlStatus { @@ -306,6 +311,7 @@ fn operator_status_text_explains_unleased_live_running_lane() { project_id: String::from("pubfi"), run_limit: 10, warnings: Vec::new(), + warning_details: Vec::new(), connector_backoffs: Vec::new(), projects: Vec::new(), account_control: OperatorCodexAccountControlStatus { diff --git a/apps/decodex/src/orchestrator/types.rs b/apps/decodex/src/orchestrator/types.rs index 4c035a6e..471fb4a4 100644 --- a/apps/decodex/src/orchestrator/types.rs +++ b/apps/decodex/src/orchestrator/types.rs @@ -743,6 +743,7 @@ struct OperatorStatusSnapshot { project_id: String, run_limit: usize, warnings: Vec, + warning_details: Vec, connector_backoffs: Vec, projects: Vec, account_control: OperatorCodexAccountControlStatus, @@ -755,6 +756,15 @@ struct OperatorStatusSnapshot { post_review_lanes: Vec, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +struct OperatorSnapshotWarningDetail { + warning: String, + project_id: Option, + repo_root: Option, + reason: String, + next_action: Option, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize)] struct OperatorConnectorBackoffStatus { project_id: String, diff --git a/docs/reference/operator-control-plane.md b/docs/reference/operator-control-plane.md index cbb91dcb..3151e441 100644 --- a/docs/reference/operator-control-plane.md +++ b/docs/reference/operator-control-plane.md @@ -157,6 +157,11 @@ and manual retry controls are intentionally not shown. `runActivity.activeRunsCo marks whether a payload is the complete active-run list; subscription-filtered payloads set it to `false`, so consumers must not treat a missing run in that payload as ended. +Snapshot `warnings` remain stable machine-readable tokens. When a warning needs +operator action, snapshots may also include `warning_details` entries with the +affected `project_id`, `repo_root`, reason, and next action; for example, a stale +registered project whose repo path is no longer a Git checkout can explain the bad +project instead of only surfacing `worktree_hygiene_unavailable`. The stop control signals the recorded child process for that run, marks the local attempt interrupted, and releases the local queue lease. `ack` is dashboard-local acknowledgement only. The socket is not a browser connection to Codex app-server,