Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 100 additions & 1 deletion .github/workflows/sweep.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/scheduler.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions src/repair/workflow-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<string>();

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);
Expand Down Expand Up @@ -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<string, string>): void {
for (const [key, value] of Object.entries(values)) console.log(`${key}=${value}`);
}
Expand Down
110 changes: 110 additions & 0 deletions test/repair/workflow-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import path from "node:path";
import test from "node:test";

import {
applyContinuationBlocker,
applyCursorAdvanceCount,
artifactItemNumbers,
automationLimit,
Expand All @@ -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"),
Expand Down
24 changes: 24 additions & 0 deletions test/sweep-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
Expand Down Expand Up @@ -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/);
});
Expand Down