From a12b0cb1df0dea0562ded69f35a4d6f44e5cdee3 Mon Sep 17 00:00:00 2001 From: brokemac79 Date: Sat, 4 Jul 2026 00:12:34 +0100 Subject: [PATCH 1/2] feat: report apply cycle estimates --- .github/workflows/sweep.yml | 41 +++++- dashboard/worker.ts | 44 ++++++- docs/live-dashboard.md | 4 + src/repair/workflow-utils.ts | 198 +++++++++++++++++++++++++++-- test/dashboard-worker.test.ts | 12 ++ test/repair/workflow-utils.test.ts | 34 +++++ 6 files changed, 315 insertions(+), 18 deletions(-) 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"); From e0283a8657c118f9b96855950bd6a280d29e096e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 3 Jul 2026 20:12:00 -0700 Subject: [PATCH 2/2] test: cover apply cycle telemetry wiring --- test/sweep-workflow.test.ts | 5 +++++ 1 file changed, 5 insertions(+) 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"/);