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,