diff --git a/apps/decodex/src/orchestrator/entrypoints.rs b/apps/decodex/src/orchestrator/entrypoints.rs index a39bd33d..d0d99ac1 100644 --- a/apps/decodex/src/orchestrator/entrypoints.rs +++ b/apps/decodex/src/orchestrator/entrypoints.rs @@ -833,6 +833,8 @@ fn operator_project_status_from_registration( retained_worktree_count: 0, waiting_lane_count: 0, attention_count: 0, + cleanup_blocked_count: 0, + cleanup_pending_count: 0, connector_state: if project.enabled() { if warning_count == 0 { String::from("ok") @@ -861,6 +863,8 @@ fn operator_project_status_from_api_only_registration( retained_worktree_count: 0, waiting_lane_count: 0, attention_count: 0, + cleanup_blocked_count: 0, + cleanup_pending_count: 0, connector_state: if project.enabled() { String::from("api_only") } else { diff --git a/apps/decodex/src/orchestrator/operator_dashboard.html b/apps/decodex/src/orchestrator/operator_dashboard.html index 88537416..1ada98d1 100644 --- a/apps/decodex/src/orchestrator/operator_dashboard.html +++ b/apps/decodex/src/orchestrator/operator_dashboard.html @@ -4142,6 +4142,7 @@

Run History

case "wait_for_review": return "tone-review"; case "cleanup_blocked": + return "tone-wait"; case "closeout_blocked": case "blocked": return "tone-blocked"; @@ -7705,6 +7706,12 @@

Run History

(candidate) => candidate.classification === "closed", ); const worktrees = snapshot?.worktrees ?? []; + const cleanupIssueKeys = new Set(); + for (const worktree of worktrees) { + if (worktree.hygiene) { + cleanupIssueKeys.add(issueDisplayKey(worktree)); + } + } const waitingItems = []; const readyItems = []; const attentionItems = []; @@ -7800,6 +7807,9 @@

Run History

if (isPostReviewBlocker(lane)) { const blockerScope = postReviewBlockerScope(lane); + if (blockerScope === "Cleanup") { + cleanupIssueKeys.add(issueKey); + } attentionItems.push({ tone, scope: blockerScope, @@ -7861,8 +7871,9 @@

Run History

(item) => item.scope === "Review", ).length; const reviewBlockerCount = attentionItems.filter((item) => - ["Review", "Closeout", "Cleanup"].includes(item.scope), + ["Review", "Closeout"].includes(item.scope), ).length; + const cleanupCount = cleanupIssueKeys.size; const runningAttentionCount = attentionItems.filter((item) => item.scope === "Running").length; const liveRuns = activeRuns.filter(runCountsAsRunning).length; const intakeAttentionCount = queueBacklogCandidates.filter(queuedCandidateNeedsAttention).length; @@ -7891,6 +7902,7 @@

Run History

readyItems, reviewWaitingCount, reviewBlockerCount, + cleanupCount, runningAttentionCount, sessionHistoryRuns: historyRuns, worktrees, @@ -7944,6 +7956,8 @@

Run History

(project.queued_candidate_count ?? 0) + (project.waiting_lane_count ?? 0) + (project.attention_count ?? 0) + + (project.cleanup_blocked_count ?? 0) + + (project.cleanup_pending_count ?? 0) + (project.post_review_lane_count ?? 0); const connector = projectConnectorSummary(project); const syncNeedsAttention = project.enabled && ["backoff", "degraded", "stale"].includes(connector); @@ -7968,6 +7982,9 @@

Run History

if ((project.waiting_lane_count ?? 0) > 0) { return { label: "waiting", tone: "tone-wait", title: "Lanes waiting to resume" }; } + if ((project.cleanup_blocked_count ?? 0) > 0) { + return { label: "cleanup blocked", tone: "tone-wait", title: "Post-land cleanup needs operator action" }; + } if (project.connector_state === "backoff") { return { label: "sync backoff", @@ -7979,17 +7996,27 @@

Run History

(project.warning_count ?? 0) > 0 || ["degraded", "stale_cache"].includes(project.connector_state) ) { + if ((project.cleanup_pending_count ?? 0) > 0) { + return { label: "cleanup pending", tone: "tone-retained", title: "Post-land cleanup pending" }; + } + return { label: "sync degraded", tone: "tone-wait", title: "Tracker sync or retry state degraded" }; } + if ((project.cleanup_pending_count ?? 0) > 0) { + return { label: "cleanup pending", tone: "tone-retained", title: "Post-land cleanup pending" }; + } return { label: "ok", tone: "tone-ready", title: "No project warnings" }; } function projectCapacitySummary(project) { + const cleanup = (project.cleanup_blocked_count ?? 0) + (project.cleanup_pending_count ?? 0); + return [ `${project.active_run_count ?? 0} running`, `${project.waiting_lane_count ?? 0} waiting`, `${project.attention_count ?? 0} attention`, + `${cleanup} cleanup`, ].join(" · "); } @@ -8005,10 +8032,11 @@

Run History

const running = project.active_run_count ?? 0; const waiting = project.waiting_lane_count ?? 0; const attention = project.attention_count ?? 0; + const cleanup = (project.cleanup_blocked_count ?? 0) + (project.cleanup_pending_count ?? 0); return ` - - ${escapeHtml(running)}/${escapeHtml(waiting)}/${escapeHtml(attention)} + + ${escapeHtml(running)}/${escapeHtml(waiting)}/${escapeHtml(attention)}/${escapeHtml(cleanup)} `; } @@ -8020,6 +8048,12 @@

Run History

if ((project.attention_count ?? 0) > 0) { return `${project.attention_count} attention`; } + if ((project.cleanup_blocked_count ?? 0) > 0) { + return `${project.cleanup_blocked_count} cleanup blocked`; + } + if ((project.cleanup_pending_count ?? 0) > 0) { + return `${project.cleanup_pending_count} cleanup pending`; + } if ((project.warning_count ?? 0) > 0) { return pluralize(project.warning_count, "warning"); } @@ -8079,14 +8113,14 @@

Run History

function projectWorkInfoMarkup() { return ` - - running / waiting / attention + running / waiting / attention / cleanup `; } @@ -8195,6 +8229,8 @@

Run History

projectNumber(project.active_run_count), projectNumber(project.waiting_lane_count), projectNumber(project.attention_count), + projectNumber(project.cleanup_blocked_count), + projectNumber(project.cleanup_pending_count), ]; } @@ -8271,20 +8307,29 @@

Run History

if ((project.active_run_count ?? 0) > 0) { return 0; } - if ((project.attention_count ?? 0) > 0 || (project.warning_count ?? 0) > 0) { + if ((project.attention_count ?? 0) > 0) { return 1; } - if (["backoff", "degraded", "stale_cache"].includes(project.connector_state)) { + if ((project.waiting_lane_count ?? 0) > 0) { return 2; } - if ((project.waiting_lane_count ?? 0) > 0) { + if ((project.cleanup_blocked_count ?? 0) > 0) { return 3; } - if (projectHasActiveWork(project)) { + if ((project.cleanup_pending_count ?? 0) > 0) { return 4; } + if (["backoff", "degraded", "stale_cache"].includes(project.connector_state)) { + return 5; + } + if ((project.warning_count ?? 0) > 0) { + return 6; + } + if (projectHasActiveWork(project)) { + return 7; + } - return 5; + return 8; } function compareProjectRowsStable(left, right) { @@ -8846,6 +8891,20 @@

${escapeHtml(title)}

}; } if (reviewMatch) { + if (hygiene) { + const isDirty = hygiene.classification === "merged_dirty_worktree" || hygiene.dirty === true; + + return { + sortRank: 1, + tone: isDirty ? "tone-wait" : "tone-retained", + label: isDirty ? "post-review cleanup blocked" : "post-review cleanup", + summary: + hygiene.reason || + worktree.ownership_reason || + "Post-review cleanup pending.", + }; + } + return { sortRank: 1, tone: toneForLane(reviewMatch), @@ -8895,6 +8954,20 @@

${escapeHtml(title)}

}; } if (worktree.ownership === "post_review_lane") { + if (hygiene) { + const isDirty = hygiene.classification === "merged_dirty_worktree" || hygiene.dirty === true; + + return { + sortRank: 1, + tone: isDirty ? "tone-wait" : "tone-retained", + label: isDirty ? "post-review cleanup blocked" : "post-review cleanup", + summary: + hygiene.reason || + worktree.ownership_reason || + "Post-review cleanup pending.", + }; + } + return { sortRank: 1, tone: "tone-retained", @@ -8909,8 +8982,8 @@

${escapeHtml(title)}

return { sortRank: 2, - tone: isDirty ? "tone-blocked" : "tone-retained", - label: isDirty ? "post-land dirty worktree" : "post-land cleanup", + tone: isDirty ? "tone-wait" : "tone-retained", + label: isDirty ? "post-land cleanup blocked" : "post-land cleanup", summary: hygiene.reason || worktree.ownership_reason || @@ -8953,7 +9026,7 @@

${escapeHtml(title)}

function recoveryWorktreeShouldDefaultOpen(renderedWorktree) { const role = renderedWorktree.role; - return role.tone === "tone-blocked" || role.label.startsWith("post-land"); + return role.tone === "tone-blocked" || role.label.includes("cleanup"); } function renderWorktrees(snapshot) { @@ -9486,8 +9559,8 @@

${escapeHtml(worktree.branch_name)}

setPanelMeta( nodes.reviewLanesMeta, snapshot - ? `${pluralize(derived.postReviewLanes.length, "PR")} · ${pluralize(derived.reviewBlockerCount, "needs attention", "need attention")} · ${derived.readyItems.length} ready · ${derived.reviewWaitingCount} waiting` - : "0 PRs · 0 need attention · 0 ready · 0 waiting", + ? `${pluralize(derived.postReviewLanes.length, "PR")} · ${pluralize(derived.reviewBlockerCount, "needs attention", "need attention")} · ${derived.readyItems.length} ready · ${derived.reviewWaitingCount} waiting · ${derived.cleanupCount} cleanup` + : "0 PRs · 0 need attention · 0 ready · 0 waiting · 0 cleanup", ); renderWorktrees(snapshot); } diff --git a/apps/decodex/src/orchestrator/status.rs b/apps/decodex/src/orchestrator/status.rs index a9db0614..90b73ff1 100644 --- a/apps/decodex/src/orchestrator/status.rs +++ b/apps/decodex/src/orchestrator/status.rs @@ -186,6 +186,8 @@ fn build_operator_status_snapshot( retained_worktree_count: 0, waiting_lane_count: 0, attention_count: 0, + cleanup_blocked_count: 0, + cleanup_pending_count: 0, connector_state: String::from("ok"), last_activity_at: None, warning_count: 0, @@ -488,6 +490,8 @@ fn refresh_operator_project_summary(snapshot: &mut OperatorStatusSnapshot) { let retained_worktree_count = rendered_recovery_worktrees(snapshot).len(); let waiting_lane_count = project_waiting_lane_count(snapshot); let attention_count = project_attention_count(snapshot); + let cleanup_blocked_count = project_cleanup_blocked_count(snapshot); + let cleanup_pending_count = project_cleanup_pending_count(snapshot); let connector_state = project_connector_state(snapshot); let last_activity_at = project_last_activity_at(snapshot); let warning_count = snapshot.warnings.len(); @@ -499,6 +503,8 @@ fn refresh_operator_project_summary(snapshot: &mut OperatorStatusSnapshot) { project_status.retained_worktree_count = retained_worktree_count; project_status.waiting_lane_count = waiting_lane_count; project_status.attention_count = attention_count; + project_status.cleanup_blocked_count = cleanup_blocked_count; + project_status.cleanup_pending_count = cleanup_pending_count; project_status.connector_state = connector_state; project_status.last_activity_at = last_activity_at; project_status.warning_count = warning_count; @@ -559,17 +565,62 @@ fn project_attention_count(snapshot: &OperatorStatusSnapshot) -> usize { .filter(|lane| { matches!( lane.classification.as_str(), - "blocked" | "needs_review_repair" | "closeout_blocked" | "cleanup_blocked" + "blocked" | "needs_review_repair" | "closeout_blocked" ) }) .count(); - let hygiene_attention = snapshot + + active_attention + queued_attention + review_attention +} + +fn project_cleanup_blocked_count(snapshot: &OperatorStatusSnapshot) -> usize { + let mut cleanup_keys = HashSet::new(); + + for lane in snapshot + .post_review_lanes + .iter() + .filter(|lane| lane.classification == "cleanup_blocked") + { + cleanup_keys.insert(post_review_lane_cleanup_key(lane)); + } + for worktree in snapshot.worktrees.iter().filter(|worktree| { + worktree.hygiene.as_ref().is_some_and(|hygiene| { + hygiene.dirty || hygiene.classification == "merged_dirty_worktree" + }) + }) { + cleanup_keys.insert(worktree_cleanup_key(worktree)); + } + + cleanup_keys.len() +} + +fn project_cleanup_pending_count(snapshot: &OperatorStatusSnapshot) -> usize { + snapshot .worktrees .iter() - .filter(|worktree| worktree.hygiene.is_some()) - .count(); + .filter(|worktree| { + worktree.hygiene.as_ref().is_some_and(|hygiene| { + !hygiene.dirty && hygiene.classification == "merged_worktree_cleanup_pending" + }) + }) + .map(worktree_cleanup_key) + .collect::>() + .len() +} - active_attention + queued_attention + review_attention + hygiene_attention +fn post_review_lane_cleanup_key(lane: &OperatorPostReviewLaneStatus) -> String { + if lane.issue_identifier.is_empty() { + return lane.issue_id.clone(); + } + + lane.issue_identifier.clone() +} + +fn worktree_cleanup_key(worktree: &OperatorWorktreeStatus) -> String { + worktree + .issue_identifier + .clone() + .unwrap_or_else(|| worktree.issue_id.clone()) } fn operator_run_counts_as_active(run: &OperatorRunStatus) -> bool { diff --git a/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs b/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs index 3a57d184..d416f2a9 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs @@ -338,8 +338,9 @@ fn operator_dashboard_child_bucket_rows_split_time_bars_from_event_diagnostics() assert!(response.contains("worktree.ownership_reason")); assert!(response.contains("const hygiene = worktree.hygiene;")); assert!(response.contains("hygiene.classification === \"merged_dirty_worktree\"")); - assert!(response.contains("post-land dirty worktree")); + assert!(response.contains("post-land cleanup blocked")); assert!(response.contains("post-land cleanup")); + assert!(response.contains("post-review cleanup blocked")); assert!(response.contains("hygiene.reason ||")); assert!(response.contains("function renderWorktreeHygieneFields(worktree)")); assert!(response.contains("field(\"Cleanup state\", humanizeToken(hygiene.classification || \"cleanup_pending\"))")); @@ -1152,6 +1153,8 @@ fn operator_dashboard_projects_show_compact_activity_work_and_location() { assert!(response.contains("return { label: \"running\", tone: \"tone-run\"")); assert!(response.contains("return { label: \"needs attention\", tone: \"tone-blocked\"")); assert!(response.contains("return { label: \"waiting\", tone: \"tone-wait\"")); + assert!(response.contains("return { label: \"cleanup blocked\", tone: \"tone-wait\"")); + assert!(response.contains("return { label: \"cleanup pending\", tone: \"tone-retained\"")); assert!(response.contains("label: \"sync backoff\"")); assert!(response.contains("label: \"sync degraded\"")); assert!(response.contains("return { label: \"ok\", tone: \"tone-ready\"")); @@ -1177,9 +1180,13 @@ fn operator_dashboard_projects_show_compact_activity_work_and_location() { assert!(response.contains("const running = project.active_run_count ?? 0;")); assert!(response.contains("const waiting = project.waiting_lane_count ?? 0;")); assert!(response.contains("const attention = project.attention_count ?? 0;")); + assert!(response.contains("const cleanup = (project.cleanup_blocked_count ?? 0) + (project.cleanup_pending_count ?? 0);")); assert!(response.contains("`${project.active_run_count ?? 0} running`")); assert!(response.contains("`${project.waiting_lane_count ?? 0} waiting`")); assert!(response.contains("`${project.attention_count ?? 0} attention`")); + assert!(response.contains("`${cleanup} cleanup`")); + assert!(response.contains("projectNumber(project.cleanup_blocked_count)")); + assert!(response.contains("projectNumber(project.cleanup_pending_count)")); assert!(!response.contains("[project.post_review_lane_count ?? 0, \"review/land\"]")); assert!(!response.contains("[project.retained_worktree_count, \"recovery\"]")); assert!(!response.contains("aria-label=\"Project capacity\"")); @@ -1192,6 +1199,7 @@ fn operator_dashboard_projects_show_compact_activity_work_and_location() { assert!(response.contains("class=\"project-work-ratio\"")); assert!(response.contains("function projectWorkInfoMarkup()")); assert!(response.contains("data-project-work-info")); + assert!(response.contains("Work format: running / waiting / attention / cleanup")); assert!(response.contains("class=\"project-work-tooltip\" role=\"tooltip\"")); } @@ -1288,7 +1296,7 @@ fn operator_dashboard_empty_lane_meta_uses_counts() { assert!(!response.contains("COPY.waitingSnapshot")); assert!(response.contains("runningLaneMetaText(derived),")); assert!(response.contains(": \"0 issues · 0 attempts\",")); - assert!(response.contains(": \"0 PRs · 0 need attention · 0 ready · 0 waiting\",")); + assert!(response.contains(": \"0 PRs · 0 need attention · 0 ready · 0 waiting · 0 cleanup\",")); assert!(response.contains("const parts = [`${derived.liveRuns ?? 0} running`];")); assert!(response.contains("const parts = [`${derived.queueBacklogCandidates.length} queued`];")); assert!(response.contains("return \"0 queued\";")); @@ -1311,12 +1319,15 @@ fn operator_dashboard_flow_counts_distinguish_intake_attention() { assert!(response.contains("attention.thread_status && attention.thread_status !== \"systemError\"")); assert!(response.contains("queueBacklogCandidates.filter(queuedCandidateNeedsAttention).length")); assert!(response.contains( - "${pluralize(derived.postReviewLanes.length, \"PR\")} · ${pluralize(derived.reviewBlockerCount, \"needs attention\", \"need attention\")}" + "${pluralize(derived.postReviewLanes.length, \"PR\")} · ${pluralize(derived.reviewBlockerCount, \"needs attention\", \"need attention\")} · ${derived.readyItems.length} ready · ${derived.reviewWaitingCount} waiting · ${derived.cleanupCount} cleanup" )); + assert!(response.contains("const cleanupIssueKeys = new Set();")); + assert!(response.contains("const cleanupCount = cleanupIssueKeys.size;")); assert!(response.contains("? pluralize(retainedWorktrees.length, \"worktree\")")); assert!(!response.contains("retained or cleanup")); assert!(response.contains("function recoveryWorktreeShouldDefaultOpen(renderedWorktree)")); - assert!(response.contains("role.tone === \"tone-blocked\" || role.label.startsWith(\"post-land\")")); + assert!(response.contains("role.tone === \"tone-blocked\" || role.label.includes(\"cleanup\")")); + assert!(response.contains("label: isDirty ? \"post-review cleanup blocked\" : \"post-review cleanup\"")); assert!(response.contains("retainedWorktrees.some(recoveryWorktreeShouldDefaultOpen)")); assert!(!response.contains("syncDefaultDetailOpenState(nodes.panels.worktrees, retainedWorktrees.length > 0);")); assert!(!response.contains("Ready, capacity-limited, or blocked issues appear here before they start.")); 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 bdd3ec6f..353df809 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/running_lanes.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/running_lanes.rs @@ -118,6 +118,12 @@ fn operator_status_snapshot_surfaces_merged_dirty_ad_hoc_worktree() { "hygiene state should mark the local changes" ); + let project = snapshot.projects.first().expect("project summary should exist"); + + assert_eq!(project.attention_count, 0); + assert_eq!(project.cleanup_blocked_count, 1); + assert_eq!(project.cleanup_pending_count, 0); + let error = orchestrator::ensure_project_has_no_merged_worktree_cleanup_debt(&config) .expect_err("normal automation should stop while merged dirty worktrees remain"); @@ -174,6 +180,12 @@ fn operator_status_snapshot_updates_owned_merged_worktree_hygiene_without_global worktree.hygiene.as_ref().is_some_and(|hygiene| hygiene.dirty), "hygiene should still surface on the owned worktree row" ); + + let project = snapshot.projects.first().expect("project summary should exist"); + + assert_eq!(project.attention_count, 0); + assert_eq!(project.cleanup_blocked_count, 1); + assert_eq!(project.cleanup_pending_count, 0); } #[test] @@ -266,6 +278,8 @@ fn idle_operator_status_snapshot_has_no_runtime_or_recovery_noise() { assert_eq!(project.retained_worktree_count, 0); assert_eq!(project.waiting_lane_count, 0); assert_eq!(project.attention_count, 0); + assert_eq!(project.cleanup_blocked_count, 0); + assert_eq!(project.cleanup_pending_count, 0); assert_eq!(project.connector_state, "ok"); assert_eq!(project.last_activity_at, None); diff --git a/apps/decodex/src/orchestrator/types.rs b/apps/decodex/src/orchestrator/types.rs index 7f0f5a75..501f92aa 100644 --- a/apps/decodex/src/orchestrator/types.rs +++ b/apps/decodex/src/orchestrator/types.rs @@ -705,6 +705,8 @@ struct OperatorProjectStatus { retained_worktree_count: usize, waiting_lane_count: usize, attention_count: usize, + cleanup_blocked_count: usize, + cleanup_pending_count: usize, connector_state: String, last_activity_at: Option, warning_count: usize,