diff --git a/.github/workflows/sweep.yml b/.github/workflows/sweep.yml
index 920b3fb243..841219817a 100644
--- a/.github/workflows/sweep.yml
+++ b/.github/workflows/sweep.yml
@@ -2292,6 +2292,8 @@ jobs:
cursor_path="results/comment-sync-cursors/${target_slug}.json"
apply_cursor_path="results/apply-cursors/${target_slug}.json"
next_cursor=""
+ apply_ready_count=""
+ cursor_advance_count=""
if [ "$sync_open_pr_batch" = "true" ]; then
sync_comments_only="true"
apply_kind="pull_request"
@@ -2333,6 +2335,9 @@ jobs:
health_processed_limit="$4"
health_cursor_path="${5:-}"
health_cursor_required="${6:-false}"
+ health_candidate_count="${7:-}"
+ health_scheduled_interval_minutes="${8:-}"
+ health_cursor_advance_count="${9:-}"
health_args=(
--target-repo "$TARGET_REPO"
--report "$report_path"
@@ -2346,6 +2351,15 @@ jobs:
if [ "$health_cursor_required" = "true" ]; then
health_args+=(--cursor-required true)
fi
+ if [ -n "$health_candidate_count" ]; then
+ health_args+=(--candidate-count "$health_candidate_count")
+ fi
+ if [ -n "$health_scheduled_interval_minutes" ]; then
+ health_args+=(--scheduled-interval-minutes "$health_scheduled_interval_minutes")
+ fi
+ if [ -n "$health_cursor_advance_count" ]; then
+ health_args+=(--cursor-advance-count "$health_cursor_advance_count")
+ fi
pnpm run --silent workflow -- summarize-apply-report "${health_args[@]}" > "$output_path"
}
reconcile_args=(--target-repo "$TARGET_REPO" --skip-closed-at)
@@ -2406,10 +2420,17 @@ jobs:
if [ -n "$item_numbers" ]; then
auto_selected_apply_batch=true
proposed_count="$(pnpm run --silent workflow -- count-csv --items "$item_numbers")"
+ apply_ready_count="$(pnpm run --silent workflow -- proposed-item-count \
+ --target-repo "$TARGET_REPO" \
+ --apply-kind "$apply_kind" \
+ --apply-close-reasons "$apply_close_reasons" \
+ --stale-min-age-days "$stale_min_age_days" \
+ --min-age-days "$min_age_days" \
+ --min-age-minutes "$min_age_minutes")"
if [ "$proposed_count" -lt "$limit" ]; then
limit="$proposed_count"
fi
- echo "Auto-selected $proposed_count proposed close candidate(s) from apply cursor window: $item_numbers"
+ echo "Auto-selected $proposed_count proposed close candidate(s) from apply cursor window: $item_numbers; apply-ready count: ${apply_ready_count:-unknown}"
fi
fi
item_numbers_arg=()
@@ -2491,7 +2512,7 @@ jobs:
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"
+ 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" \
@@ -2541,6 +2562,9 @@ jobs:
--report ".artifacts/apply-reports/apply-report-$checkpoint.json" \
--target-repo "$TARGET_REPO" \
--item-numbers "$item_numbers"
+ cursor_advance_count="$(pnpm run --silent workflow -- apply-cursor-advance-count \
+ --report ".artifacts/apply-reports/apply-report-$checkpoint.json" \
+ --item-numbers "$item_numbers")"
apply_publish_paths+=(results/apply-cursors)
fi
publish_changes "chore: apply sweep decisions checkpoint $checkpoint" "${apply_publish_paths[@]}"
@@ -2548,7 +2572,7 @@ jobs:
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"
+ 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" "$apply_ready_count" "15" "$cursor_advance_count"
pnpm run status -- \
--target-repo "$TARGET_REPO" \
--state "Apply in progress" \
@@ -2583,20 +2607,29 @@ jobs:
final_health_processed_limit="$close_processed_limit"
final_health_cursor_path=""
final_health_cursor_required="$auto_selected_apply_batch"
+ final_health_candidate_count=""
+ final_health_scheduled_interval_minutes=""
+ final_health_cursor_advance_count=""
if [ "$auto_selected_apply_batch" = "true" ]; then
final_health_cursor_path="$apply_cursor_path"
+ final_health_candidate_count="$apply_ready_count"
+ final_health_scheduled_interval_minutes="15"
+ final_health_cursor_advance_count="$cursor_advance_count"
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"
+ final_health_candidate_count=""
+ final_health_scheduled_interval_minutes=""
+ final_health_cursor_advance_count=""
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"
+ 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" "$final_health_candidate_count" "$final_health_scheduled_interval_minutes" "$final_health_cursor_advance_count"
pnpm run status -- \
--target-repo "$TARGET_REPO" \
--state "Apply finished" \
diff --git a/dashboard/worker.ts b/dashboard/worker.ts
index 1b7520fbb3..9b0ad8bc58 100644
--- a/dashboard/worker.ts
+++ b/dashboard/worker.ts
@@ -3584,6 +3584,7 @@ async function readApplyHealthMarker(env, targetRepo) {
lanes: applyHealthLanes(health.lanes),
next_actions: nextActions,
next_action_buckets: numericRecord(health.next_action_buckets),
+ cycle: applyHealthCycle(health.cycle),
attention_reasons: Array.isArray(health.attention_reasons)
? health.attention_reasons
.map((reason) => String(reason))
@@ -3620,6 +3621,7 @@ async function readApplyHealthMarker(env, targetRepo) {
lanes: emptyApplyHealthLanes(),
next_actions: [],
next_action_buckets: {},
+ cycle: emptyApplyHealthCycle(),
attention_reasons: [],
cursor: null,
};
@@ -3638,6 +3640,7 @@ function emptyApplyHealthStatus(targetRepos) {
lanes: emptyApplyHealthLanes(),
next_actions: [],
next_action_buckets: {},
+ cycle: emptyApplyHealthCycle(),
attention_reasons: [],
cursor: null,
})),
@@ -3699,6 +3702,31 @@ function applyHealthNextActions(value) {
.filter(Boolean)
.slice(0, 12);
}
+
+function applyHealthCycle(value) {
+ const source = objectValue(value);
+ return {
+ basis: nullableString(source.basis),
+ apply_ready_count: optionalNumber(source.apply_ready_count),
+ window_size: optionalNumber(source.window_size),
+ estimated_full_cycle_windows: optionalNumber(source.estimated_full_cycle_windows),
+ estimated_full_cycle_minutes: optionalNumber(source.estimated_full_cycle_minutes),
+ scheduled_interval_minutes: optionalNumber(source.scheduled_interval_minutes),
+ label: nullableString(source.label),
+ };
+}
+
+function emptyApplyHealthCycle() {
+ return {
+ basis: null,
+ apply_ready_count: null,
+ window_size: null,
+ estimated_full_cycle_windows: null,
+ estimated_full_cycle_minutes: null,
+ scheduled_interval_minutes: null,
+ label: null,
+ };
+}
function latestIso(values) {
const timestamps = values
.map((value) => Date.parse(value || ""))
@@ -4763,6 +4791,11 @@ function numberOrNull(value) {
return Number.isFinite(number) ? number : null;
}
+function optionalNumber(value) {
+ if (value === null || value === undefined || value === "") return null;
+ return numberOrNull(value);
+}
+
function numberFrom(value, fallback) {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
@@ -6890,14 +6923,23 @@ function renderApplyHealth(data) {
const syncProcessed = Number.isFinite(item.lanes?.comment_sync?.processed) ? fmt.format(item.lanes.comment_sync.processed) : processed;
const closureSynced = Number.isFinite(item.lanes?.closure?.comment_synced) ? fmt.format(item.lanes.closure.comment_synced) : "0";
const syncLaneSynced = Number.isFinite(item.lanes?.comment_sync?.comment_synced) ? fmt.format(item.lanes.comment_sync.comment_synced) : "0";
+ const cycle = applyHealthCyclePill(item.cycle);
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 + buckets + linkClass(item.run_url, "workflow run", "pill run-link") + '
';
+ '' + esc(processed) + ' processed' + esc(closed) + ' closed' + esc(synced) + ' comments synced' + cycle + cursorPill + reasons + buckets + linkClass(item.run_url, "workflow run", "pill run-link") + '
';
}).join("");
}
+function applyHealthCyclePill(cycle) {
+ if (!cycle || cycle.basis !== "scheduled_close_cursor") return "";
+ const windows = Number(cycle.estimated_full_cycle_windows);
+ const label = Number.isFinite(windows)
+ ? "revisit ~" + fmt.format(windows) + " window" + (windows === 1 ? "" : "s")
+ : "revisit estimate";
+ return '' + esc(label) + '';
+}
function applyHealthNeedsAttention(status) {
return ["attention", "blocked", "degraded", "failed", "needs_attention", "warning"].includes(String(status || "").toLowerCase());
}
diff --git a/docs/live-dashboard.md b/docs/live-dashboard.md
index a75cd146ee..13d04cb41f 100644
--- a/docs/live-dashboard.md
+++ b/docs/live-dashboard.md
@@ -134,6 +134,10 @@ is absent or a cache event lands in another Cloudflare colo.
- skip next-action buckets in apply health JSON so stale reviews, missing close
proof, protected labels, stable skips, invalid reports, and open closing PRs
are discoverable without reading individual item records
+- scheduled close-cycle telemetry in apply-health JSON, including current
+ apply-ready candidate count and an estimated number of cursor windows to
+ revisit the close queue; scheduled cadence time is explanatory only because
+ successful windows can dispatch immediate continuations
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/src/repair/workflow-utils.ts b/src/repair/workflow-utils.ts
index ef76d1aac7..fe3cc950f2 100644
--- a/src/repair/workflow-utils.ts
+++ b/src/repair/workflow-utils.ts
@@ -21,6 +21,9 @@ type ApplyReportSummaryOptions = {
closeLimit: number | null;
cursorPath: string;
cursorRequired: boolean;
+ candidateCount?: number | null;
+ cursorAdvanceCount?: number | null;
+ scheduledIntervalMinutes?: number | null;
};
type ApplyReportSummary = {
@@ -43,6 +46,7 @@ type ApplyReportSummary = {
};
next_actions: ApplySkipNextAction[];
next_action_buckets: Record;
+ cycle: ApplyCycleSummary;
attention_reasons: string[];
cursor_required: boolean;
cursor: {
@@ -84,6 +88,20 @@ type ApplySkipNextAction = {
};
type ApplySkipNextActionDetail = Omit;
+type ApplyCycleSummary = {
+ basis:
+ | "scheduled_close_cursor"
+ | "not_close_cursor"
+ | "missing_candidate_count"
+ | "missing_window_size"
+ | "no_apply_ready_candidates";
+ apply_ready_count: number | null;
+ window_size: number | null;
+ estimated_full_cycle_windows: number | null;
+ estimated_full_cycle_minutes: number | null;
+ scheduled_interval_minutes: number | null;
+ label: string;
+};
const args = parseArgs(process.argv.slice(2));
function runCli(): void {
@@ -109,6 +127,11 @@ function runCli(): void {
case "count-actions":
console.log(countActions(requiredString("report"), requiredString("action")));
break;
+ case "apply-cursor-advance-count":
+ console.log(
+ applyCursorAdvanceCount(requiredString("report"), optionalString("item-numbers")),
+ );
+ break;
case "summarize-apply-report":
process.stdout.write(
`${JSON.stringify(
@@ -120,6 +143,15 @@ function runCli(): void {
closeLimit: optionalString("close-limit") ? numberArg("close-limit", 0) : null,
cursorPath: optionalString("cursor-path"),
cursorRequired: booleanArg("cursor-required", false),
+ candidateCount: optionalString("candidate-count")
+ ? nonNegativeIntegerArg("candidate-count")
+ : null,
+ cursorAdvanceCount: optionalString("cursor-advance-count")
+ ? nonNegativeIntegerArg("cursor-advance-count")
+ : null,
+ scheduledIntervalMinutes: optionalString("scheduled-interval-minutes")
+ ? positiveIntegerArg("scheduled-interval-minutes")
+ : null,
}),
null,
2,
@@ -157,6 +189,9 @@ function runCli(): void {
case "proposed-item-numbers":
process.stdout.write(proposedItemNumbers(proposedItemOptions()).join(","));
break;
+ case "proposed-item-count":
+ process.stdout.write(String(proposedItemCount(proposedItemOptions())));
+ break;
case "proposed-pr-close-coverage-item-numbers":
process.stdout.write(proposedPrCloseCoverageItemNumbers(proposedItemOptions()).join(","));
break;
@@ -334,6 +369,13 @@ export function summarizeApplyReport(options: ApplyReportSummaryOptions): ApplyR
const cursor = readApplyCursorForSummary(options.cursorPath);
const processedLimit = options.processedLimit > 0 ? options.processedLimit : null;
+ const cycle = applyCycleSummary({
+ mode: options.mode,
+ cursorRequired: options.cursorRequired,
+ candidateCount: options.candidateCount ?? null,
+ cursorAdvanceCount: options.cursorAdvanceCount ?? null,
+ scheduledIntervalMinutes: options.scheduledIntervalMinutes ?? null,
+ });
const attentionReasons: string[] = [];
if (
options.cursorRequired &&
@@ -392,6 +434,7 @@ export function summarizeApplyReport(options: ApplyReportSummaryOptions): ApplyR
lanes,
next_actions: nextActions,
next_action_buckets: applyNextActionBuckets(nextActions),
+ cycle,
attention_reasons: attentionReasons,
cursor_required: options.cursorRequired,
cursor,
@@ -644,6 +687,97 @@ function alreadyResolvedAction(): ApplySkipNextActionDetail {
next_step: "No action is needed unless this bucket dominates repeated runs.",
};
}
+
+function applyCycleSummary(options: {
+ mode: string;
+ cursorRequired: boolean;
+ candidateCount: number | null;
+ cursorAdvanceCount: number | null;
+ scheduledIntervalMinutes: number | null;
+}): ApplyCycleSummary {
+ const closeCursorMode =
+ String(options.mode || "").toLowerCase() === "close" && options.cursorRequired;
+ const windowSize =
+ options.cursorAdvanceCount !== null && options.cursorAdvanceCount > 0
+ ? options.cursorAdvanceCount
+ : null;
+ const cadence = options.scheduledIntervalMinutes;
+ if (!closeCursorMode) {
+ return {
+ basis: "not_close_cursor",
+ apply_ready_count: options.candidateCount,
+ window_size: windowSize,
+ estimated_full_cycle_windows: null,
+ estimated_full_cycle_minutes: null,
+ scheduled_interval_minutes: cadence,
+ label: "Cycle estimate is only reported for scheduled close cursor windows.",
+ };
+ }
+ if (options.candidateCount === null) {
+ return {
+ basis: "missing_candidate_count",
+ apply_ready_count: null,
+ window_size: windowSize,
+ estimated_full_cycle_windows: null,
+ estimated_full_cycle_minutes: null,
+ scheduled_interval_minutes: cadence,
+ label:
+ "Cycle estimate is unavailable because the apply-ready candidate count was not recorded.",
+ };
+ }
+ if (options.candidateCount === 0) {
+ return {
+ basis: "no_apply_ready_candidates",
+ apply_ready_count: 0,
+ window_size: windowSize,
+ estimated_full_cycle_windows: 0,
+ estimated_full_cycle_minutes: 0,
+ scheduled_interval_minutes: cadence,
+ label: "No apply-ready close candidates are waiting in this lane.",
+ };
+ }
+ if (!windowSize) {
+ return {
+ basis: "missing_window_size",
+ apply_ready_count: options.candidateCount,
+ window_size: null,
+ estimated_full_cycle_windows: null,
+ estimated_full_cycle_minutes: null,
+ scheduled_interval_minutes: cadence,
+ label: "Cycle estimate is unavailable because no scan window size was recorded.",
+ };
+ }
+ const windows = Math.ceil(options.candidateCount / windowSize);
+ const minutes = cadence && cadence > 0 ? windows * cadence : null;
+ return {
+ basis: "scheduled_close_cursor",
+ apply_ready_count: options.candidateCount,
+ window_size: windowSize,
+ estimated_full_cycle_windows: windows,
+ estimated_full_cycle_minutes: minutes,
+ scheduled_interval_minutes: cadence,
+ label: cycleLabel(options.candidateCount, windowSize, windows, minutes, cadence),
+ };
+}
+
+function cycleLabel(
+ candidateCount: number,
+ windowSize: number,
+ windows: number,
+ minutes: number | null,
+ cadence: number | null,
+): string {
+ const base = `${candidateCount} apply-ready close candidates at ${windowSize} records per latest cursor advance: about ${windows} window${windows === 1 ? "" : "s"}`;
+ if (!minutes || !cadence) return `${base}.`;
+ return `${base}; scheduled cadence alone would take roughly ${durationLabel(minutes)} at ${cadence}-minute intervals, while successful windows can continue sooner.`;
+}
+
+function durationLabel(minutes: number): string {
+ if (minutes < 60) return `${minutes} min`;
+ const hours = Math.floor(minutes / 60);
+ const remainder = minutes % 60;
+ return remainder === 0 ? `${hours}h` : `${hours}h ${remainder}m`;
+}
function applyReportHealthSummary(options: {
status: ApplyReportSummary["status"];
processed: number;
@@ -726,6 +860,11 @@ export function proposedItemNumbers(options: ProposedItemOptions): number[] {
return selectedProposedItemCandidates(options, "all").map((candidate) => candidate.number);
}
+export function proposedItemCount(options: ProposedItemOptions): number {
+ return selectedProposedItemCandidates({ ...options, batchSize: null, cursorPath: null }, "all")
+ .length;
+}
+
export function proposedPrCloseCoverageItemNumbers(options: ProposedItemOptions): number[] {
return selectedProposedItemCandidates(options, "pr-close-coverage-proof").map(
(candidate) => candidate.number,
@@ -1049,19 +1188,7 @@ export function writeApplyCursor(
targetRepo: string,
itemNumbers = "",
): void {
- const actions = readApplyActions(reportPath);
- const processed = actions.flatMap((action) =>
- typeof action.number === "number" ? [action.number] : [],
- );
- const selected = csvItems(itemNumbers)
- .map((item) => Number(item))
- .filter((number) => Number.isInteger(number) && number > 0);
- const processedSet = new Set(processed);
- const number =
- selected.filter((itemNumber) => processedSet.has(itemNumber)).at(-1) ??
- selected.at(-1) ??
- processed.at(-1) ??
- 0;
+ const { number } = applyCursorAdvance(reportPath, itemNumbers);
const applyCheckedAt = number > 0 ? applyCheckedAtForItem(targetRepo, number) : "";
fs.mkdirSync(path.dirname(cursorPath), { recursive: true });
fs.writeFileSync(
@@ -1079,6 +1206,35 @@ export function writeApplyCursor(
);
}
+export function applyCursorAdvanceCount(reportPath: string, itemNumbers = ""): number {
+ return applyCursorAdvance(reportPath, itemNumbers).count;
+}
+
+function applyCursorAdvance(
+ reportPath: string,
+ itemNumbers: string,
+): { number: number; count: number } {
+ const processed = readApplyActions(reportPath).flatMap((action) =>
+ typeof action.number === "number" ? [action.number] : [],
+ );
+ const selected = csvItems(itemNumbers)
+ .map((item) => Number(item))
+ .filter((number) => Number.isInteger(number) && number > 0);
+ if (selected.length === 0) {
+ return {
+ number: processed.at(-1) ?? 0,
+ count: new Set(processed).size,
+ };
+ }
+ const processedSet = new Set(processed);
+ const lastProcessedIndex = selected.findLastIndex((number) => processedSet.has(number));
+ const cursorIndex = lastProcessedIndex >= 0 ? lastProcessedIndex : selected.length - 1;
+ return {
+ number: selected[cursorIndex] ?? 0,
+ count: cursorIndex + 1,
+ };
+}
+
function applyCheckedAtForItem(targetRepo: string, itemNumber: number): string {
const baseDir = path.join("records", targetSlug(targetRepo));
for (const stateDir of ["items", "closed"]) {
@@ -1231,6 +1387,22 @@ function numberArg(name: string, fallback: number): number {
return parsed;
}
+function nonNegativeIntegerArg(name: string): number {
+ const parsed = numberArg(name, 0);
+ if (!Number.isInteger(parsed) || parsed < 0) {
+ throw new Error(`--${name} must be a non-negative integer`);
+ }
+ return parsed;
+}
+
+function positiveIntegerArg(name: string): number {
+ const parsed = numberArg(name, 0);
+ if (!Number.isInteger(parsed) || parsed < 1) {
+ throw new Error(`--${name} must be a positive integer`);
+ }
+ return parsed;
+}
+
function booleanArg(name: string, fallback: boolean): boolean {
const value = optionalString(name).toLowerCase();
if (!value) return fallback;
diff --git a/test/dashboard-worker.test.ts b/test/dashboard-worker.test.ts
index 0c110ff216..6e593ac544 100644
--- a/test/dashboard-worker.test.ts
+++ b/test/dashboard-worker.test.ts
@@ -1557,6 +1557,16 @@ test("dashboard exposes apply health from sweep status without broad scans", asy
next_action_buckets: {
review_refresh: 2,
},
+ cycle: {
+ basis: "scheduled_close_cursor",
+ apply_ready_count: 1200,
+ window_size: 300,
+ estimated_full_cycle_windows: 4,
+ estimated_full_cycle_minutes: null,
+ scheduled_interval_minutes: null,
+ label:
+ "1200 apply-ready close candidates at 300 records per latest cursor advance: about 4 windows.",
+ },
attention_reasons: ["cursor_required_but_missing_after_full_window"],
cursor: null,
},
@@ -1619,6 +1629,8 @@ test("dashboard exposes apply health from sweep status without broad scans", asy
status.recent.apply_health.items[0].next_actions[0].next_step,
"Queue a fresh ClawSweeper review before any close retry.",
);
+ assert.equal(status.recent.apply_health.items[0].cycle.estimated_full_cycle_minutes, null);
+ assert.equal(status.recent.apply_health.items[0].cycle.apply_ready_count, 1200);
assert.equal(status.recent.apply_health.items[0].cursor, null);
} finally {
globalThis.fetch = originalFetch;
diff --git a/test/repair/workflow-utils.test.ts b/test/repair/workflow-utils.test.ts
index 2693b9478e..51423feb6f 100644
--- a/test/repair/workflow-utils.test.ts
+++ b/test/repair/workflow-utils.test.ts
@@ -6,6 +6,7 @@ import path from "node:path";
import test from "node:test";
import {
+ applyCursorAdvanceCount,
artifactItemNumbers,
automationLimit,
commentSyncBatchOutput,
@@ -215,6 +216,9 @@ test("workflow utilities summarize apply health with skip buckets and cursor", (
closeLimit: 5,
cursorPath,
cursorRequired: true,
+ candidateCount: 12,
+ cursorAdvanceCount: 4,
+ scheduledIntervalMinutes: 15,
});
assert.equal(summary.status, "ok");
@@ -252,6 +256,16 @@ test("workflow utilities summarize apply health with skip buckets and cursor", (
summary.next_actions.find((action) => action.reason === "skipped_comment_auth")?.next_step,
"Repair the GitHub App write token before retrying comment sync.",
);
+ assert.deepEqual(summary.cycle, {
+ basis: "scheduled_close_cursor",
+ apply_ready_count: 12,
+ window_size: 4,
+ estimated_full_cycle_windows: 3,
+ estimated_full_cycle_minutes: 45,
+ scheduled_interval_minutes: 15,
+ label:
+ "12 apply-ready close candidates at 4 records per latest cursor advance: about 3 windows; scheduled cadence alone would take roughly 45 min at 15-minute intervals, while successful windows can continue sooner.",
+ });
assert.equal(summary.cursor?.next_after_number, 40);
});
@@ -388,6 +402,8 @@ test("workflow utilities flag full-window close scans without the required curso
closeLimit: 5,
cursorPath: path.join(root, "missing-cursor.json"),
cursorRequired: true,
+ candidateCount: null,
+ scheduledIntervalMinutes: null,
});
assert.equal(summary.status, "needs_attention");
@@ -1301,6 +1317,24 @@ test("workflow utilities persist apply cursor from processed or selected items",
}
});
+test("workflow utilities count records advanced by the apply 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: "review_comment_synced" },
+ { number: 10, action: "closed" },
+ { number: 30, action: "skipped_changed_since_review" },
+ ]),
+ );
+ assert.equal(applyCursorAdvanceCount(reportPath, "10,20,30,40"), 3);
+
+ write(reportPath, "[]");
+ assert.equal(applyCursorAdvanceCount(reportPath, "10,20,30,40"), 4);
+});
+
test("workflow utilities select cursor-based PR comment sync batches", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawsweeper-workflow-"));
const cursorPath = path.join(root, "results/comment-sync-cursors/openclaw-openclaw.json");
diff --git a/test/sweep-workflow.test.ts b/test/sweep-workflow.test.ts
index 4b95dd9914..5baef57c86 100644
--- a/test/sweep-workflow.test.ts
+++ b/test/sweep-workflow.test.ts
@@ -148,6 +148,11 @@ test("apply workflow bounds checkpoints and requeues with a fresh token", () =>
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, /proposed-item-count/);
+ assert.match(applyStep, /apply-cursor-advance-count/);
+ assert.match(applyStep, /--candidate-count "\$health_candidate_count"/);
+ assert.match(applyStep, /--cursor-advance-count "\$health_cursor_advance_count"/);
+ assert.match(applyStep, /--scheduled-interval-minutes "\$health_scheduled_interval_minutes"/);
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"/);