diff --git a/.github/workflows/sweep.yml b/.github/workflows/sweep.yml
index b325df963f..920b3fb243 100644
--- a/.github/workflows/sweep.yml
+++ b/.github/workflows/sweep.yml
@@ -2326,6 +2326,28 @@ jobs:
git restore results/sweep-status || true
fi
}
+ write_apply_health() {
+ report_path="$1"
+ output_path="$2"
+ health_mode="$3"
+ health_processed_limit="$4"
+ health_cursor_path="${5:-}"
+ health_cursor_required="${6:-false}"
+ health_args=(
+ --target-repo "$TARGET_REPO"
+ --report "$report_path"
+ --mode "$health_mode"
+ --processed-limit "$health_processed_limit"
+ --close-limit "$limit"
+ )
+ if [ -n "$health_cursor_path" ]; then
+ health_args+=(--cursor-path "$health_cursor_path")
+ fi
+ if [ "$health_cursor_required" = "true" ]; then
+ health_args+=(--cursor-required true)
+ fi
+ pnpm run --silent workflow -- summarize-apply-report "${health_args[@]}" > "$output_path"
+ }
reconcile_args=(--target-repo "$TARGET_REPO" --skip-closed-at)
if [ -n "$item_numbers" ]; then
reconcile_args+=(--item-numbers "$item_numbers")
@@ -2396,6 +2418,12 @@ jobs:
fi
if [ "$sync_comments_only" != "true" ] && [ -z "$item_numbers" ]; then
echo "No unchanged high-confidence close proposals are awaiting apply. Scheduled apply wakes every 15 minutes and exits without scanning unrelated keep-open records when there is no close work."
+ pnpm run status -- \
+ --target-repo "$TARGET_REPO" \
+ --state "Apply idle" \
+ --detail "No unchanged high-confidence close proposals are awaiting apply. Scheduled apply wakes every 15 minutes and exits without scanning unrelated keep-open records when there is no close work." \
+ --run-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
+ publish_status "chore: update idle sweep apply status"
{
echo "APPLY_CLOSED_TOTAL=0"
echo "APPLY_LIMIT=1"
@@ -2455,11 +2483,21 @@ jobs:
--target-repo "$TARGET_REPO"
fi
publish_changes "chore: sync sweep review comments checkpoint $checkpoint" records apply-report.json results/comment-sync-cursors
+ comment_sync_health_cursor_path=""
+ comment_sync_health_cursor_required="false"
+ comment_sync_health_processed_limit="$comment_sync_processed_limit"
+ if [ "$sync_open_pr_batch" = "true" ]; then
+ comment_sync_health_cursor_path="$cursor_path"
+ comment_sync_health_cursor_required="true"
+ comment_sync_health_processed_limit="$sync_batch_size"
+ fi
+ write_apply_health ".artifacts/apply-reports/apply-report-$checkpoint.json" ".artifacts/apply-health-$checkpoint.json" "comment_sync" "$comment_sync_health_processed_limit" "$comment_sync_health_cursor_path" "$comment_sync_health_cursor_required"
pnpm run status -- \
--target-repo "$TARGET_REPO" \
--state "Apply comments synced" \
--detail "Comment-only apply checkpoint $checkpoint finished. Synced durable review comments: $synced_count. Result records: $result_count. Item numbers: ${item_numbers:-all}. Next cursor: ${next_cursor:-none}." \
- --run-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
+ --run-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
+ --apply-health-file ".artifacts/apply-health-$checkpoint.json"
publish_status "chore: update sweep comment sync status"
echo "::endgroup::"
fi
@@ -2506,11 +2544,17 @@ jobs:
apply_publish_paths+=(results/apply-cursors)
fi
publish_changes "chore: apply sweep decisions checkpoint $checkpoint" "${apply_publish_paths[@]}"
+ close_health_cursor_path=""
+ if [ "$auto_selected_apply_batch" = "true" ]; then
+ close_health_cursor_path="$apply_cursor_path"
+ fi
+ write_apply_health ".artifacts/apply-reports/apply-report-$checkpoint.json" ".artifacts/apply-health-$checkpoint.json" "close" "$close_processed_limit" "$close_health_cursor_path" "$auto_selected_apply_batch"
pnpm run status -- \
--target-repo "$TARGET_REPO" \
--state "Apply in progress" \
--detail "Checkpoint $checkpoint finished. Fresh closes in checkpoint: $closed_in_chunk. Total fresh closes in this run: $closed_total/$limit. Result records in checkpoint: $result_count, including durable review comment syncs." \
- --run-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
+ --run-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
+ --apply-health-file ".artifacts/apply-health-$checkpoint.json"
publish_status "chore: update sweep apply checkpoint $checkpoint status"
echo "::endgroup::"
if [ "$result_count" -ge "$close_processed_limit" ]; then
@@ -2535,11 +2579,30 @@ jobs:
continue_apply=true
break
done
+ final_health_mode="close"
+ final_health_processed_limit="$close_processed_limit"
+ final_health_cursor_path=""
+ final_health_cursor_required="$auto_selected_apply_batch"
+ if [ "$auto_selected_apply_batch" = "true" ]; then
+ final_health_cursor_path="$apply_cursor_path"
+ fi
+ if [ "$sync_comments_only" = "true" ]; then
+ final_health_mode="comment_sync"
+ final_health_processed_limit="$comment_sync_processed_limit"
+ final_health_cursor_required="false"
+ if [ "$sync_open_pr_batch" = "true" ]; then
+ final_health_cursor_path="$cursor_path"
+ final_health_cursor_required="true"
+ final_health_processed_limit="$sync_batch_size"
+ fi
+ fi
+ write_apply_health "apply-report.json" ".artifacts/apply-health-final.json" "$final_health_mode" "$final_health_processed_limit" "$final_health_cursor_path" "$final_health_cursor_required"
pnpm run status -- \
--target-repo "$TARGET_REPO" \
--state "Apply finished" \
--detail "Apply/comment-sync run finished with $closed_total fresh closes out of requested limit $limit. See apply-report.json for per-item results." \
- --run-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
+ --run-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
+ --apply-health-file ".artifacts/apply-health-final.json"
publish_status "chore: mark sweep apply finished"
next_apply_item_numbers="$item_numbers"
if [ "$auto_selected_apply_batch" = "true" ] && [ -z "$explicit_item_numbers" ]; then
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4219692364..760d782848 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ checkpoint, and status-only commits are intentionally omitted.
### Added
+- Added apply-health telemetry and a quiet-by-default dashboard alert for stalled, cursorless, or fully blocked pruning windows. Thanks @brokemac79.
- Added author-wide PR repair intake across configured public repositories, with private and unsupported repositories excluded before job generation. Thanks @Jhacarreiro.
- Added a system, light, and dark theme switcher to the generated documentation site. Thanks @joshka.
diff --git a/dashboard/worker.ts b/dashboard/worker.ts
index 75cc7e77bf..423e55217f 100644
--- a/dashboard/worker.ts
+++ b/dashboard/worker.ts
@@ -1764,7 +1764,7 @@ async function statusSnapshot(env) {
TERMINAL_BAD_CONCLUSIONS.has(String(run.conclusion)),
);
const activeJobs = await activeWorkerSnapshot(env, repo, workerRuns);
- const [workerHealth, pipeline, clusterRepair, automerge, closed, storedEvents] =
+ const [workerHealth, pipeline, clusterRepair, applyHealth, automerge, closed, storedEvents] =
await Promise.all([
withTimeout(
recentWorkerHealth(env, repo, completedWorkflowRuns),
@@ -1790,6 +1790,14 @@ async function statusSnapshot(env) {
errors.push(error.message);
return emptyClusterRepairStatus(targetRepos);
}),
+ withTimeout(
+ applyHealthStatus(env, targetRepos),
+ OPTIONAL_SECTION_TIMEOUT_MS,
+ "apply health",
+ ).catch((error) => {
+ errors.push(error.message);
+ return emptyApplyHealthStatus(targetRepos);
+ }),
withTimeout(
recentAutomerge(env, targetRepos[0] || "openclaw/openclaw"),
OPTIONAL_SECTION_TIMEOUT_MS,
@@ -1844,6 +1852,7 @@ async function statusSnapshot(env) {
pipeline,
recent: {
cluster_repair: clusterRepair,
+ apply_health: applyHealth,
automerge: automerge.items,
closed_items: closed.items,
closed_stats: closed.stats,
@@ -3527,6 +3536,131 @@ async function clusterRepairStatus(env, repo, targetRepos, activeRuns) {
};
}
+async function applyHealthStatus(env, targetRepos) {
+ const items = await Promise.all(
+ targetRepos.map((targetRepo) => readApplyHealthMarker(env, targetRepo)),
+ );
+ const attention = items.filter((item) => applyHealthNeedsAttention(item.status));
+ return {
+ items,
+ attention_count: attention.length,
+ latest_attention_at: latestIso(attention.map((item) => item.updated_at)),
+ };
+}
+
+async function readApplyHealthMarker(env, targetRepo) {
+ const stateRepo = String(env.CLAWSWEEPER_STATE_REPO || CLAWSWEEPER_STATE_REPO);
+ const stateRef = String(env.CLAWSWEEPER_STATE_REF || CLAWSWEEPER_STATE_REF);
+ const repoSlug = String(targetRepo || "").replace(/\//g, "-");
+ const statusPath = `results/sweep-status/${repoSlug}.json`;
+ try {
+ const content = await githubJson(
+ env,
+ `/repos/${stateRepo}/contents/${githubPath(statusPath)}?ref=${encodeURIComponent(stateRef)}`,
+ );
+ const status = parseJsonObject(decodeGithubContent(content?.content)) || {};
+ const health = objectValue(status.apply_health);
+ const skipReasons = numericRecord(health.skip_reasons);
+ const cursor = objectValue(health.cursor);
+ return {
+ target_repo: nullableString(status.target_repo) || targetRepo,
+ status_path: statusPath,
+ state: nullableString(status.state),
+ detail: nullableString(status.detail),
+ run_url: nullableString(status.run_url),
+ updated_at: nullableString(health.generated_at) || nullableString(status.updated_at),
+ mode: nullableString(health.mode),
+ status: nullableString(health.status) || "unavailable",
+ summary: nullableString(health.summary),
+ processed: numberOrNull(health.processed),
+ processed_limit: numberOrNull(health.processed_limit),
+ close_limit: numberOrNull(health.close_limit),
+ closed: numberOrNull(health.closed),
+ comment_synced: numberOrNull(health.comment_synced),
+ skipped: numberOrNull(health.skipped),
+ skip_reasons: skipReasons,
+ cursor_required: health.cursor_required === true,
+ attention_reasons: Array.isArray(health.attention_reasons)
+ ? health.attention_reasons
+ .map((reason) => String(reason))
+ .filter(Boolean)
+ .slice(0, 8)
+ : [],
+ cursor: cursor.next_after_number
+ ? {
+ next_after_number: numberOrNull(cursor.next_after_number),
+ next_after_apply_checked_at: nullableString(cursor.next_after_apply_checked_at),
+ updated_at: nullableString(cursor.updated_at),
+ }
+ : null,
+ };
+ } catch {
+ return {
+ target_repo: targetRepo,
+ status_path: statusPath,
+ state: null,
+ detail: null,
+ run_url: null,
+ updated_at: null,
+ mode: null,
+ status: "unavailable",
+ summary: null,
+ processed: null,
+ processed_limit: null,
+ close_limit: null,
+ closed: null,
+ comment_synced: null,
+ skipped: null,
+ skip_reasons: {},
+ cursor_required: false,
+ attention_reasons: [],
+ cursor: null,
+ };
+ }
+}
+
+function emptyApplyHealthStatus(targetRepos) {
+ return {
+ items: targetRepos.map((targetRepo) => ({
+ target_repo: targetRepo,
+ status_path: `results/sweep-status/${String(targetRepo || "").replace(/\//g, "-")}.json`,
+ status: "unavailable",
+ updated_at: null,
+ skip_reasons: {},
+ cursor_required: false,
+ attention_reasons: [],
+ cursor: null,
+ })),
+ attention_count: 0,
+ latest_attention_at: null,
+ };
+}
+
+function applyHealthNeedsAttention(status) {
+ return ["attention", "blocked", "degraded", "failed", "needs_attention", "warning"].includes(
+ String(status || "").toLowerCase(),
+ );
+}
+
+function latestIso(values) {
+ const timestamps = values
+ .map((value) => Date.parse(value || ""))
+ .filter((value) => Number.isFinite(value));
+ if (!timestamps.length) return null;
+ return new Date(Math.max(...timestamps)).toISOString();
+}
+
+function numericRecord(value) {
+ const record = objectValue(value);
+ return Object.fromEntries(
+ Object.entries(record)
+ .map(([key, count]) => ({ key, count: numberOrNull(count) }))
+ .filter((entry) => entry.count !== null && entry.count > 0)
+ .sort((left, right) => left.key.localeCompare(right.key))
+ .map((entry) => [entry.key, entry.count]),
+ );
+}
+
async function readClusterRepairMarker(env, targetRepo) {
const stateRepo = String(env.CLAWSWEEPER_STATE_REPO || CLAWSWEEPER_STATE_REPO);
const stateRef = String(env.CLAWSWEEPER_STATE_REF || CLAWSWEEPER_STATE_REF);
@@ -5717,6 +5851,63 @@ h2 {
.status-dot.waiting { background: var(--amber); }
.status-dot.done { background: var(--green); }
.status-dot.failed { background: var(--red); }
+.apply-health-alert {
+ display: grid;
+ gap: 8px;
+ margin-top: 14px;
+ padding: 11px 12px;
+ border: 1px solid rgba(243,183,89,0.5);
+ border-left: 3px solid var(--amber);
+ border-radius: 8px;
+ background: rgba(243,183,89,0.08);
+}
+.apply-health-heading {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+.apply-health-heading strong { color: #ffe0a8; }
+.apply-health-alert p { margin: 0; color: var(--muted); }
+.apply-health-next strong { color: var(--text); }
+.apply-health-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+.apply-health-meta .pill {
+ min-height: 22px;
+ padding: 2px 8px;
+ font-size: 11px;
+}
+.apply-health-reason {
+ cursor: help;
+ border-color: rgba(243,183,89,0.35);
+}
+.apply-health-action {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto auto;
+ gap: 6px;
+ align-items: center;
+}
+.apply-health-command {
+ min-width: 0;
+ padding: 5px 8px;
+ color: #dce7f5;
+ overflow-wrap: anywhere;
+ white-space: normal;
+ background: rgba(6,10,15,0.45);
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ line-height: 1.45;
+}
+.apply-health-copy {
+ min-height: 27px;
+}
+@media (max-width: 740px) {
+ .apply-health-action { grid-template-columns: 1fr; }
+}
.automatic-head { margin-top: 24px; }
.automatic-grid {
display: grid;
@@ -6224,6 +6415,7 @@ a:hover { color: #89c8ff; text-decoration: underline; }
waiting
available
+
Automatic Builds
@@ -6596,6 +6788,7 @@ function renderDashboard(data, note) {
metric("🎯 Capacity", fleet.budget_used_percent + "%", "fleet utilization", fleet.budget_used_percent, "var(--green)")
].join("");
renderSystemMap(data);
+ renderApplyHealth(data);
renderAutomaticWork(data.automatic_work || []);
renderWorkers(data.workers || []);
openWorkerFromHash();
@@ -6608,6 +6801,225 @@ function renderDashboard(data, note) {
renderOperations(data.recent.operation_counts);
renderEvents(data.recent.events || []);
}
+function renderApplyHealth(data) {
+ const target = document.getElementById("apply-health");
+ if (!target) return;
+ const items = (data.recent?.apply_health?.items || []).filter(item => applyHealthNeedsAttention(item.status));
+ if (!items.length) {
+ target.innerHTML = "";
+ return;
+ }
+ target.innerHTML = items.map(item => {
+ const topReason = applyHealthPrimaryReason(item);
+ const topInfo = applyHealthReasonInfo(topReason);
+ const action = applyHealthRecommendedAction(item, topReason);
+ const reasons = applyHealthReasonEntries(item)
+ .slice(0, 4)
+ .map(([reason, count]) => applyHealthReasonPill(reason, count))
+ .join("");
+ const showCursor = item.cursor_required || Boolean(item.cursor?.next_after_number);
+ const cursor = item.cursor?.next_after_number ? "cursor #" + item.cursor.next_after_number : "cursor missing";
+ const cursorTitle = item.cursor?.next_after_number
+ ? "Rotation cursor was recorded; the next pruning run should continue after this item."
+ : "No rotation cursor was recorded. If this was a full scan window, the next pruning run can repeat the same records.";
+ const cursorPill = showCursor
+ ? '
' + esc(cursor) + ''
+ : "";
+ const processed = Number.isFinite(item.processed) ? fmt.format(item.processed) : "unknown";
+ const closed = Number.isFinite(item.closed) ? fmt.format(item.closed) : "unknown";
+ const synced = Number.isFinite(item.comment_synced) ? fmt.format(item.comment_synced) : "unknown";
+ return '
' +
+ '
Pruning sweep ' + esc(applyHealthStatusLabel(item.status)) + " - " + esc(item.target_repo || "target repo") + '' + esc(applyHealthModeLabel(item.mode)) + '
' +
+ '
' + esc(applyHealthOperatorSummary(item, topInfo)) + '
' +
+ '
Next check: ' + esc(topInfo.action) + '
' +
+ applyHealthActionHtml(action) +
+ '
' + esc(processed) + ' processed' + esc(closed) + ' closed' + esc(synced) + ' comments synced' + cursorPill + reasons + linkClass(item.run_url, "workflow run", "pill run-link") + '
';
+ }).join("");
+}
+function applyHealthNeedsAttention(status) {
+ return ["attention", "blocked", "degraded", "failed", "needs_attention", "warning"].includes(String(status || "").toLowerCase());
+}
+function applyHealthStatusLabel(status) {
+ const value = String(status || "").toLowerCase();
+ if (value === "failed") return "failed";
+ if (value === "degraded" || value === "warning" || value === "attention") return "degraded";
+ return "blocked";
+}
+function applyHealthModeLabel(mode) {
+ const value = String(mode || "").toLowerCase();
+ if (value === "comment_sync") return "comment-sync lane";
+ if (value === "close") return "close lane";
+ return "pruning lane";
+}
+function applyHealthReasonEntries(item) {
+ const entries = [];
+ const seen = new Set();
+ const skipReasons = item.skip_reasons || {};
+ for (const reason of item.attention_reasons || []) {
+ if (!reason || seen.has(reason)) continue;
+ seen.add(reason);
+ const skipCount = skipReasons[reason];
+ entries.push([reason, Number.isFinite(skipCount) ? skipCount : null]);
+ }
+ for (const entry of Object.entries(skipReasons).sort((left, right) => Number(right[1]) - Number(left[1]))) {
+ if (seen.has(entry[0])) continue;
+ seen.add(entry[0]);
+ entries.push(entry);
+ }
+ return entries;
+}
+function applyHealthPrimaryReason(item) {
+ return applyHealthReasonEntries(item)[0]?.[0] || item.status || "";
+}
+function applyHealthReasonPill(reason, count) {
+ const info = applyHealthReasonInfo(reason);
+ const countText = Number.isFinite(count) ? " " + fmt.format(count) : "";
+ return '
' + esc(info.label + countText) + '';
+}
+function applyHealthActionHtml(action) {
+ if (!action) return "";
+ const command = action.command || "";
+ const commandHtml = command
+ ? '
' + esc(command) + ''
+ : '
' + esc(action.detail || "No safe automatic action is available from the dashboard.") + '';
+ return '
' + commandHtml + linkClass(action.url, action.linkLabel || "open workflow", "pill run-link") + '
';
+}
+function applyHealthRecommendedAction(item, reason) {
+ const targetRepo = String(item.target_repo || "openclaw/openclaw");
+ const mode = String(item.mode || "").toLowerCase();
+ const workflowUrl = "https://github.com/openclaw/clawsweeper/actions/workflows/sweep.yml";
+ if (reason === "cursor_required_but_missing_after_full_window") {
+ return {
+ title: "Maintainer action: inspect the current run before rerunning, because a missing cursor can make the next run repeat the same window.",
+ detail: "Inspect the cursor-write and state-publish steps; rerun only after the cursor write failure is understood.",
+ url: item.run_url || workflowUrl,
+ linkLabel: item.run_url ? "open run" : "open workflow",
+ };
+ }
+ if (reason === "skipped_changed_since_review") {
+ return {
+ title: "Maintainer action: refresh review records before trying to close changed items.",
+ command: "gh workflow run sweep.yml --repo openclaw/clawsweeper -f target_repo=" + targetRepo + " -f apply_existing=false",
+ url: workflowUrl,
+ linkLabel: "open workflow",
+ };
+ }
+ if (reason === "skipped_pr_close_coverage_proof") {
+ return {
+ title: "Maintainer action: add close-coverage proof before retrying PR pruning.",
+ detail: "Add or refresh close-coverage proof, then rerun the close lane.",
+ url: item.run_url || workflowUrl,
+ linkLabel: item.run_url ? "open run" : "open workflow",
+ };
+ }
+ if (mode === "comment_sync") {
+ return {
+ title: "Maintainer action: run the next comment-sync cursor window. GitHub permissions control who can run it.",
+ command: "gh workflow run sweep.yml --repo openclaw/clawsweeper -f target_repo=" + targetRepo + " -f apply_existing=true -f apply_sync_comments_only=true -f apply_item_numbers=__cursor__ -f apply_limit=25",
+ url: workflowUrl,
+ linkLabel: "open workflow",
+ };
+ }
+ const closeLimit = Number.isFinite(item.close_limit) && item.close_limit > 0 ? item.close_limit : 5;
+ return {
+ title: "Maintainer action: rerun the bounded close lane. GitHub permissions control who can run it.",
+ command: "gh workflow run sweep.yml --repo openclaw/clawsweeper -f target_repo=" + targetRepo + " -f apply_existing=true -f apply_limit=" + closeLimit + " -f apply_kind=all -f apply_close_reasons=all",
+ url: workflowUrl,
+ linkLabel: "open workflow",
+ };
+}
+function applyHealthReasonInfo(reason) {
+ const value = String(reason || "");
+ if (value === "cursor_required_but_missing_after_full_window") {
+ return {
+ label: "Rotation cursor missing",
+ summary: "The pruning sweep processed the full bounded window but did not publish the next cursor.",
+ action: "Open the workflow run and check the cursor-write step; until the cursor is written, the next run can repeat this window.",
+ };
+ }
+ if (value === "skipped_runtime_budget") {
+ return {
+ label: "Runtime budget hit",
+ summary: "The workflow stopped processing because it reached its bounded runtime.",
+ action: "Let the next scheduled sweep continue; if this repeats, reduce the batch size or raise the apply runtime budget.",
+ };
+ }
+ if (value === "skipped_live_fetch_failed") {
+ return {
+ label: "GitHub live check failed",
+ summary: "ClawSweeper could not confirm live GitHub state before mutating an item.",
+ action: "Inspect the workflow run for GitHub API, auth, or rate-limit failures, then rerun after live checks recover.",
+ };
+ }
+ if (value === "skipped_changed_since_review") {
+ return {
+ label: "Changed since review",
+ summary: "The item changed after the ClawSweeper review that proposed the close.",
+ action: "Refresh the ClawSweeper review for those items before closing; this skip is a safety guard.",
+ };
+ }
+ if (value === "skipped_pr_close_coverage_proof") {
+ return {
+ label: "PR close proof needed",
+ summary: "The PR needs coverage proof before ClawSweeper can close it as duplicate or superseded.",
+ action: "Add or refresh close-coverage proof, then rerun the sweep.",
+ };
+ }
+ if (value === "skipped_open_closing_pr") {
+ return {
+ label: "Closing PR still open",
+ summary: "The issue appears covered by an open pull request, so ClawSweeper avoided closing it early.",
+ action: "Review or land the linked closing PR before expecting the issue to close.",
+ };
+ }
+ if (value === "skipped_maintainer_authored") {
+ return {
+ label: "Maintainer-authored item",
+ summary: "Automation will not close this maintainer-authored item without human review.",
+ action: "Have a maintainer decide whether to close it manually or update the review policy.",
+ };
+ }
+ if (value === "skipped_policy_exempt" || value === "skipped_protected_label") {
+ return {
+ label: "Policy-protected item",
+ summary: "A label or policy exemption blocked automated pruning.",
+ action: "Check the policy or label before taking manual action.",
+ };
+ }
+ if (value === "skipped_not_open" || value === "skipped_already_closed" || value === "skipped_closed") {
+ return {
+ label: "Already closed",
+ summary: "The item was no longer open by the time ClawSweeper checked it.",
+ action: "No action is usually needed; investigate only if already-closed records dominate repeated runs.",
+ };
+ }
+ return {
+ label: applyHealthReasonLabel(value || "blocked_condition"),
+ summary: "ClawSweeper reported this skip bucket while checking whether it could safely prune an item.",
+ action: "Open the workflow run and inspect this skip bucket before rerunning or changing limits.",
+ };
+}
+function applyHealthReasonLabel(reason) {
+ return String(reason || "")
+ .replace(/^skipped_/, "")
+ .replace(/_/g, " ")
+ .replace(/\\b\\w/g, letter => letter.toUpperCase());
+}
+function applyHealthOperatorSummary(item, reasonInfo) {
+ const processed = applyHealthCount(item.processed, "record", "records");
+ const skipped = Number.isFinite(item.skipped) ? "; " + applyHealthCount(item.skipped, "record", "records") + " skipped" : "";
+ const closed = Number.isFinite(item.closed) ? item.closed : 0;
+ const synced = Number.isFinite(item.comment_synced) ? item.comment_synced : 0;
+ const useful = closed + synced;
+ const result = useful > 0
+ ? "ClawSweeper processed " + processed + " and completed " + applyHealthCount(useful, "close/comment update", "close/comment updates")
+ : "ClawSweeper processed " + processed + " without closing or syncing anything";
+ return result + skipped + ". Main signal: " + reasonInfo.label + ".";
+}
+function applyHealthCount(value, singular, plural) {
+ if (!Number.isFinite(value)) return "unknown " + plural;
+ return fmt.format(value) + " " + (value === 1 ? singular : plural);
+}
function renderPipeline(rows) {
if (!rows.length) {
document.getElementById("pipeline").innerHTML = '
All quiet in the depths... no active sweeps
';
@@ -6701,6 +7113,21 @@ document.getElementById("automatic-work").addEventListener("click", event => {
const row = automaticIndex.get(String(button.dataset.automaticId));
if (row) renderAutomaticDialog(row);
});
+document.addEventListener("click", event => {
+ const button = event.target.closest("button[data-copy-command]");
+ if (!button) return;
+ const command = String(button.dataset.copyCommand || "");
+ if (!command) return;
+ const copied = navigator.clipboard?.writeText(command);
+ if (!copied) return;
+ copied.then(() => {
+ const original = button.textContent;
+ button.textContent = "Copied";
+ setTimeout(() => {
+ button.textContent = original || "Copy command";
+ }, 1500);
+ }).catch(() => undefined);
+});
document.getElementById("worker-dialog-close").addEventListener("click", closeWorkerDialog);
document.getElementById("worker-dialog").addEventListener("click", event => {
const linkedWorker = event.target.closest("button[data-linked-worker-id]");
diff --git a/docs/live-dashboard.md b/docs/live-dashboard.md
index 94527ed7b4..6d96e6bb50 100644
--- a/docs/live-dashboard.md
+++ b/docs/live-dashboard.md
@@ -125,6 +125,9 @@ is absent or a cache event lands in another Cloudflare colo.
- recent automerge command-to-merge timing samples
- explicit workflow status events posted to the ingest API when KV ingest is
enabled
+- problem-focused pruning alerts from latest sweep status files when apply runs
+ report blocked or degraded progress, with reason tooltips and maintainer
+ workflow commands for safe follow-up
The Worker fetches job details only for the bounded active-run set, limits that
GitHub fanout to 12 concurrent requests, and caches each run's jobs for 60
diff --git a/docs/proof/pr-391/apply-health-alert.png b/docs/proof/pr-391/apply-health-alert.png
new file mode 100644
index 0000000000..51d2080ce6
Binary files /dev/null and b/docs/proof/pr-391/apply-health-alert.png differ
diff --git a/docs/proof/pr-391/apply-health-quiet.png b/docs/proof/pr-391/apply-health-quiet.png
new file mode 100644
index 0000000000..905ca2a40c
Binary files /dev/null and b/docs/proof/pr-391/apply-health-quiet.png differ
diff --git a/src/clawsweeper.ts b/src/clawsweeper.ts
index b57f99873c..aa6dc6dcd1 100644
--- a/src/clawsweeper.ts
+++ b/src/clawsweeper.ts
@@ -759,6 +759,7 @@ interface WorkflowStatusSummary {
state: string;
detail: string;
runUrl: string | undefined;
+ applyHealth: Record
| undefined;
plannedCount: number | undefined;
plannedCapacity: number | undefined;
plannedShards: number | undefined;
@@ -1918,9 +1919,14 @@ function writeSweepStatus(options: {
failedReviewRetryExhaustions?: number;
botOwnedProofDecisionsRequested?: number;
botOwnedProofDispatches?: number;
+ applyHealth?: Record | null;
}): void {
const profile = options.profile ?? targetProfile();
const updatedAt = new Date().toISOString();
+ const applyHealth =
+ options.applyHealth === undefined
+ ? readSweepStatusSummary(profile)?.applyHealth
+ : options.applyHealth;
const payload = {
schema_version: 1,
slug: profile.slug,
@@ -1942,6 +1948,7 @@ function writeSweepStatus(options: {
failed_review_retry_exhaustions: options.failedReviewRetryExhaustions ?? null,
bot_owned_proof_decisions_requested: options.botOwnedProofDecisionsRequested ?? null,
bot_owned_proof_dispatches: options.botOwnedProofDispatches ?? null,
+ apply_health: applyHealth ?? null,
updated_at: updatedAt,
};
const outputPath = sweepStatusPath(profile);
@@ -8286,6 +8293,7 @@ function readSweepStatusSummary(profile = targetProfile()): WorkflowStatusSummar
state: stringOrUndefined(parsed.state) ?? "Idle",
detail: stringOrUndefined(parsed.detail) ?? "No workflow status has been published yet.",
runUrl: stringOrUndefined(parsed.run_url),
+ applyHealth: recordOrUndefined(parsed.apply_health),
plannedCount: numberOrUndefined(parsed.planned_count),
plannedCapacity: numberOrUndefined(parsed.planned_capacity),
plannedShards: numberOrUndefined(parsed.planned_shards),
@@ -8316,6 +8324,12 @@ function numberOrUndefined(value: unknown): number | undefined {
return Number.isFinite(number) ? number : undefined;
}
+function recordOrUndefined(value: unknown): Record | undefined {
+ return value && typeof value === "object" && !Array.isArray(value)
+ ? (value as Record)
+ : undefined;
+}
+
function currentWorkflowStatusBlock(readme: string, profile = targetProfile()): string {
const statusSummary = readSweepStatusSummary(profile);
if (statusSummary) return workflowStatusBlock({ ...statusSummary, profile });
@@ -8373,6 +8387,7 @@ function workflowStatusSummary(block: string): WorkflowStatusSummary {
state,
detail,
runUrl,
+ applyHealth: undefined,
plannedCount: numberOrUndefined(planMatch?.[1]),
plannedShards: numberOrUndefined(planMatch?.[2]),
plannedCapacity: numberOrUndefined(planMatch?.[3]),
@@ -20386,6 +20401,13 @@ function statusCommand(args: Args): void {
args.bot_owned_proof_decisions_requested,
);
const botOwnedProofDispatches = optionalNumberArg(args.bot_owned_proof_dispatches);
+ const applyHealthArg = applyHealthStatusArg(args);
+ const applyHealth =
+ applyHealthArg === undefined
+ ? state.startsWith("Apply ")
+ ? null
+ : readSweepStatusSummary(profile)?.applyHealth
+ : applyHealthArg;
const statusOptions: Parameters[0] = {
state,
detail,
@@ -20410,10 +20432,26 @@ function statusCommand(args: Args): void {
statusOptions.botOwnedProofDecisionsRequested = botOwnedProofDecisionsRequested;
if (botOwnedProofDispatches !== undefined)
statusOptions.botOwnedProofDispatches = botOwnedProofDispatches;
+ if (applyHealth !== undefined) statusOptions.applyHealth = applyHealth;
writeSweepStatus(statusOptions);
console.log(JSON.stringify({ status_path: sweepStatusRelativePath(profile), state, detail }));
}
+function applyHealthStatusArg(args: Args): Record | undefined {
+ const filePath = stringArg(args.apply_health_file, "");
+ const jsonText = stringArg(args.apply_health_json, "");
+ if (filePath && jsonText) {
+ throw new Error("--apply-health-file and --apply-health-json are mutually exclusive");
+ }
+ const text = filePath ? readFileSync(resolve(filePath), "utf8") : jsonText;
+ if (!text.trim()) return undefined;
+ const parsed = JSON.parse(text) as unknown;
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ throw new Error("apply health status must be a JSON object");
+ }
+ return parsed as Record;
+}
+
function assistCommand(args: Args): void {
repoFromArgs(args);
const itemNumber = numberArg(args.item_number, 0);
diff --git a/src/repair/workflow-utils.ts b/src/repair/workflow-utils.ts
index 4ba5f207a4..e9c01b6e14 100644
--- a/src/repair/workflow-utils.ts
+++ b/src/repair/workflow-utils.ts
@@ -10,6 +10,41 @@ import { AUTOMATION_LIMITS, WORKER_CONFIG, workerLimit, type WorkerLane } from "
type ApplyAction = {
action: string;
number?: number;
+ reason?: string;
+};
+
+type ApplyReportSummaryOptions = {
+ reportPath: string;
+ targetRepo: string;
+ mode: string;
+ processedLimit: number;
+ closeLimit: number | null;
+ cursorPath: string;
+ cursorRequired: boolean;
+};
+
+type ApplyReportSummary = {
+ schema_version: 1;
+ generated_at: string;
+ target_repo: string;
+ mode: string;
+ status: "ok" | "idle" | "needs_attention";
+ summary: string;
+ processed: number;
+ processed_limit: number | null;
+ close_limit: number | null;
+ closed: number;
+ comment_synced: number;
+ skipped: number;
+ skip_reasons: Record;
+ attention_reasons: string[];
+ cursor_required: boolean;
+ cursor: {
+ path: string;
+ next_after_number: number;
+ next_after_apply_checked_at: string | null;
+ updated_at: string | null;
+ } | null;
};
const args = parseArgs(process.argv.slice(2));
@@ -37,6 +72,23 @@ function runCli(): void {
case "count-actions":
console.log(countActions(requiredString("report"), requiredString("action")));
break;
+ case "summarize-apply-report":
+ process.stdout.write(
+ `${JSON.stringify(
+ summarizeApplyReport({
+ reportPath: requiredString("report"),
+ targetRepo: requiredString("target-repo"),
+ mode: optionalString("mode") || "close",
+ processedLimit: numberArg("processed-limit", 0),
+ closeLimit: optionalString("close-limit") ? numberArg("close-limit", 0) : null,
+ cursorPath: optionalString("cursor-path"),
+ cursorRequired: booleanArg("cursor-required", false),
+ }),
+ null,
+ 2,
+ )}\n`,
+ );
+ break;
case "count-command-actions":
console.log(
countCommandActions(
@@ -227,6 +279,110 @@ export function countActions(reportPath: string, action: string): number {
return readApplyActions(reportPath).filter((entry) => entry.action === action).length;
}
+export function summarizeApplyReport(options: ApplyReportSummaryOptions): ApplyReportSummary {
+ const actions = readApplyActions(options.reportPath);
+ const skipReasons: Record = {};
+ let closed = 0;
+ let commentSynced = 0;
+ let skipped = 0;
+ for (const entry of actions) {
+ if (entry.action === "closed") closed += 1;
+ if (reportsReviewCommentSync(entry)) commentSynced += 1;
+ const productive =
+ entry.action === "closed" ||
+ entry.action === "review_comment_synced" ||
+ (entry.action === "kept_open" && isSuccessfulLabelSyncReason(entry.reason));
+ if (!productive) {
+ skipped += 1;
+ skipReasons[entry.action] = (skipReasons[entry.action] || 0) + 1;
+ }
+ }
+
+ const cursor = readApplyCursorForSummary(options.cursorPath);
+ const processedLimit = options.processedLimit > 0 ? options.processedLimit : null;
+ const attentionReasons: string[] = [];
+ if (
+ options.cursorRequired &&
+ processedLimit !== null &&
+ actions.length >= processedLimit &&
+ !cursor
+ ) {
+ attentionReasons.push("cursor_required_but_missing_after_full_window");
+ }
+ for (const reason of ["skipped_runtime_budget", "skipped_live_fetch_failed"]) {
+ if ((skipReasons[reason] || 0) > 0) attentionReasons.push(reason);
+ }
+ if (actions.length > 0 && skipped === actions.length) {
+ const benignSkipReasons = new Set([
+ "skipped_already_closed",
+ "skipped_closed",
+ "skipped_not_open",
+ ]);
+ for (const reason of Object.keys(skipReasons).sort()) {
+ if (!benignSkipReasons.has(reason) && !attentionReasons.includes(reason)) {
+ attentionReasons.push(reason);
+ }
+ }
+ }
+
+ const status =
+ actions.length === 0 ? "idle" : attentionReasons.length > 0 ? "needs_attention" : "ok";
+ const summary = applyReportHealthSummary({
+ status,
+ processed: actions.length,
+ processedLimit,
+ closed,
+ commentSynced,
+ skipped,
+ cursor,
+ attentionReasons,
+ });
+
+ return {
+ schema_version: 1,
+ generated_at: new Date().toISOString(),
+ target_repo: options.targetRepo,
+ mode: options.mode,
+ status,
+ summary,
+ processed: actions.length,
+ processed_limit: processedLimit,
+ close_limit: options.closeLimit,
+ closed,
+ comment_synced: commentSynced,
+ skipped,
+ skip_reasons: Object.fromEntries(
+ Object.entries(skipReasons).sort(([left], [right]) => left.localeCompare(right)),
+ ),
+ attention_reasons: attentionReasons,
+ cursor_required: options.cursorRequired,
+ cursor,
+ };
+}
+
+function applyReportHealthSummary(options: {
+ status: ApplyReportSummary["status"];
+ processed: number;
+ processedLimit: number | null;
+ closed: number;
+ commentSynced: number;
+ skipped: number;
+ cursor: ApplyReportSummary["cursor"];
+ attentionReasons: string[];
+}): string {
+ if (options.status === "idle") return "Apply processed no records in this run.";
+ const budget =
+ options.processedLimit === null
+ ? `${options.processed} processed`
+ : `${options.processed}/${options.processedLimit} processed`;
+ const cursorText = options.cursor
+ ? `cursor at #${options.cursor.next_after_number}`
+ : "no cursor recorded";
+ const base = `${budget}; ${options.closed} closed, ${options.commentSynced} comments synced, ${options.skipped} skipped; ${cursorText}.`;
+ if (options.attentionReasons.length === 0) return base;
+ return `${base} Attention: ${options.attentionReasons.join(", ")}.`;
+}
+
export function countCommandActions(reportPath: string, action: string, status = ""): number {
const report = readJsonObject(reportPath);
const commands: JsonValue[] = Array.isArray(report.commands) ? report.commands : [];
@@ -574,6 +730,7 @@ export function writeCommentSyncCursor(
type ApplyCursor = {
applyCheckedAt: string;
number: number;
+ updatedAt: string | null;
};
function readApplyCursor(cursorPath: string): ApplyCursor | null {
@@ -586,7 +743,20 @@ function readApplyCursor(cursorPath: string): ApplyCursor | null {
typeof parsed.next_after_apply_checked_at === "string"
? parsed.next_after_apply_checked_at
: "";
- return { number, applyCheckedAt };
+ const updatedAt = typeof parsed.updated_at === "string" ? parsed.updated_at : null;
+ return { number, applyCheckedAt, updatedAt };
+}
+
+function readApplyCursorForSummary(cursorPath: string): ApplyReportSummary["cursor"] {
+ if (!cursorPath) return null;
+ const cursor = readApplyCursor(cursorPath);
+ if (!cursor) return null;
+ return {
+ path: cursorPath,
+ next_after_number: cursor.number,
+ next_after_apply_checked_at: cursor.applyCheckedAt || null,
+ updated_at: cursor.updatedAt,
+ };
}
export function writeApplyCursor(
@@ -707,12 +877,27 @@ function readApplyActions(reportPath: string): ApplyAction[] {
if (!Array.isArray(parsed)) throw new Error(`${reportPath} must contain an array`);
return parsed.map((entry) => {
if (!isJsonObject(entry) || typeof entry.action !== "string") return { action: "" };
+ const action: ApplyAction = { action: entry.action };
+ if (typeof entry.reason === "string") action.reason = entry.reason;
const number = Number(entry.number);
- if (!Number.isInteger(number) || number <= 0) return { action: entry.action };
- return { action: entry.action, number };
+ if (Number.isInteger(number) && number > 0) action.number = number;
+ return action;
});
}
+function isSuccessfulLabelSyncReason(reason: string | undefined): boolean {
+ return /^(?:synced|dry-run: would sync) (?:advisory issue|ClawSweeper) labels$/.test(
+ reason || "",
+ );
+}
+
+function reportsReviewCommentSync(entry: ApplyAction): boolean {
+ return (
+ entry.action === "review_comment_synced" ||
+ (entry.reason || "").split("; ").includes("updated durable Codex review comment")
+ );
+}
+
function resultFiles(reportDir: string): string[] {
if (!fs.existsSync(reportDir)) return [];
return fs
@@ -762,6 +947,14 @@ function numberArg(name: string, fallback: number): number {
return parsed;
}
+function booleanArg(name: string, fallback: boolean): boolean {
+ const value = optionalString(name).toLowerCase();
+ if (!value) return fallback;
+ if (["1", "true", "yes", "on"].includes(value)) return true;
+ if (["0", "false", "no", "off"].includes(value)) return false;
+ throw new Error(`--${name} must be boolean`);
+}
+
function positiveNumber(value: string, fallback: number): number {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
diff --git a/test/clawsweeper.test.ts b/test/clawsweeper.test.ts
index 18bc50ce77..c23b411bdf 100644
--- a/test/clawsweeper.test.ts
+++ b/test/clawsweeper.test.ts
@@ -1988,6 +1988,16 @@ test("sweep dashboard status writes are scoped to the target repository", () =>
}
});
+test("sweep status writer preserves non-apply health and clears stale apply updates", () => {
+ const source = readText("src/clawsweeper.ts");
+
+ assert.match(
+ source,
+ /state\.startsWith\("Apply "\)\s+\?\s+null\s+:\s+readSweepStatusSummary\(profile\)\?\.applyHealth/,
+ );
+ assert.match(source, /apply_health: applyHealth \?\? null/);
+});
+
test("review parser strips environment access caveats from risks", () => {
const parsed = parseDecision(
closeDecision({
diff --git a/test/dashboard-worker.test.ts b/test/dashboard-worker.test.ts
index b81957dc7c..5173cf0680 100644
--- a/test/dashboard-worker.test.ts
+++ b/test/dashboard-worker.test.ts
@@ -746,6 +746,15 @@ test("dashboard HTML preserves UTF-8 emoji labels", async () => {
assert.match(html, /href="https:\/\/fleet\.example\.test\/terminal\?view=live&mode=all"/);
assert.match(html, /🌊 Loading pipeline state/);
assert.match(html, /System Overview/);
+ assert.match(html, /id="apply-health"/);
+ assert.match(html, /function renderApplyHealth/);
+ assert.match(html, /Pruning sweep/);
+ assert.match(html, /Copy command/);
+ assert.match(html, /applyHealthRecommendedAction/);
+ assert.match(html, /Rotation cursor missing/);
+ assert.match(html, /Inspect the cursor-write and state-publish steps/);
+ assert.match(html, /const skipCount = skipReasons\[reason\]/);
+ assert.doesNotMatch(html, /Apply needs attention/);
assert.match(html, /Automatic Builds/);
assert.match(html, /id="automatic-work"/);
assert.match(html, /Lifecycle Timeline/);
@@ -1484,6 +1493,89 @@ test("dashboard exposes scheduled cluster intake markers and runs", async () =>
}
});
+test("dashboard exposes apply health from sweep status without broad scans", async () => {
+ const originalFetch = globalThis.fetch;
+ const originalCaches = globalThis.caches;
+ Object.defineProperty(globalThis, "caches", {
+ configurable: true,
+ value: {
+ default: {
+ match: async () => undefined,
+ put: async () => undefined,
+ },
+ },
+ });
+ const sweepStatus = {
+ target_repo: "openclaw/openclaw",
+ state: "Apply finished",
+ run_url: "https://github.com/openclaw/clawsweeper/actions/runs/99",
+ updated_at: "2026-07-03T10:15:00Z",
+ apply_health: {
+ mode: "close",
+ status: "needs_attention",
+ summary: "2/2 processed; 0 closed, 0 comments synced, 2 skipped; no cursor recorded.",
+ processed: 2,
+ processed_limit: 2,
+ close_limit: 5,
+ closed: 0,
+ comment_synced: 0,
+ skipped: 2,
+ cursor_required: true,
+ skip_reasons: {
+ skipped_changed_since_review: 2,
+ },
+ attention_reasons: ["cursor_required_but_missing_after_full_window"],
+ cursor: null,
+ },
+ };
+ globalThis.fetch = async (input) => {
+ const url = new URL(String(input));
+ if (url.pathname === "/repos/openclaw/clawsweeper/actions/runs") {
+ return jsonResponse({ workflow_runs: [] });
+ }
+ if (
+ url.pathname ===
+ "/repos/openclaw/clawsweeper/actions/workflows/repair-cluster-intake.yml/runs"
+ ) {
+ return jsonResponse({ workflow_runs: [] });
+ }
+ if (
+ url.pathname ===
+ "/repos/openclaw/clawsweeper-state/contents/results/sweep-status/openclaw-openclaw.json"
+ ) {
+ assert.equal(url.searchParams.get("ref"), "state");
+ return jsonResponse({
+ content: Buffer.from(JSON.stringify(sweepStatus)).toString("base64"),
+ });
+ }
+ if (url.pathname === "/search/issues") return jsonResponse({ items: [] });
+ if (url.pathname === "/repos/openclaw/openclaw/issues") return jsonResponse([]);
+ throw new Error(`unexpected fetch ${url}`);
+ };
+
+ try {
+ const response = await worker.fetch(new Request("https://clawsweeper.openclaw.ai/api/status"), {
+ STATUS_STORE: new MemoryKv(),
+ CLAWSWEEPER_REPO: "openclaw/clawsweeper",
+ TARGET_REPOS: "openclaw/openclaw",
+ CACHE_TTL_SECONDS: "0",
+ });
+ assert.equal(response.status, 200);
+ const status = await response.json();
+ assert.equal(status.recent.apply_health.attention_count, 1);
+ assert.equal(status.recent.apply_health.items[0].status, "needs_attention");
+ assert.equal(status.recent.apply_health.items[0].processed, 2);
+ assert.equal(status.recent.apply_health.items[0].cursor_required, true);
+ assert.deepEqual(status.recent.apply_health.items[0].skip_reasons, {
+ skipped_changed_since_review: 2,
+ });
+ assert.equal(status.recent.apply_health.items[0].cursor, null);
+ } finally {
+ globalThis.fetch = originalFetch;
+ Object.defineProperty(globalThis, "caches", { configurable: true, value: originalCaches });
+ }
+});
+
test("dashboard reads stored CI status for active PR rows", async () => {
const originalFetch = globalThis.fetch;
const originalCaches = globalThis.caches;
diff --git a/test/repair/workflow-utils.test.ts b/test/repair/workflow-utils.test.ts
index ab2cd60867..3d07418f1e 100644
--- a/test/repair/workflow-utils.test.ts
+++ b/test/repair/workflow-utils.test.ts
@@ -17,6 +17,7 @@ import {
plannedItemNumberCsv,
proposedItemNumbers,
proposedPrCloseCoverageItemNumbers,
+ summarizeApplyReport,
writeApplyCursor,
writeCommentSyncCursor,
} from "../../dist/repair/workflow-utils.js";
@@ -174,6 +175,186 @@ test("workflow utilities derive artifact item numbers and action counts", () =>
assert.equal(countActions(path.join(root, "apply-report.json"), "closed"), 1);
});
+test("workflow utilities summarize apply health with skip buckets and cursor", () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawsweeper-workflow-"));
+ const reportPath = path.join(root, "apply-report.json");
+ const cursorPath = path.join(root, "results/apply-cursors/openclaw-openclaw.json");
+ write(
+ reportPath,
+ JSON.stringify([
+ { number: 10, action: "closed" },
+ { number: 20, action: "review_comment_synced" },
+ { number: 30, action: "skipped_changed_since_review" },
+ { number: 40, action: "skipped_changed_since_review" },
+ ]),
+ );
+ write(
+ cursorPath,
+ JSON.stringify({
+ next_after_number: 40,
+ next_after_apply_checked_at: "2026-07-01T00:00:00Z",
+ updated_at: "2026-07-03T10:00:00Z",
+ }),
+ );
+
+ const summary = summarizeApplyReport({
+ reportPath,
+ targetRepo: "openclaw/openclaw",
+ mode: "close",
+ processedLimit: 300,
+ closeLimit: 5,
+ cursorPath,
+ cursorRequired: true,
+ });
+
+ assert.equal(summary.status, "ok");
+ assert.equal(summary.processed, 4);
+ assert.equal(summary.closed, 1);
+ assert.equal(summary.comment_synced, 1);
+ assert.deepEqual(summary.skip_reasons, { skipped_changed_since_review: 2 });
+ assert.equal(summary.cursor?.next_after_number, 40);
+});
+
+test("workflow utilities flag full-window close scans without the required cursor", () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawsweeper-workflow-"));
+ const reportPath = path.join(root, "apply-report.json");
+ write(
+ reportPath,
+ JSON.stringify([
+ { number: 10, action: "skipped_changed_since_review" },
+ { number: 20, action: "skipped_changed_since_review" },
+ ]),
+ );
+
+ const summary = summarizeApplyReport({
+ reportPath,
+ targetRepo: "openclaw/openclaw",
+ mode: "close",
+ processedLimit: 2,
+ closeLimit: 5,
+ cursorPath: path.join(root, "missing-cursor.json"),
+ cursorRequired: true,
+ });
+
+ assert.equal(summary.status, "needs_attention");
+ assert.deepEqual(summary.attention_reasons, [
+ "cursor_required_but_missing_after_full_window",
+ "skipped_changed_since_review",
+ ]);
+ assert.match(summary.summary, /Attention:/);
+});
+
+test("workflow utilities require the cursor after a full window that closed an item", () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawsweeper-workflow-"));
+ const reportPath = path.join(root, "apply-report.json");
+ write(
+ reportPath,
+ JSON.stringify([
+ { number: 10, action: "closed" },
+ { number: 20, action: "skipped_changed_since_review" },
+ ]),
+ );
+
+ const summary = summarizeApplyReport({
+ reportPath,
+ targetRepo: "openclaw/openclaw",
+ mode: "close",
+ processedLimit: 2,
+ closeLimit: 5,
+ cursorPath: path.join(root, "missing-cursor.json"),
+ cursorRequired: true,
+ });
+
+ assert.equal(summary.status, "needs_attention");
+ assert.equal(summary.closed, 1);
+ assert.deepEqual(summary.attention_reasons, ["cursor_required_but_missing_after_full_window"]);
+});
+
+test("workflow utilities flag operator-action skips when every result is blocked", () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawsweeper-workflow-"));
+ const reportPath = path.join(root, "apply-report.json");
+ write(
+ reportPath,
+ JSON.stringify([
+ { number: 10, action: "skipped_changed_since_review" },
+ {
+ number: 20,
+ action: "skipped_pr_close_coverage_proof",
+ reason: "close proof kept this open; updated durable Codex review comment",
+ },
+ { number: 30, action: "skipped_maintainer_authored" },
+ { number: 40, action: "skipped_invalid_decision" },
+ { number: 50, action: "skipped_open_closing_pr" },
+ { number: 60, action: "skipped_same_author_pair" },
+ { number: 70, action: "skipped_protected_label" },
+ { number: 80, action: "skipped_already_closed" },
+ {
+ number: 90,
+ action: "kept_open",
+ reason: "review lacks verified local checkout access",
+ },
+ {
+ number: 100,
+ action: "retry_pr_close_coverage_proof",
+ reason: "linked canonical PR changed after coverage proof",
+ },
+ ]),
+ );
+
+ const summary = summarizeApplyReport({
+ reportPath,
+ targetRepo: "openclaw/openclaw",
+ mode: "close",
+ processedLimit: 300,
+ closeLimit: 5,
+ cursorRequired: false,
+ });
+
+ assert.equal(summary.status, "needs_attention");
+ assert.equal(summary.comment_synced, 1);
+ assert.deepEqual(summary.attention_reasons, [
+ "kept_open",
+ "retry_pr_close_coverage_proof",
+ "skipped_changed_since_review",
+ "skipped_invalid_decision",
+ "skipped_maintainer_authored",
+ "skipped_open_closing_pr",
+ "skipped_pr_close_coverage_proof",
+ "skipped_protected_label",
+ "skipped_same_author_pair",
+ ]);
+});
+
+test("workflow utilities keep all-benign skip windows quiet", () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawsweeper-workflow-"));
+ const reportPath = path.join(root, "apply-report.json");
+ write(
+ reportPath,
+ JSON.stringify([
+ { number: 10, action: "skipped_already_closed" },
+ { number: 20, action: "skipped_not_open" },
+ { number: 30, action: "kept_open", reason: "synced ClawSweeper labels" },
+ ]),
+ );
+
+ const summary = summarizeApplyReport({
+ reportPath,
+ targetRepo: "openclaw/openclaw",
+ mode: "close",
+ processedLimit: 300,
+ closeLimit: 5,
+ cursorRequired: false,
+ });
+
+ assert.equal(summary.status, "ok");
+ assert.equal(summary.skipped, 2);
+ assert.deepEqual(summary.skip_reasons, {
+ skipped_already_closed: 1,
+ skipped_not_open: 1,
+ });
+ assert.deepEqual(summary.attention_reasons, []);
+});
+
test("workflow utilities count nested command actions by status", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawsweeper-workflow-"));
const report = path.join(root, "comment-router-latest.json");
diff --git a/test/sweep-workflow.test.ts b/test/sweep-workflow.test.ts
index 8a20fd487c..4b95dd9914 100644
--- a/test/sweep-workflow.test.ts
+++ b/test/sweep-workflow.test.ts
@@ -147,6 +147,16 @@ test("apply workflow bounds checkpoints and requeues with a fresh token", () =>
assert.ok(applyFlagInit > applyStep.indexOf('item_numbers="${{'));
assert.ok(applyFlagInit < applyStep.indexOf("auto_selected_apply_batch=true"));
assert.match(applyStep, /apply_cursor_path="results\/apply-cursors\/\$\{target_slug\}\.json"/);
+ assert.match(applyStep, /write_apply_health\(\)/);
+ assert.match(applyStep, /pnpm run --silent workflow -- summarize-apply-report/);
+ assert.match(applyStep, /health_cursor_path="\$\{5:-\}"/);
+ assert.match(applyStep, /comment_sync_health_cursor_path="\$cursor_path"/);
+ assert.match(applyStep, /comment_sync_health_cursor_required="true"/);
+ assert.match(applyStep, /comment_sync_health_processed_limit="\$sync_batch_size"/);
+ assert.match(applyStep, /close_health_cursor_path="\$apply_cursor_path"/);
+ assert.match(applyStep, /--apply-health-file "\.artifacts\/apply-health-\$checkpoint\.json"/);
+ assert.match(applyStep, /--apply-health-file "\.artifacts\/apply-health-final\.json"/);
+ assert.match(applyStep, /--state "Apply idle"/);
assert.match(applyStep, /--batch-size "\$close_processed_limit"/);
assert.match(applyStep, /--cursor-path "\$apply_cursor_path"/);
assert.match(applyStep, /write-apply-cursor/);