diff --git a/.github/workflows/sweep.yml b/.github/workflows/sweep.yml index 841219817a..c6bf2e87e5 100644 --- a/.github/workflows/sweep.yml +++ b/.github/workflows/sweep.yml @@ -1,6 +1,63 @@ name: ClawSweeper -run-name: ${{ (github.event_name == 'schedule' && (github.event.schedule == '4/15 * * * *' || github.event.schedule == '41 * * * *' || github.event.schedule == '37 */6 * * *')) && 'Fan out ClawSweeper targets' || (github.event_name == 'schedule' && github.event.schedule == '13 * * * *') && 'Retry failed Codex reviews' || (github.event_name == 'repository_dispatch' && github.event.action == 'clawsweeper_target_sweep' && github.event.client_payload.hot_intake == 'true') && format('Review hot target repo {0}', github.event.client_payload.target_repo || 'openclaw/openclaw') || (github.event_name == 'repository_dispatch' && github.event.action == 'clawsweeper_target_sweep') && format('Review target repo {0}', github.event.client_payload.target_repo || 'openclaw/openclaw') || github.event_name == 'repository_dispatch' && format('Review event item {0}#{1}', github.event.client_payload.target_repo || 'openclaw/openclaw', github.event.client_payload.item_number || '?') || ((github.event_name == 'workflow_dispatch' && github.event.inputs.apply_existing == 'true' && github.event.inputs.apply_sync_comments_only == 'true') || (github.event_name == 'schedule' && github.event.schedule == '6,21,36,51 * * * *')) && 'Sync Codex review comments' || ((github.event_name == 'workflow_dispatch' && github.event.inputs.apply_existing == 'true') || (github.event_name == 'schedule' && (github.event.schedule == '3 * * * *' || github.event.schedule == '18 * * * *' || github.event.schedule == '33 * * * *' || github.event.schedule == '48 * * * *' || github.event.schedule == '8,23,38,53 * * * *'))) && 'Apply ClawSweeper closures' || ((github.event_name == 'workflow_dispatch' && github.event.inputs.audit_dashboard == 'true') || (github.event_name == 'schedule' && (github.event.schedule == '7 */6 * * *' || github.event.schedule == '12 */6 * * *' || github.event.schedule == '17 */6 * * *'))) && 'Audit ClawSweeper state' || ((github.event_name == 'workflow_dispatch' && github.event.inputs.hot_intake == 'true') || (github.event_name == 'schedule' && (github.event.schedule == '*/5 * * * *' || github.event.schedule == '2/5 * * * *'))) && 'Review hot ClawSweeper items' || 'Review ClawSweeper items' }} +run-name: >- + ${{ + (github.event_name == 'schedule' && + (github.event.schedule == '4/15 * * * *' || + github.event.schedule == '41 * * * *' || + github.event.schedule == '37 */6 * * *')) && + 'Fan out ClawSweeper targets' || + (github.event_name == 'schedule' && github.event.schedule == '13 * * * *') && + 'Retry failed Codex reviews' || + (github.event_name == 'repository_dispatch' && + github.event.action == 'clawsweeper_target_sweep' && + github.event.client_payload.hot_intake == 'true') && + format('Review hot target repo {0}', github.event.client_payload.target_repo || 'openclaw/openclaw') || + (github.event_name == 'repository_dispatch' && + github.event.action == 'clawsweeper_target_sweep') && + format('Review target repo {0}', github.event.client_payload.target_repo || 'openclaw/openclaw') || + github.event_name == 'repository_dispatch' && + format('Review event item {0}#{1}', github.event.client_payload.target_repo || 'openclaw/openclaw', github.event.client_payload.item_number || '?') || + ((github.event_name == 'workflow_dispatch' && + github.event.inputs.apply_existing == 'true' && + github.event.inputs.apply_sync_comments_only == 'true') || + (github.event_name == 'schedule' && github.event.schedule == '6,21,36,51 * * * *')) && + format('Sync Codex review comments for {0}', github.event.inputs.target_repo || 'openclaw/openclaw') || + (github.event_name == 'workflow_dispatch' && + github.event.inputs.apply_existing == 'true' && + github.event.inputs.apply_sync_comments_only != 'true' && + (github.event.inputs.apply_item_numbers != '' || + github.event.inputs.apply_limit != '5' || + github.event.inputs.apply_min_age_days != '0' || + github.event.inputs.apply_min_age_minutes != '' || + github.event.inputs.apply_kind != 'all' || + github.event.inputs.apply_close_reasons != 'all' || + github.event.inputs.apply_stale_min_age_days != '60' || + github.event.inputs.apply_close_delay_ms != '2000' || + github.event.inputs.apply_checkpoint_size != '5' || + github.event.inputs.apply_comment_sync_min_age_days != '7')) && + format('Apply custom ClawSweeper closures for {0}', github.event.inputs.target_repo || 'openclaw/openclaw') || + ((github.event_name == 'workflow_dispatch' && github.event.inputs.apply_existing == 'true') || + (github.event_name == 'schedule' && + (github.event.schedule == '3 * * * *' || + github.event.schedule == '18 * * * *' || + github.event.schedule == '33 * * * *' || + github.event.schedule == '48 * * * *' || + github.event.schedule == '8,23,38,53 * * * *'))) && + format('Apply default ClawSweeper closures for {0}', github.event.inputs.target_repo || ((github.event.schedule == '8,23,38,53 * * * *') && 'openclaw/clawhub' || 'openclaw/openclaw')) || + ((github.event_name == 'workflow_dispatch' && github.event.inputs.audit_dashboard == 'true') || + (github.event_name == 'schedule' && + (github.event.schedule == '7 */6 * * *' || + github.event.schedule == '12 */6 * * *' || + github.event.schedule == '17 */6 * * *'))) && + 'Audit ClawSweeper state' || + ((github.event_name == 'workflow_dispatch' && github.event.inputs.hot_intake == 'true') || + (github.event_name == 'schedule' && + (github.event.schedule == '*/5 * * * *' || + github.event.schedule == '2/5 * * * *'))) && + 'Review hot ClawSweeper items' || + 'Review ClawSweeper items' + }} on: repository_dispatch: @@ -2702,6 +2759,48 @@ jobs: exit 0 fi + can_share_apply_continuation=false + if [ "${APPLY_AUTO_SELECTED_BATCH:-false}" = "true" ] && + [ -z "${APPLY_ITEM_NUMBERS:-}" ] && + [ "${APPLY_LIMIT:-5}" = "5" ] && + [ "${APPLY_MIN_AGE_DAYS:-0}" = "0" ] && + [ -z "${APPLY_MIN_AGE_MINUTES:-}" ] && + [ "${APPLY_KIND:-all}" = "all" ] && + [ "${APPLY_CLOSE_REASONS:-all}" = "all" ] && + [ "${APPLY_STALE_MIN_AGE_DAYS:-60}" = "60" ] && + [ "${APPLY_CLOSE_DELAY_MS:-2000}" = "2000" ] && + [ "${APPLY_CHECKPOINT_SIZE:-5}" = "5" ] && + [ "${APPLY_COMMENT_SYNC_MIN_AGE_DAYS:-7}" = "7" ]; then + can_share_apply_continuation=true + fi + if [ "$can_share_apply_continuation" != "true" ]; then + echo "Apply continuation uses explicit or non-default inputs; preserving exact continuation dispatch." + else + continuation_env="$(mktemp)" + runs_json="$(mktemp)" + { + for run_status in in_progress pending queued waiting requested; do + gh api --paginate "repos/${{ github.repository }}/actions/runs?per_page=100&status=${run_status}" \ + --jq '.workflow_runs[] | {databaseId:.id, workflowPath:.path, displayTitle:.display_title, status:.status, createdAt:.created_at, updatedAt:.updated_at}' 2>/dev/null || true + done + } | jq -s '.' > "$runs_json" + pnpm run --silent workflow -- apply-continuation-blocker \ + --runs "$runs_json" \ + --current-run-id "${{ github.run_id }}" \ + --target-repo "${APPLY_TARGET_REPO:-openclaw/openclaw}" > "$continuation_env" + rm -f "$runs_json" + cat "$continuation_env" + APPLY_CONTINUATION_BLOCKED="$(awk -F= '$1 == "APPLY_CONTINUATION_BLOCKED" { print $2 }' "$continuation_env")" + APPLY_CONTINUATION_BLOCKER_RUN_ID="$(awk -F= '$1 == "APPLY_CONTINUATION_BLOCKER_RUN_ID" { print $2 }' "$continuation_env")" + APPLY_CONTINUATION_BLOCKER_STATUS="$(awk -F= '$1 == "APPLY_CONTINUATION_BLOCKER_STATUS" { print $2 }' "$continuation_env")" + rm -f "$continuation_env" + if [ "${APPLY_CONTINUATION_BLOCKED:-false}" = "true" ]; then + echo "Apply continuation for $APPLY_TARGET_REPO is already covered by ${APPLY_CONTINUATION_BLOCKER_STATUS:-active} run ${APPLY_CONTINUATION_BLOCKER_RUN_ID:-unknown}; not queueing another apply run." + echo "The existing default cursor run will continue the lane." + exit 0 + fi + fi + run_workflow_with_retry() { local label="$1" shift diff --git a/docs/scheduler.md b/docs/scheduler.md index 0606eef37b..63c10cd682 100644 --- a/docs/scheduler.md +++ b/docs/scheduler.md @@ -459,6 +459,14 @@ continuation with a fresh GitHub App token after any checkpoint that closes at least one item. A saturated scan that closes nothing stops without chaining so the same records cannot create an unbounded runner loop. +Apply and comment-sync Actions run titles include the target repository. Before +dispatching a default cursor-based apply continuation, the workflow checks +recent active or queued same-target default cursor runs and treats one of those +runs as the continuation instead of adding another pending run. Custom-input +and explicit-item runs have a different title and cannot suppress the default +cursor lane; their own continuations still dispatch with the exact inputs. The +log identifies the default cursor run that covered the continuation. + ## Continuation and Recovery When a normal or hot review run fills its planned capacity, the publish job diff --git a/src/repair/workflow-utils.ts b/src/repair/workflow-utils.ts index fe3cc950f2..cf114d5807 100644 --- a/src/repair/workflow-utils.ts +++ b/src/repair/workflow-utils.ts @@ -13,6 +13,17 @@ type ApplyAction = { reason?: string; }; +type ApplyContinuationBlocker = { + databaseId: string; + status: string; +}; + +type ApplyContinuationBlockerOptions = { + currentRunId: string; + targetRepo: string; + nowMs?: number; +}; + type ApplyReportSummaryOptions = { reportPath: string; targetRepo: string; @@ -132,6 +143,18 @@ function runCli(): void { applyCursorAdvanceCount(requiredString("report"), optionalString("item-numbers")), ); break; + case "apply-continuation-blocker": { + const blocker = applyContinuationBlocker(readJsonArray(requiredString("runs")), { + currentRunId: requiredString("current-run-id"), + targetRepo: requiredString("target-repo"), + }); + printOutput({ + APPLY_CONTINUATION_BLOCKED: blocker ? "true" : "false", + APPLY_CONTINUATION_BLOCKER_RUN_ID: blocker?.databaseId ?? "", + APPLY_CONTINUATION_BLOCKER_STATUS: blocker?.status ?? "", + }); + break; + } case "summarize-apply-report": process.stdout.write( `${JSON.stringify( @@ -351,6 +374,37 @@ export function countActions(reportPath: string, action: string): number { return readApplyActions(reportPath).filter((entry) => entry.action === action).length; } +export function applyContinuationBlocker( + values: readonly unknown[], + options: ApplyContinuationBlockerOptions, +): ApplyContinuationBlocker | null { + const expectedTitle = `Apply default ClawSweeper closures for ${options.targetRepo}`; + const activeStatuses = new Set(["in_progress", "pending", "queued", "waiting", "requested"]); + const queuedStatuses = new Set(["pending", "queued", "waiting", "requested"]); + const staleQueuedMs = 6 * 60 * 60 * 1000; + const nowMs = options.nowMs ?? Date.now(); + const seen = new Set(); + + for (const value of values) { + if (!isJsonObject(value)) continue; + const databaseId = String(value.databaseId ?? ""); + if (!databaseId || seen.has(databaseId)) continue; + seen.add(databaseId); + if (databaseId === options.currentRunId) continue; + if (value.workflowPath !== ".github/workflows/sweep.yml") continue; + if (value.displayTitle !== expectedTitle) continue; + const status = String(value.status ?? ""); + if (!activeStatuses.has(status)) continue; + if (queuedStatuses.has(status)) { + const updatedAt = String(value.updatedAt || value.createdAt || ""); + const lastChangedAt = Date.parse(updatedAt); + if (Number.isFinite(lastChangedAt) && nowMs - lastChangedAt > staleQueuedMs) continue; + } + return { databaseId, status }; + } + return null; +} + export function summarizeApplyReport(options: ApplyReportSummaryOptions): ApplyReportSummary { const actions = readApplyActions(options.reportPath); const lanes = summarizeApplyLanes(actions, options.mode); @@ -1359,6 +1413,12 @@ function readJsonObject(filePath: string): LooseRecord { return parsed; } +function readJsonArray(filePath: string): unknown[] { + const parsed: unknown = JSON.parse(fs.readFileSync(filePath, "utf8")); + if (!Array.isArray(parsed)) throw new Error(`${filePath} must contain a JSON array`); + return parsed; +} + function printOutput(values: Record): void { for (const [key, value] of Object.entries(values)) console.log(`${key}=${value}`); } diff --git a/test/repair/workflow-utils.test.ts b/test/repair/workflow-utils.test.ts index 51423feb6f..7e8ba0a90d 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 { + applyContinuationBlocker, applyCursorAdvanceCount, artifactItemNumbers, automationLimit, @@ -29,6 +30,115 @@ import { workerLimit, } from "../../dist/repair/limits.js"; +const APPLY_RUN_PATH = ".github/workflows/sweep.yml"; +const DEFAULT_APPLY_TITLE = "Apply default ClawSweeper closures for openclaw/openclaw"; + +test("apply continuation blocker only shares the default cursor lane", () => { + const blocker = applyContinuationBlocker( + [ + { + databaseId: "current", + workflowPath: APPLY_RUN_PATH, + displayTitle: DEFAULT_APPLY_TITLE, + status: "in_progress", + }, + { + databaseId: "custom", + workflowPath: APPLY_RUN_PATH, + displayTitle: "Apply custom ClawSweeper closures for openclaw/openclaw", + status: "in_progress", + }, + { + databaseId: "default", + workflowPath: APPLY_RUN_PATH, + displayTitle: DEFAULT_APPLY_TITLE, + status: "in_progress", + }, + ], + { currentRunId: "current", targetRepo: "openclaw/openclaw" }, + ); + + assert.deepEqual(blocker, { databaseId: "default", status: "in_progress" }); +}); + +test("apply continuation blocker ignores stale queued and unrelated runs", () => { + const nowMs = Date.parse("2026-07-04T12:00:00Z"); + const blocker = applyContinuationBlocker( + [ + { + databaseId: "wrong-path", + workflowPath: ".github/workflows/other.yml", + displayTitle: DEFAULT_APPLY_TITLE, + status: "in_progress", + }, + { + databaseId: "completed", + workflowPath: APPLY_RUN_PATH, + displayTitle: DEFAULT_APPLY_TITLE, + status: "completed", + }, + { + databaseId: "stale", + workflowPath: APPLY_RUN_PATH, + displayTitle: DEFAULT_APPLY_TITLE, + status: "queued", + updatedAt: "2026-07-04T05:59:59Z", + }, + { + databaseId: "fresh", + workflowPath: APPLY_RUN_PATH, + displayTitle: DEFAULT_APPLY_TITLE, + status: "queued", + updatedAt: "2026-07-04T06:00:01Z", + }, + ], + { currentRunId: "current", targetRepo: "openclaw/openclaw", nowMs }, + ); + + assert.deepEqual(blocker, { databaseId: "fresh", status: "queued" }); +}); + +test("apply continuation blocker CLI emits workflow fields", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawsweeper-apply-blocker-")); + const runsPath = path.join(root, "runs.json"); + write( + runsPath, + JSON.stringify([ + { + databaseId: 42, + workflowPath: APPLY_RUN_PATH, + displayTitle: DEFAULT_APPLY_TITLE, + status: "waiting", + }, + ]), + ); + + const output = execFileSync( + process.execPath, + [ + path.resolve("dist/repair/workflow-utils.js"), + "apply-continuation-blocker", + "--runs", + runsPath, + "--current-run-id", + "99", + "--target-repo", + "openclaw/openclaw", + ], + { encoding: "utf8" }, + ); + + assert.equal( + output, + [ + "APPLY_CONTINUATION_BLOCKED=true", + "APPLY_CONTINUATION_BLOCKER_RUN_ID=42", + "APPLY_CONTINUATION_BLOCKER_STATUS=waiting", + "", + ].join("\n"), + ); +}); + test("workflow utilities expose automation limits", () => { assert.equal( automationLimit("exact_review.concurrent_max"), diff --git a/test/sweep-workflow.test.ts b/test/sweep-workflow.test.ts index 5baef57c86..0329e4d996 100644 --- a/test/sweep-workflow.test.ts +++ b/test/sweep-workflow.test.ts @@ -136,6 +136,12 @@ test("apply workflow bounds checkpoints and requeues with a fresh token", () => applyJob.indexOf("- name: Queue review backstops"), ); + assert.match(workflow, /format\('Apply default ClawSweeper closures for \{0\}'/); + assert.match(workflow, /format\('Apply custom ClawSweeper closures for \{0\}'/); + assert.match( + workflow, + /github\.event\.schedule == '8,23,38,53 \* \* \* \*'\) && 'openclaw\/clawhub'/, + ); assert.match(inputBlock, /apply_limit:[\s\S]*default: "5"/); assert.match(inputBlock, /apply_checkpoint_size:[\s\S]*default: "5"/); assert.match(applyStep, /Capping apply checkpoint size at 5/); @@ -185,6 +191,24 @@ test("apply workflow bounds checkpoints and requeues with a fresh token", () => assert.match(applyStep, /echo "APPLY_CONTINUE=\$continue_apply"/); assert.match(applyStep, /echo "APPLY_AUTO_SELECTED_BATCH=\$auto_selected_apply_batch"/); assert.match(continueStep, /APPLY_CONTINUE:-false/); + assert.match(continueStep, /can_share_apply_continuation=false/); + assert.match(continueStep, /\[ "\$\{APPLY_AUTO_SELECTED_BATCH:-false\}" = "true" \]/); + assert.match(continueStep, /\[ -z "\$\{APPLY_ITEM_NUMBERS:-\}" \]/); + assert.match(continueStep, /\[ "\$\{APPLY_COMMENT_SYNC_MIN_AGE_DAYS:-7\}" = "7" \]/); + assert.match(continueStep, /preserving exact continuation dispatch/); + assert.match( + continueStep, + /gh api --paginate "repos\/\$\{\{ github\.repository \}\}\/actions\/runs\?per_page=100&status=\$\{run_status\}"/, + ); + assert.match(continueStep, /workflowPath:\.path/); + assert.doesNotMatch(continueStep, /workflowName:\.name/); + assert.doesNotMatch(continueStep, /gh run list/); + assert.match(continueStep, /pnpm run --silent workflow -- apply-continuation-blocker/); + assert.match(continueStep, /--current-run-id "\$\{\{ github\.run_id \}\}"/); + assert.match(continueStep, /--target-repo "\$\{APPLY_TARGET_REPO:-openclaw\/openclaw\}"/); + assert.match(continueStep, /APPLY_CONTINUATION_BLOCKED/); + assert.match(continueStep, /existing default cursor run will continue the lane/); + assert.match(continueStep, /already covered by \$/); assert.match(continueStep, /-f apply_item_numbers="\$APPLY_ITEM_NUMBERS"/); assert.doesNotMatch(continueStep, /APPLY_CLOSED_TOTAL:-0.*APPLY_LIMIT:-0/); });