Compare commits

...

69 Commits
v0.2.0 ... main

Author SHA1 Message Date
Peter Steinberger
b9a420e71b
feat: add generic openclaw target onboarding
Some checks failed
CI / pnpm check (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (actions) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (push) Has been cancelled
Pages / Deploy docs (push) Has been cancelled
2026-05-06 23:41:03 +01:00
pashpashpash
fdd213207f
Merge pull request #51 from openclaw/codex/proof-privacy-guidance
Nudge proof reviews toward screenshots and redaction
2026-05-06 14:53:08 -07:00
pashpashpash
a7170a575a docs: prefer screenshots or videos for proof 2026-05-06 14:50:14 -07:00
pashpashpash
290a3f749c docs: tune real behavior proof guidance 2026-05-06 13:53:56 -07:00
Peter Steinberger
c44bbe7e2d
fix: scope sweep state publishes by target 2026-05-06 21:46:21 +01:00
Peter Steinberger
1003195db4
feat: enable fs-safe event reviews 2026-05-06 21:30:42 +01:00
Peter Steinberger
1f9fcbc508
fix: require full URLs in review references 2026-05-06 08:05:37 +01:00
Peter Steinberger
f925ae5ae7
fix: isolate exact review dispatch concurrency 2026-05-06 07:54:01 +01:00
Peter Steinberger
dca9feb029
fix: route bundled skills to ClawHub 2026-05-06 07:49:12 +01:00
Val Alexander
12314945e8
fix: require runtime proof for screenshot-only reviews 2026-05-05 22:02:52 -05:00
stainlu
fb9c2f32fd
perf: cache review prompt assets 2026-05-06 00:19:35 +01:00
Fer Frau Roca
e82a802a67
fix: scope generated plan cleanup to configured dir 2026-05-06 00:18:39 +01:00
Fer Frau Roca
369c634b3c
feat: render work candidate coding plans 2026-05-06 00:18:39 +01:00
pashpashpash
0fefca282d
Merge pull request #48 from openclaw/codex/agent-led-proof-judgement
Let ClawSweeper judge real behavior proof
2026-05-05 16:10:22 -07:00
pashpashpash
37cbd19c19 fix: tell contributors how to refresh proof reviews 2026-05-05 16:05:09 -07:00
pashpashpash
90dd661e0d feat: judge real behavior proof evidence 2026-05-05 15:54:50 -07:00
Peter Steinberger
412df40759
fix: scope sweep dashboard status 2026-05-05 23:51:05 +01:00
Peter Steinberger
4a3be5222d
docs: credit landed rate-limit prs 2026-05-05 23:49:33 +01:00
stainlu
ec1ea902a3
perf: cache comment router label lookups 2026-05-05 23:49:08 +01:00
stain lu
2477968992
perf: reduce live worker capacity probes 2026-05-05 23:48:24 +01:00
Peter Steinberger
ee25a76eee
docs: thank contributor for landed perf prs
Some checks failed
CI / pnpm check (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (actions) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (push) Has been cancelled
Pages / Deploy docs (push) Has been cancelled
2026-05-05 08:24:51 +01:00
stainlu
2ba139fcd6
feat: record review cost telemetry 2026-05-05 08:24:04 +01:00
stainlu
00d7037915
perf: lazily compact review context 2026-05-05 08:23:37 +01:00
stainlu
f512051d79
perf: request larger github pages 2026-05-05 08:22:23 +01:00
stain lu
eb0407f59a
perf: skip untargeted repair snippet scans 2026-05-05 08:21:45 +01:00
Peter Steinberger
20ed692452
docs: clarify ClawSweeper self-hosting boundary 2026-05-05 07:10:50 +01:00
Peter Steinberger
2a2546b71f
fix: allow same-head repair retry 2026-05-05 06:38:36 +01:00
Peter Steinberger
6e9773534c
fix: retry repair target checkouts 2026-05-05 06:33:38 +01:00
pashpashpash
eae122c38f
review: require real behavior proof (#40) 2026-05-05 05:41:05 +01:00
Peter Steinberger
b033b73435
fix: count active workflow runs for worker scheduling 2026-05-05 04:34:50 +01:00
Peter Steinberger
e923eac212
docs: add readme worker budget summary 2026-05-05 01:30:17 +01:00
Peter Steinberger
5d54e8660a
docs: explain worker budget scheduler 2026-05-05 01:29:50 +01:00
Peter Steinberger
1a45767d1b
fix: fallback fork workflow repairs to replacement prs 2026-05-05 01:09:54 +01:00
Peter Steinberger
16cced2111
feat: centralize worker budget scheduling 2026-05-05 00:57:58 +01:00
Peter Steinberger
42f414194b
fix: retry codex edit rate limits 2026-05-05 00:47:09 +01:00
Peter Steinberger
d821944133
chore: lower automation limits 2026-05-05 00:45:06 +01:00
Peter Steinberger
ae3cd4988b
fix: give final repair review findings one more pass 2026-05-05 00:31:47 +01:00
Peter Steinberger
e2942b1739
ci: include limits config in comment router 2026-05-05 00:27:59 +01:00
Peter Steinberger
925728aee9
fix: keep positional workflow limit paths 2026-05-05 00:26:30 +01:00
Patrick Erichsen
86376d8f43
Merge pull request #38 from openclaw/codex/link-fix-pr-in-close-comments
[codex] link fixing PRs in close comments
2026-05-04 16:09:13 -07:00
Peter Steinberger
be899d2fe3
ci: include limits config in sparse checkout 2026-05-05 00:06:13 +01:00
Peter Steinberger
3002e45562
fix: self-heal failed live repair runs 2026-05-05 00:03:23 +01:00
Peter Steinberger
2955367141
chore: centralize automation limits 2026-05-05 00:02:58 +01:00
Patrick Erichsen
0761da3e82 lookup fixing PRs from fixed commits 2026-05-04 15:51:50 -07:00
Peter Steinberger
ed8c25371c
perf: avoid repeated final-base validation loops 2026-05-04 23:39:02 +01:00
Peter Steinberger
451b27a189
chore: reduce worker fan-out defaults 2026-05-04 23:37:05 +01:00
Peter Steinberger
c941f8a27a
feat: reference merged closing PRs in issue closes 2026-05-04 23:34:08 +01:00
Peter Steinberger
636bee7d49
fix: preserve validation diagnostics for repair prompts 2026-05-04 23:23:59 +01:00
Peter Steinberger
abd0febb24
fix: skip duplicate active self-heal repairs 2026-05-04 23:21:19 +01:00
Peter Steinberger
dd15176f9f
fix: fetch repair PR heads from target refs
Some checks are pending
CI / pnpm check (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (push) Waiting to run
Pages / Deploy docs (push) Waiting to run
2026-05-04 20:51:11 +01:00
Peter Steinberger
09e9daf950
fix: cap repair prompt payloads 2026-05-04 10:14:59 +01:00
Peter Steinberger
f638537aa3
fix: block workflow-permission repair pushes 2026-05-04 07:06:51 +01:00
Peter Steinberger
f72071e2ec
chore: reduce review worker pressure 2026-05-04 02:21:54 +01:00
Peter Steinberger
682658e7d8
fix: finalize failed repair progress 2026-05-04 01:13:25 +01:00
Peter Steinberger
9c2cf8c3b8
docs: record commit review page limit 2026-05-03 23:18:52 +01:00
Peter Steinberger
faf5042244
fix: limit commit review page size 2026-05-03 23:04:32 +01:00
Peter Steinberger
460bb0dd35
ci: skip forwarded sync activity 2026-05-03 22:20:15 +01:00
Peter Steinberger
3ef87d364a
ci: skip routine activity before checkout 2026-05-03 22:16:59 +01:00
Alex Knight
063360a8cc
Merge pull request #30 from openclaw/codex/fix-human-review-stop-replay
Fix paused automerge and re-review progress
2026-05-04 07:06:04 +10:00
Alex Knight
5a9f2d09a0 fix: keep stopped automerge paused 2026-05-04 07:03:04 +10:00
Peter Steinberger
f9a6708575
ci: tolerate runner node in activity lane 2026-05-03 22:02:07 +01:00
Peter Steinberger
3171d7d329
ci: remove setup-node from activity lane 2026-05-03 22:00:19 +01:00
Peter Steinberger
c67b2d83cb
fix: explain human review pauses 2026-05-03 21:41:33 +01:00
Peter Steinberger
d1c11f4a1e
chore: bump version to 0.2.1 2026-05-03 21:37:56 +01:00
Peter Steinberger
a48a724573
docs: add social preview card
Some checks are pending
CI / pnpm check (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (actions) (push) Waiting to run
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (push) Waiting to run
Pages / Deploy docs (push) Waiting to run
2026-05-03 20:21:32 +01:00
Peter Steinberger
4bc090e812
docs: refresh website and docs 2026-05-03 20:14:44 +01:00
Peter Steinberger
2da3c49074
docs: add emojis to readme title 2026-05-03 20:10:25 +01:00
Peter Steinberger
3b95ab5d5a
docs: improve readme overview 2026-05-03 20:08:41 +01:00
Peter Steinberger
ebccd34fc9
docs: improve release changelog flow 2026-05-03 20:04:15 +01:00
64 changed files with 5284 additions and 541 deletions

View File

@ -30,6 +30,7 @@ jobs:
with:
sparse-checkout: |
.github
config
docs
instructions
prompts

View File

@ -150,6 +150,7 @@ jobs:
- name: Select commits
id: select
env:
GH_TOKEN: ${{ github.token }}
TARGET_REPO: ${{ steps.target.outputs.target_repo }}
TARGET_NAME: ${{ steps.target.outputs.target_checkout_dir }}
TARGET_TOKEN: ${{ steps.target-read-token.outputs.token }}
@ -159,8 +160,33 @@ jobs:
ENABLED: ${{ steps.mode.outputs.enabled }}
SOURCE_REF: ${{ github.event.client_payload.ref || 'refs/heads/main' }}
SETTLE_SECONDS: ${{ vars.CLAWSWEEPER_COMMIT_REVIEW_SETTLE_SECONDS || '60' }}
PAGE_SIZE: ${{ vars.CLAWSWEEPER_COMMIT_REVIEW_PAGE_SIZE || '' }}
run: |
set -euo pipefail
active_run_count() {
gh run list --repo "${{ github.repository }}" --limit 100 --json workflowName,status 2>/dev/null \
| WORKFLOW_NAME="$1" jq '[.[] | select(.workflowName == env.WORKFLOW_NAME) | select(.status == "in_progress" or .status == "pending" or .status == "queued" or .status == "waiting" or .status == "requested")] | length' 2>/dev/null \
|| printf '0'
}
active_sweep_background_workers() {
local normal_limit hot_limit
normal_limit="$(pnpm --dir clawsweeper run --silent workflow -- limit review_shards.normal_default)"
hot_limit="$(pnpm --dir clawsweeper run --silent workflow -- limit review_shards.hot_intake_default)"
gh run list --repo "${{ github.repository }}" --limit 100 --json workflowName,displayTitle,status 2>/dev/null \
| NORMAL_LIMIT="$normal_limit" HOT_LIMIT="$hot_limit" jq '[.[] | select(.workflowName == "ClawSweeper") | select(.status == "in_progress" or .status == "pending" or .status == "queued" or .status == "waiting" or .status == "requested") | if .displayTitle == "Review ClawSweeper items" then (env.NORMAL_LIMIT | tonumber) elif .displayTitle == "Review hot ClawSweeper items" then (env.HOT_LIMIT | tonumber) else 0 end] | add // 0' 2>/dev/null \
|| printf '0'
}
active_sweep_exact_count() {
gh run list --repo "${{ github.repository }}" --limit 100 --json workflowName,displayTitle,status 2>/dev/null \
| jq '[.[] | select(.workflowName == "ClawSweeper") | select(.status == "in_progress" or .status == "pending" or .status == "queued" or .status == "waiting" or .status == "requested") | select(.displayTitle | startswith("Review event item "))] | length' 2>/dev/null \
|| printf '0'
}
if [ -z "$PAGE_SIZE" ]; then
active_critical_workers="$(( $(active_run_count "repair cluster worker") + $(active_sweep_exact_count) ))"
active_background_workers="$(active_sweep_background_workers)"
PAGE_SIZE="$(pnpm --dir clawsweeper run --silent workflow -- worker-limit commit_review --active-critical "$active_critical_workers" --active-background "$active_background_workers")"
fi
page_size_hard_cap="$(pnpm --dir clawsweeper run --silent workflow -- limit commit_review.page_size_hard_cap)"
if [ "$ENABLED" = "false" ]; then
{
echo "matrix=[]"
@ -195,6 +221,16 @@ jobs:
echo "Invalid settle wait: $SETTLE_SECONDS" >&2
exit 1
fi
if ! printf '%s' "$PAGE_SIZE" | grep -Eq '^[0-9]+$'; then
echo "Invalid commit review page size: $PAGE_SIZE" >&2
exit 1
fi
if [ "$PAGE_SIZE" -lt 1 ]; then
PAGE_SIZE=1
fi
if [ "$PAGE_SIZE" -gt "$page_size_hard_cap" ]; then
PAGE_SIZE="$page_size_hard_cap"
fi
if [ "$SETTLE_SECONDS" -gt 0 ]; then
echo "Waiting ${SETTLE_SECONDS}s for target main to settle before selecting commits."
sleep "$SETTLE_SECONDS"
@ -217,7 +253,7 @@ jobs:
commits="$COMMIT_SHA"
fi
total_count="$(printf '%s\n' "$commits" | sed '/^$/d' | wc -l | tr -d ' ')"
page_size=256
page_size="$PAGE_SIZE"
start_line=$((COMMIT_OFFSET + 1))
end_line=$((COMMIT_OFFSET + page_size))
selected_commits="$(printf '%s\n' "$commits" | sed -n "${start_line},${end_line}p")"
@ -341,7 +377,6 @@ jobs:
--codex-model gpt-5.5 \
--codex-reasoning-effort high \
--codex-sandbox danger-full-access \
--codex-service-tier fast \
--codex-timeout-ms 1800000
- name: Create target checks token

View File

@ -62,6 +62,12 @@ concurrency:
jobs:
notify:
if: >-
${{
!(github.event_name == 'pull_request_target' && github.event.action == 'synchronize') &&
!(github.event_name == 'repository_dispatch' && github.event.client_payload.activity.type == 'pull_request_target' && github.event.client_payload.activity.action == 'synchronize') &&
!(github.event_name == 'workflow_run' && contains(fromJSON('["success","neutral","skipped"]'), github.event.workflow_run.conclusion))
}}
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 10
steps:
@ -70,14 +76,11 @@ jobs:
filter: blob:none
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version: "24"
- name: Prepare notifier runtime
timeout-minutes: 5
run: |
set -euo pipefail
node --version
corepack enable
for attempt in 1 2 3; do
if corepack prepare "pnpm@10.33.2" --activate; then

View File

@ -243,6 +243,7 @@ jobs:
permission-contents: write
permission-issues: write
permission-pull-requests: write
permission-workflows: write
- uses: ./.github/actions/setup-state
with:

View File

@ -79,6 +79,7 @@ jobs:
fetch-depth: 0
sparse-checkout: |
.github
config
jobs
results
scripts/hydrate-state.ts

View File

@ -143,12 +143,15 @@ jobs:
if: ${{ steps.prepare.outputs.should_repair == 'true' }}
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
MAX_LIVE_WORKERS: ${{ vars.CLAWSWEEPER_AUTO_IMPLEMENT_MAX_LIVE_WORKERS || '50' }}
MAX_LIVE_WORKERS: ${{ vars.CLAWSWEEPER_AUTO_IMPLEMENT_MAX_LIVE_WORKERS || '' }}
RUNNER: ${{ github.event.inputs.runner || vars.CLAWSWEEPER_WORKER_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
EXECUTION_RUNNER: ${{ github.event.inputs.execution_runner || vars.CLAWSWEEPER_EXECUTION_RUNNER || 'blacksmith-16vcpu-ubuntu-2404' }}
MODEL: ${{ github.event.inputs.model || vars.CLAWSWEEPER_MODEL || 'gpt-5.5' }}
run: |
set -euo pipefail
if [ -z "$MAX_LIVE_WORKERS" ]; then
MAX_LIVE_WORKERS="$(pnpm run --silent workflow -- worker-limit issue_implementation)"
fi
git pull --rebase
pnpm run repair:dispatch -- "${{ steps.prepare.outputs.job_path }}" \
--mode autonomous \

View File

@ -72,9 +72,9 @@ on:
required: false
default: "600000"
shard_count:
description: "Parallel shards (capped at 100)"
description: "Parallel shards (capped by config/automation-limits.json)"
required: false
default: "100"
default: "70"
item_number:
description: "Optional single issue/PR number to review"
required: false
@ -133,7 +133,7 @@ env:
CLAWSWEEPER_APP_CLIENT_ID: Iv23liOECG0slfuhz093
concurrency:
group: ${{ github.event_name == 'repository_dispatch' && format('clawsweeper-event-{0}-{1}', github.event.client_payload.target_repo || 'openclaw/openclaw', github.event.client_payload.item_number || github.run_id) || format('{0}-{1}', (github.event_name == 'workflow_dispatch' && github.event.inputs.apply_existing == 'true' && github.event.inputs.apply_sync_comments_only == 'true') && 'clawsweeper-comment-sync' || ((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 * * * *'))) && 'clawsweeper-apply' || (github.event_name == 'workflow_dispatch' && github.event.inputs.hot_intake == 'true' && (github.event.inputs.item_number != '' || github.event.inputs.item_numbers != '')) && format('clawsweeper-intake-exact-{0}', github.event.inputs.item_number || github.event.inputs.item_numbers) || ((github.event_name == 'workflow_dispatch' && github.event.inputs.hot_intake == 'true') || (github.event_name == 'schedule' && (github.event.schedule == '*/5 * * * *' || github.event.schedule == '2/5 * * * *'))) && 'clawsweeper-intake-v2' || ((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 * * *'))) && 'clawsweeper-audit' || 'clawsweeper-review', github.event.inputs.target_repo || github.event.client_payload.target_repo || ((github.event.schedule == '17 */6 * * *') && 'openclaw/clawsweeper' || ((github.event.schedule == '2/5 * * * *' || github.event.schedule == '22 * * * *' || github.event.schedule == '8,23,38,53 * * * *' || github.event.schedule == '12 */6 * * *') && 'openclaw/clawhub' || 'openclaw/openclaw'))) }}
group: ${{ github.event_name == 'repository_dispatch' && format('clawsweeper-event-{0}-{1}', github.event.client_payload.target_repo || 'openclaw/openclaw', github.event.client_payload.item_number || github.run_id) || format('{0}-{1}', (github.event_name == 'workflow_dispatch' && github.event.inputs.apply_existing == 'true' && github.event.inputs.apply_sync_comments_only == 'true') && 'clawsweeper-comment-sync' || ((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 * * * *'))) && 'clawsweeper-apply' || (github.event_name == 'workflow_dispatch' && (github.event.inputs.item_number != '' || github.event.inputs.item_numbers != '')) && format('clawsweeper-intake-exact-{0}', github.event.inputs.item_number || github.event.inputs.item_numbers) || ((github.event_name == 'workflow_dispatch' && github.event.inputs.hot_intake == 'true') || (github.event_name == 'schedule' && (github.event.schedule == '*/5 * * * *' || github.event.schedule == '2/5 * * * *'))) && 'clawsweeper-intake-v2' || ((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 * * *'))) && 'clawsweeper-audit' || 'clawsweeper-review', github.event.inputs.target_repo || github.event.client_payload.target_repo || ((github.event.schedule == '17 */6 * * *') && 'openclaw/clawsweeper' || ((github.event.schedule == '2/5 * * * *' || github.event.schedule == '22 * * * *' || github.event.schedule == '8,23,38,53 * * * *' || github.event.schedule == '12 */6 * * *') && 'openclaw/clawhub' || 'openclaw/openclaw'))) }}
cancel-in-progress: ${{ github.event_name == 'repository_dispatch' }}
jobs:
@ -257,6 +257,24 @@ jobs:
with:
build-script: build:all
- name: Mark re-review command in progress
continue-on-error: true
env:
GH_TOKEN: ${{ steps.target-write-token.outputs.token }}
TARGET_REPO: ${{ steps.target.outputs.target_repo }}
ITEM_NUMBER: ${{ steps.target.outputs.item_number }}
COMMAND_STATUS_MARKER: ${{ github.event.client_payload.command_status_marker || '' }}
RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
pnpm run repair:update-command-status -- \
--repo "$TARGET_REPO" \
--item-number "$ITEM_NUMBER" \
--marker "$COMMAND_STATUS_MARKER" \
--state "Review in progress" \
--detail "Targeted re-review run started; Codex is reviewing the item." \
--run-url "$RUN_URL" \
--wait-ms 120000
- uses: ./.github/actions/setup-codex
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
@ -293,6 +311,7 @@ jobs:
git -C "$checkout_dir" rev-parse --short HEAD
- name: Review exact event item
id: review-exact-event-item
continue-on-error: true
env:
GH_TOKEN: ${{ steps.target-write-token.outputs.token }}
@ -313,7 +332,6 @@ jobs:
--codex-model gpt-5.5 \
--codex-reasoning-effort high \
--codex-sandbox danger-full-access \
--codex-service-tier fast \
--codex-timeout-ms 600000 \
--item-numbers "${{ steps.target.outputs.item_number }}" \
--readonly-openclaw \
@ -330,6 +348,7 @@ jobs:
token: ${{ steps.state-token.outputs.token }}
- name: Publish event result and apply safe close
id: publish-event-result
env:
GH_TOKEN: ${{ steps.target-write-token.outputs.token }}
REPO_TOKEN: ${{ github.token }}
@ -340,6 +359,7 @@ jobs:
run: pnpm run repair:publish-event-result
- name: Route synced ClawSweeper verdict
id: route-synced-verdict
timeout-minutes: 4
env:
GH_TOKEN: ${{ steps.target-write-token.outputs.token }}
@ -361,6 +381,33 @@ jobs:
--max-comments "$CLAWSWEEPER_COMMENT_MAX_COMMENTS" \
--execute
- name: Mark re-review complete
if: ${{ always() && steps.setup-pnpm.outcome == 'success' }}
continue-on-error: true
env:
GH_TOKEN: ${{ steps.target-write-token.outputs.token }}
TARGET_REPO: ${{ steps.target.outputs.target_repo }}
ITEM_NUMBER: ${{ steps.target.outputs.item_number }}
COMMAND_STATUS_MARKER: ${{ github.event.client_payload.command_status_marker || '' }}
RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
REVIEW_OUTCOME: ${{ steps.review-exact-event-item.outcome }}
PUBLISH_OUTCOME: ${{ steps.publish-event-result.outcome }}
ROUTE_OUTCOME: ${{ steps.route-synced-verdict.outcome }}
run: |
state="Failed"
detail="The targeted re-review did not finish cleanly. Check the workflow run for details."
if [ "$REVIEW_OUTCOME" = "success" ] && [ "$PUBLISH_OUTCOME" = "success" ] && [ "$ROUTE_OUTCOME" = "success" ]; then
state="Complete"
detail="The targeted re-review finished, the durable review comment was updated, and the synced verdict was routed."
fi
pnpm run repair:update-command-status -- \
--repo "$TARGET_REPO" \
--item-number "$ITEM_NUMBER" \
--marker "$COMMAND_STATUS_MARKER" \
--state "$state" \
--detail "$detail" \
--run-url "$RUN_URL"
- name: Commit event comment router ledger
if: ${{ !cancelled() && steps.setup-pnpm.outcome == 'success' }}
run: |
@ -538,27 +585,64 @@ jobs:
continue-on-error: true
working-directory: clawsweeper
run: |
target_slug="${{ steps.target.outputs.target_repo }}"
target_slug="${target_slug//\//-}"
pnpm run status -- \
--target-repo "${{ steps.target.outputs.target_repo }}" \
--state "Planning review" \
--detail "Planner is scanning GitHub for the next review candidates. Candidate counts and shard details will be posted after planning completes." \
--run-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
pnpm run repair:publish-main -- \
--message "chore: mark sweep planning started" \
--path results/sweep-status \
--path "results/sweep-status/${target_slug}.json" \
--rebase-strategy theirs
- id: mode
env:
GH_TOKEN: ${{ github.token }}
run: |
limit() {
pnpm --dir clawsweeper run --silent workflow -- limit "$1"
}
worker_limit() {
pnpm --dir clawsweeper run --silent workflow -- worker-limit "$@"
}
active_run_count() {
gh run list --repo "${{ github.repository }}" --limit 100 --json workflowName,status 2>/dev/null \
| WORKFLOW_NAME="$1" jq '[.[] | select(.workflowName == env.WORKFLOW_NAME) | select(.status == "in_progress" or .status == "pending" or .status == "queued" or .status == "waiting" or .status == "requested")] | length' 2>/dev/null \
|| printf '0'
}
active_sweep_exact_count() {
gh run list --repo "${{ github.repository }}" --limit 100 --json workflowName,displayTitle,status 2>/dev/null \
| jq '[.[] | select(.workflowName == "ClawSweeper") | select((.status == "in_progress" or .status == "pending" or .status == "queued" or .status == "waiting" or .status == "requested") and (.displayTitle | startswith("Review event item ")))] | length' 2>/dev/null \
|| printf '0'
}
active_sweep_background_workers() {
local normal_limit hot_limit
normal_limit="$(limit review_shards.normal_default)"
hot_limit="$(limit review_shards.hot_intake_default)"
gh run list --repo "${{ github.repository }}" --limit 100 --json databaseId,workflowName,displayTitle,status 2>/dev/null \
| CURRENT_RUN_ID="${GITHUB_RUN_ID:-0}" NORMAL_LIMIT="$normal_limit" HOT_LIMIT="$hot_limit" jq '[.[] | select((.databaseId | tostring) != env.CURRENT_RUN_ID) | select(.workflowName == "ClawSweeper") | select(.status == "in_progress" or .status == "pending" or .status == "queued" or .status == "waiting" or .status == "requested") | if .displayTitle == "Review ClawSweeper items" then (env.NORMAL_LIMIT | tonumber) elif .displayTitle == "Review hot ClawSweeper items" then (env.HOT_LIMIT | tonumber) else 0 end] | add // 0' 2>/dev/null \
|| printf '0'
}
exact_item_shards="$(limit review_shards.exact_item_default)"
normal_active_floor="$(limit review_shards.normal_active_floor)"
hard_shard_cap="$(limit review_shards.hard_cap)"
commit_page_size="$(limit commit_review.page_size_default)"
active_critical_workers="$(( $(active_run_count "repair cluster worker") + $(active_sweep_exact_count) ))"
active_background_workers="$(( $(active_run_count "ClawSweeper Commit Review") * commit_page_size + $(active_sweep_background_workers) ))"
hot_intake_shards="$(worker_limit hot_intake --active-critical "$active_critical_workers" --active-background "$active_background_workers")"
normal_shards="$(worker_limit normal_review --active-critical "$active_critical_workers" --active-background "$active_background_workers")"
hot_intake="${{ ((github.event_name == 'workflow_dispatch' && github.event.inputs.hot_intake == 'true') || (github.event_name == 'schedule' && (github.event.schedule == '*/5 * * * *' || github.event.schedule == '2/5 * * * *'))) && 'true' || 'false' }}"
exact_item="${{ github.event.client_payload.item_number || github.event.inputs.item_number || github.event.inputs.item_numbers || '' }}"
target_repo="${{ steps.target.outputs.target_repo }}"
if [ "$hot_intake" = "true" ] && [ -n "$exact_item" ]; then
batch_size="1"
shard_count="1"
shard_count="$exact_item_shards"
max_pages="1"
elif [ "$hot_intake" = "true" ]; then
batch_size="1"
shard_count="50"
shard_count="$hot_intake_shards"
max_pages="10"
min_active_shards="0"
min_backfill_review_age_minutes="30"
@ -569,11 +653,14 @@ jobs:
batch_size="${{ github.event.inputs.batch_size || '3' }}"
fi
if [ "$target_repo" = "openclaw/openclaw" ]; then
min_active_shards="50"
min_active_shards="$normal_active_floor"
else
min_active_shards="0"
fi
shard_count="${{ github.event.inputs.shard_count || '100' }}"
shard_count="${{ github.event.inputs.shard_count || '' }}"
if [ -z "$shard_count" ]; then
shard_count="$normal_shards"
fi
max_pages="250"
min_backfill_review_age_minutes="30"
fi
@ -582,10 +669,10 @@ jobs:
min_backfill_review_age_minutes="30"
fi
if ! [[ "$shard_count" =~ ^[0-9]+$ ]]; then
shard_count="100"
shard_count="$normal_shards"
fi
if [ "$shard_count" -gt 100 ]; then
shard_count="100"
if [ "$shard_count" -gt "$hard_shard_cap" ]; then
shard_count="$hard_shard_cap"
fi
{
echo "batch_size=$batch_size"
@ -631,7 +718,6 @@ jobs:
--codex-model gpt-5.5 \
--codex-reasoning-effort high \
--codex-sandbox danger-full-access \
--codex-service-tier fast \
--min-active-shards "$MIN_ACTIVE_SHARDS" \
--min-backfill-review-age-minutes "$MIN_BACKFILL_REVIEW_AGE_MINUTES" \
"${hot_intake_arg[@]}" \
@ -648,8 +734,12 @@ jobs:
working-directory: clawsweeper
env:
GH_TOKEN: ${{ github.token }}
TARGET_REPO: ${{ steps.target.outputs.target_repo }}
run: |
target_slug="$TARGET_REPO"
target_slug="${target_slug//\//-}"
pnpm run status -- \
--target-repo "$TARGET_REPO" \
--state "${{ steps.mode.outputs.hot_intake == 'true' && 'Hot intake in progress' || 'Review in progress' }}" \
--detail "${{ steps.mode.outputs.hot_intake == 'true' && 'Hot intake planned' || 'Planned' }} ${{ steps.select.outputs.planned_count }} items across ${{ steps.select.outputs.planned_shards }} shards. Capacity is ${{ steps.select.outputs.planned_capacity }} items; due backlog scanned is ${{ steps.select.outputs.due_backlog }}. Capacity reason: ${{ steps.select.outputs.capacity_reason }}. Review shards are starting; publish will merge artifacts when they finish." \
--run-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
@ -662,7 +752,7 @@ jobs:
--capacity-reason "${{ steps.select.outputs.capacity_reason }}"
timeout 20s pnpm run repair:publish-main -- \
--message "chore: mark sweep review in progress" \
--path results/sweep-status \
--path "results/sweep-status/${target_slug}.json" \
--rebase-strategy theirs || echo "::warning::Skipped slow in-progress dashboard publish so review shards can start."
review:
@ -699,6 +789,18 @@ jobs:
permission-issues: write
permission-pull-requests: read
- name: Create target Codex inspection token
id: codex-inspection-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }}
private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}
owner: ${{ needs.plan.outputs.target_repo_owner }}
repositories: ${{ needs.plan.outputs.target_repo_name }}
permission-contents: read
permission-issues: read
permission-pull-requests: read
- uses: actions/setup-node@v6
with:
node-version: 24
@ -752,6 +854,7 @@ jobs:
working-directory: clawsweeper
env:
GH_TOKEN: ${{ steps.target-read-token.outputs.token }}
CLAWSWEEPER_PROOF_INSPECTION_TOKEN: ${{ steps.codex-inspection-token.outputs.token }}
ADDITIONAL_PROMPT: ${{ github.event.inputs.additional_prompt || github.event.client_payload.additional_prompt || '' }}
run: |
hot_intake_arg=()
@ -780,7 +883,6 @@ jobs:
--codex-model gpt-5.5 \
--codex-reasoning-effort high \
--codex-sandbox danger-full-access \
--codex-service-tier fast \
--codex-timeout-ms ${{ needs.plan.outputs.codex_timeout_ms }} \
--item-numbers "${{ matrix.item_numbers }}" \
"${hot_intake_arg[@]}" \
@ -921,6 +1023,7 @@ jobs:
fi
pnpm run apply-artifacts -- "${apply_artifacts_args[@]}"
pnpm run status -- \
--target-repo "$TARGET_REPO" \
--state "$publish_state" \
--detail "$publish_detail" \
--run-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
@ -939,6 +1042,8 @@ jobs:
HOT_INTAKE: ${{ needs.plan.outputs.hot_intake }}
TARGET_REPO: ${{ needs.plan.outputs.target_repo }}
run: |
target_slug="$TARGET_REPO"
target_slug="${target_slug//\//-}"
if [ "$HOT_INTAKE" = "true" ] && [ -z "$EXACT_ITEM" ]; then
echo "Skipping full reconcile for broad hot-intake publish."
else
@ -946,8 +1051,8 @@ jobs:
fi
pnpm run repair:publish-main -- \
--message "chore: update sweep records" \
--path records \
--path results/sweep-status \
--path "records/${target_slug}" \
--path "results/sweep-status/${target_slug}.json" \
--rebase-strategy theirs
- name: Dispatch reproducible bug implementation candidates
@ -956,9 +1061,12 @@ jobs:
GH_TOKEN: ${{ github.token }}
TARGET_REPO: ${{ needs.plan.outputs.target_repo }}
ENABLED: ${{ vars.CLAWSWEEPER_AUTO_IMPLEMENT_REPRO_BUGS == '1' && 'true' || 'false' }}
MAX_DISPATCH: ${{ vars.CLAWSWEEPER_AUTO_IMPLEMENT_MAX_DISPATCH_PER_SWEEP || '5' }}
MAX_DISPATCH: ${{ vars.CLAWSWEEPER_AUTO_IMPLEMENT_MAX_DISPATCH_PER_SWEEP || '' }}
run: |
set -euo pipefail
if [ -z "$MAX_DISPATCH" ]; then
MAX_DISPATCH="$(pnpm run --silent workflow -- limit issue_implementation.dispatches_per_sweep_default)"
fi
candidate_output="$(pnpm run --silent repair:issue-implementation-intake -- candidates \
--enabled "$ENABLED" \
--target-repo "$TARGET_REPO" \
@ -975,7 +1083,7 @@ jobs:
fi
CANDIDATES_JSON="$candidates_json" MAX_DISPATCH="$MAX_DISPATCH" node <<'NODE' > /tmp/issue-implementation-candidates.tsv
const candidates = JSON.parse(process.env.CANDIDATES_JSON || "[]");
const limit = Math.max(0, Number(process.env.MAX_DISPATCH || "5"));
const limit = Math.max(0, Number(process.env.MAX_DISPATCH || "0"));
for (const candidate of candidates.slice(0, limit)) {
console.log([
candidate.item_number,
@ -1037,6 +1145,8 @@ jobs:
run: |
set -euo pipefail
test -n "$GH_TOKEN"
target_slug="$TARGET_REPO"
target_slug="${target_slug//\//-}"
item_numbers="$(pnpm run --silent workflow -- artifact-item-numbers --artifact-dir artifacts)"
if [ -z "$item_numbers" ]; then
echo "No review artifacts to sync comments for."
@ -1056,6 +1166,7 @@ jobs:
--progress-every 25
synced_count="$(pnpm run --silent workflow -- count-actions --report apply-report.json --action review_comment_synced)"
pnpm run status -- \
--target-repo "$TARGET_REPO" \
--state "Review comments checked" \
--detail "Checked selected durable Codex review comments and synced missing or stale comments. Synced: $synced_count. Item numbers: $item_numbers." \
--run-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
@ -1068,9 +1179,9 @@ jobs:
--capacity-reason "${{ needs.plan.outputs.capacity_reason }}"
pnpm run repair:publish-main -- \
--message "chore: sync selected review comments" \
--path records \
--path "records/${target_slug}" \
--path apply-report.json \
--path results/sweep-status \
--path "results/sweep-status/${target_slug}.json" \
--rebase-strategy theirs
- name: Apply selected safe close proposals
@ -1082,6 +1193,8 @@ jobs:
run: |
set -euo pipefail
test -n "$GH_TOKEN"
target_slug="$TARGET_REPO"
target_slug="${target_slug//\//-}"
item_numbers="$(pnpm run --silent workflow -- artifact-item-numbers --artifact-dir artifacts)"
if [ -z "$item_numbers" ]; then
echo "No review artifacts to apply."
@ -1106,6 +1219,7 @@ jobs:
--progress-every 1
closed_count="$(pnpm run --silent workflow -- count-actions --report apply-report.json --action closed)"
pnpm run status -- \
--target-repo "$TARGET_REPO" \
--state "Immediate apply checked" \
--detail "Checked selected safe close proposals right after review publish. Closed: $closed_count/$item_count. Close reasons enabled: $close_reasons. Item numbers: $item_numbers." \
--run-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
@ -1118,9 +1232,9 @@ jobs:
--capacity-reason "${{ needs.plan.outputs.capacity_reason }}"
pnpm run repair:publish-main -- \
--message "chore: apply selected sweep decisions" \
--path records \
--path "records/${target_slug}" \
--path apply-report.json \
--path results/sweep-status \
--path "results/sweep-status/${target_slug}.json" \
--rebase-strategy theirs
- name: Continue sweep
@ -1326,18 +1440,23 @@ jobs:
removed_stale_closed_copies="$(jq -r '.removedStaleClosedCopies' <<<"$reconcile_json")"
pnpm run audit -- --target-repo "$TARGET_REPO" --max-pages 250 --sample-limit 25 --output /tmp/clawsweeper-audit.json --update-dashboard
pnpm run status -- \
--target-repo "$TARGET_REPO" \
--state "Audit finished" \
--detail "Reconciled durable $TARGET_REPO records before audit: moved ${moved_to_closed} closed records, restored ${moved_to_items} reopened records, removed ${removed_stale_closed_copies} stale archived copies. Refreshed audit state from a full live scan." \
--run-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
- name: Commit Audit Health
env:
TARGET_REPO: ${{ steps.target.outputs.target_repo }}
run: |
target_slug="$TARGET_REPO"
target_slug="${target_slug//\//-}"
pnpm run repair:publish-main -- \
--message "chore: update sweep audit state" \
--path README.md \
--path records \
--path results/audit \
--path results/sweep-status \
--path "records/${target_slug}" \
--path "results/audit/${target_slug}.json" \
--path "results/sweep-status/${target_slug}.json" \
--rebase-strategy theirs
- name: Refresh state dashboard
@ -1497,6 +1616,7 @@ jobs:
exit 0
fi
pnpm run status -- \
--target-repo "$TARGET_REPO" \
--state "Apply in progress" \
--detail "Starting apply/comment-sync run for up to $limit fresh $apply_kind closes. Close reasons: $apply_close_reasons. Existing Codex automated review comments are updated in place when closing or when comment-only sync is stale by ${comment_sync_min_age_days} day(s); checkpoints commit every $checkpoint_size fresh closes; close delay is ${close_delay_ms}ms; sync-comments-only=$sync_comments_only; item numbers=${item_numbers:-all}." \
--run-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
@ -1530,6 +1650,7 @@ jobs:
synced_count="$(pnpm run --silent workflow -- count-actions --report ".artifacts/apply-reports/apply-report-$checkpoint.json" --action review_comment_synced)"
publish_changes "chore: sync sweep review comments checkpoint $checkpoint" records apply-report.json
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}." \
--run-url "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
@ -1571,6 +1692,7 @@ jobs:
echo "Checkpoint $checkpoint result_count=$result_count closed_in_chunk=$closed_in_chunk closed_total=$closed_total/$limit"
publish_changes "chore: apply sweep decisions checkpoint $checkpoint" records apply-report.json
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 }}"
@ -1586,6 +1708,7 @@ jobs:
fi
done
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 }}"
@ -1613,11 +1736,13 @@ jobs:
echo "No proposed closes were ready; no apply results to commit."
exit 0
fi
target_slug="$APPLY_TARGET_REPO"
target_slug="${target_slug//\//-}"
pnpm run repair:publish-main -- \
--message "chore: apply sweep decisions" \
--path records \
--path "records/${target_slug}" \
--path apply-report.json \
--path results/sweep-status \
--path "results/sweep-status/${target_slug}.json" \
--rebase-strategy apply-records
- name: Continue apply sweep
@ -1676,7 +1801,9 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
runs_json="$(gh run list --repo "${{ github.repository }}" --workflow sweep.yml --limit 80 --json displayTitle,status,createdAt)"
hot_intake_shards="$(pnpm run --silent workflow -- limit review_shards.hot_intake_default)"
normal_shards="$(pnpm run --silent workflow -- limit review_shards.normal_default)"
runs_json="$(gh run list --repo "${{ github.repository }}" --limit 100 --json workflowName,displayTitle,status,createdAt)"
eval "$(
RUNS_JSON="$runs_json" node <<'NODE'
const runs = JSON.parse(process.env.RUNS_JSON || "[]");
@ -1684,6 +1811,7 @@ jobs:
const active = new Set(["in_progress", "pending", "queued", "waiting", "requested"]);
function recent(title, windowMs) {
return runs.some((run) => {
if (run.workflowName !== "ClawSweeper") return false;
if (run.displayTitle !== title) return false;
if (active.has(String(run.status))) return true;
const createdAt = Date.parse(String(run.createdAt || ""));
@ -1717,7 +1845,7 @@ jobs:
-f hot_intake=true \
-f target_repo=openclaw/openclaw \
-f batch_size=1 \
-f shard_count=50 \
-f shard_count="$hot_intake_shards" \
-f codex_timeout_ms=600000
fi
@ -1729,6 +1857,6 @@ jobs:
-f hot_intake=false \
-f target_repo=openclaw/openclaw \
-f batch_size=3 \
-f shard_count=100 \
-f shard_count="$normal_shards" \
-f codex_timeout_ms=600000
fi

View File

@ -5,74 +5,164 @@ All notable ClawSweeper changes are tracked here.
This file was reconstructed from first-parent git history. Generated dashboard,
checkpoint, and status-only commits are intentionally omitted.
## 0.2.1 - Unreleased
### Added
- Added a light privacy reminder and stronger screenshot-or-video nudge to real behavior proof review guidance.
- Added agent-led real behavior proof judgement so ClawSweeper can inspect linked screenshots, videos, logs, and terminal output with a read-only GitHub token, explain the proof verdict in the review comment, tell contributors how to trigger a fresh review after adding proof, and sync `proof: sufficient` when the evidence is convincing.
- Added a real behavior proof assessment to PR reviews so missing, mock-only, or insufficient contributor proof blocks pass/automerge markers and asks for screenshots, terminal output, redacted logs, recordings, linked artifacts, or copied live output instead.
- Added `config/automation-limits.json` plus docs and a drift check so review,
commit-review, repair, and issue-implementation capacity defaults have one
checked-in source of truth.
- Replaced per-lane capacity config with a single `workers.max` budget and
dynamic background lane scheduling.
- Added generated coding-plan artifacts for fresh `queue_fix_pr` work candidates
and linked them from the dashboard work-candidate tables. Thanks @FerFroid.
- Added a generated 1200x630 social preview card plus large-image Open Graph and
Twitter metadata for the docs site.
### Fixed
- Gave manual exact-item review dispatches their own concurrency group so
targeted maintainer reviews no longer wait behind broad normal backfill runs.
- Downgraded screenshot-only browser runtime proof so ClawSweeper no longer accepts "no visible console/CSP violation" screenshots as sufficient real behavior proof. Thanks @BunsDev.
- Classified optional bundled skill PRs as `skill` items and routed skill-only
OpenClaw core additions to the ClawHub upload path with clearer close copy.
- Required generated public review comments to use full GitHub URLs for
cross-issue and cross-PR references instead of shorthand `#123` refs.
- Added `openclaw/fs-safe` as an event-driven review target with conservative
PR implemented-on-main close rules and issue review-only behavior.
- Scoped sweep record/status publishing to the active target repository slug so
concurrent runs for other repositories cannot overwrite newly added target
records from stale generated state.
- Added data-driven target repository config plus a conservative `openclaw/*`
fallback so newly installed OpenClaw repositories can use exact event review
without a TypeScript profile change.
- Reduced default worker fan-out by about 20% across review shards, hot intake,
commit review pages, repair live-worker caps, and automatic implementation
dispatches.
- Made background review lanes yield to active repair and exact-item work to
lower GitHub and Codex rate-limit pressure during busy periods.
- Fixed live worker scheduling to filter GitHub Actions runs through supported
`workflowName` JSON fields instead of silently falling back to zero active
workers when `gh run list --workflow` is unavailable.
- Reduced repair live-capacity polling from one GitHub Actions API request per
active status to a single recent-runs request filtered locally, and avoided an
immediate duplicate capacity probe in the dispatch loop.
- Cached comment-router open-label issue lookups per run so repair-loop comment
discovery and command synthesis do not repeat identical GitHub searches.
- Retried Codex edit workers after TPM/rate-limit exits and collapsed JSONL failure transcripts into concise repair status reasons.
- Added deterministic merged closing-PR provenance to issue close reports and
public close comments when GitHub exposes a high-confidence closing PR.
- Allowed repair cluster execute tokens to request workflow-file write
permission, so adopted automerge repairs can rebase PR branches that already
contain `.github/workflows/*` changes.
- Stopped forcing Codex fast mode in review and commit-review runs.
- Marked automerge repair loops as failed or blocked when fix execution ends on
an unrecovered Codex transport error, instead of leaving the PR timeline at a
running step.
- Marked GitHub App workflow-file push denials as blocked repair outcomes
instead of failing the repair worker after Codex prepares an otherwise useful
fix.
- Published already-prepared fork repairs as credited replacement PRs when
GitHub rejects the contributor-branch push because rebasing would create or
update workflow files without effective workflow permission.
- Capped repair Codex prompt payloads by compacting oversized fix artifacts and
repository snippets, and classified Codex context-limit responses as blocked
repair outcomes instead of red workflow failures.
- Fetched contributor PR repair heads through the target repository pull-request
ref instead of directly from contributor forks, and treated git fetch timeouts
and push timeouts as blocked repair outcomes.
- Skipped self-heal repair redispatches when the same repair job is already
queued or running, avoiding duplicate pending workers for active PR repairs.
- Let self-heal rediscover recent failed repair workers from live GitHub run
metadata when a hard execute failure happens before durable run records are
published.
- Included the automation limits config in the CI sparse checkout so the new
limits drift check can run on GitHub as well as locally.
- Accepted positional automation-limit paths in workflow utilities again so
high-volume commit-review and scheduler workflows keep using the compact
`workflow -- limit <path>` form.
- Included the automation limits config in the repair comment-router sparse
checkout so scheduled maintainer commands can load shared worker caps.
- Let the final internal Codex `/review` in a repair loop feed one last
review-fix pass before blocking, pushing only after changed-surface validation
passes so exact-head review and GitHub checks can finish the merge decision.
- Expanded validation-failure detail passed into Codex repair follow-up prompts
so lint/typecheck failures keep the actionable diagnostic instead of only the
package-manager epilogue.
- Reduced the default final-base sync loop to one local validation pass before
pushing the synchronized head, relying on exact-head review and GitHub checks
to gate fast-moving automerge branches.
- Limited commit-review fan-out to 6 commits per workflow page by default, with
a `CLAWSWEEPER_COMMIT_REVIEW_PAGE_SIZE` override for controlled backfills.
- Made trusted human-review and security-sensitive pause reasons include the
actionable review sections instead of only the structured marker.
- Removed `actions/setup-node` from the high-volume GitHub activity lane and
kept that notifier compatible with runner-provided Node 20+ so bursty
activity forwarding is not blocked by codeload action download timeouts.
- Switched repair target checkouts to retryable blobless Git clones with a
shorter per-attempt timeout, avoiding five-minute `gh repo clone` hangs before
Codex can repair a PR.
- Preferred human GitHub Actions URLs when reporting active repair workers,
avoiding API URLs in ClawSweeper status comments and dashboards.
- Raised the same-head automatic repair cap to two attempts so a transient
checkout or runner failure does not permanently block the PR head from a
retry.
- Skipped routine native and forwarded pull request synchronize events plus
successful workflow-run events before checkout in the GitHub activity lane.
- Kept human-review pauses from being cleared by stale trusted pass markers or
replayed automerge commands.
- Updated targeted re-review command comments with live progress while the review
workflow runs.
- Avoided full-file token scans for repair repository snippets when no discovery
tokens exist, keeping untargeted fix prompts cheaper to build.
- Requested 100-item REST pages for paginated GitHub list calls, reducing
review and repair API page fan-out on large issues and pull requests.
- Compacted review prompt context lazily so large comment, timeline, file, and
commit lists no longer process entries that are omitted from Codex input.
- Scoped every sweep workflow status write to the active target repository so
`openclaw/clawhub` and `openclaw/clawsweeper` runs no longer overwrite
`openclaw/openclaw` dashboard telemetry.
- Cached the static review prompt and decision schema within each ClawSweeper
process instead of re-reading them during review planning and item prompts.
- Thanks @stainlu for the repair prompt, GitHub pagination, lazy context
compaction, review telemetry, live-capacity probe, comment-router cache, and
prompt asset cache PRs.
## 0.2.0 - 2026-05-03
### Added
- Accepted `@clawsweeper fix` as a short issue implementation command that
creates or updates one guarded ClawSweeper PR for an open issue.
- Added an `openclaw/openclaw` active review-shard floor so scheduled normal
review keeps capacity warm around the clock even when the due backlog is
temporarily below full shard capacity.
- Added coarse automerge repair progress updates to the existing mutable status
timeline for validation, Codex edit, review, base-sync, and wait phases.
- Accepted `@clawsweeper fix` as a short issue implementation command that creates or updates one guarded ClawSweeper PR for an open issue.
- Added an `openclaw/openclaw` active review-shard floor so scheduled normal review keeps capacity warm around the clock even when the due backlog is temporarily below full shard capacity.
- Added coarse automerge repair progress updates to the existing mutable status timeline for validation, Codex edit, review, base-sync, and wait phases.
### Changed
- Switched the shared Codex setup action to a per-run `CODEX_HOME` with a local
Responses proxy so Codex subprocesses no longer inherit raw OpenAI/Codex API
key environment variables.
- Replaced duplicate-lobster command status badges with one lobster plus a
state emoji for acknowledgement, review, repair, and completed/paused work.
- Kept broad review continuations warm and faster by preserving the
`openclaw/openclaw` active shard floor, stopping saturated planning once
capacity is full, capping optional pre-shard dashboard publishes, and moving
broad continuation comment sync into the separate comment-sync lane.
- Removed the expensive record reconciler from pre-shard planning status so
review jobs can start without waiting on a full GitHub state scan; publish,
apply, and audit still reconcile before mutating records.
- Made read-only review planning hydrate generated state from a shallow checkout
instead of cloning the full generated-state history.
- Removed generated-state checkout and hydration from review shards; the planner
already passes exact item numbers, so shards can start Codex after checkout and
runtime setup instead of copying historical records first.
- Moved exact event review state hydration after the Codex review step so
maintainer-triggered single-item reviews can start the model before generated
records are copied.
- Made the GitHub activity notifier workflow use a lean uncached Node/pnpm setup
so bursty events do not wait on `actions/cache` downloads before notifying
OpenClaw.
- Wrapped review shard execution in a computed shell timeout so one hung broad
review shard records failed-shard artifacts and enters recovery instead of
blocking publish until the full GitHub job timeout.
- Updated sweep and commit-review artifact upload/download actions to their
Node 24-compatible versions so review runs no longer emit artifact action
runtime deprecation annotations.
- Switched the shared Codex setup action to a per-run `CODEX_HOME` with a local Responses proxy so Codex subprocesses no longer inherit raw OpenAI/Codex API key environment variables.
- Replaced duplicate-lobster command status badges with one lobster plus a state emoji for acknowledgement, review, repair, and completed/paused work.
- Kept broad review continuations warm and faster by preserving the `openclaw/openclaw` active shard floor, stopping saturated planning once capacity is full, capping optional pre-shard dashboard publishes, and moving broad continuation comment sync into the separate comment-sync lane.
- Removed the expensive record reconciler from pre-shard planning status so review jobs can start without waiting on a full GitHub state scan; publish, apply, and audit still reconcile before mutating records.
- Made read-only review planning hydrate generated state from a shallow checkout instead of cloning the full generated-state history.
- Removed generated-state checkout and hydration from review shards; the planner already passes exact item numbers, so shards can start Codex after checkout and runtime setup instead of copying historical records first.
- Moved exact event review state hydration after the Codex review step so maintainer-triggered single-item reviews can start the model before generated records are copied.
- Made the GitHub activity notifier workflow use a lean uncached Node/pnpm setup so bursty events do not wait on `actions/cache` downloads before notifying OpenClaw.
- Wrapped review shard execution in a computed shell timeout so one hung broad review shard records failed-shard artifacts and enters recovery instead of blocking publish until the full GitHub job timeout.
- Updated sweep and commit-review artifact upload/download actions to their Node 24-compatible versions so review runs no longer emit artifact action runtime deprecation annotations.
- Updated TypeScript tooling while preserving the existing `pnpm` workflow.
### Fixed
- Kept review continuations warm when the normal backlog is below the target
active shard floor.
- Retried transient Codex edit-pass transport failures where the Codex tool
router reports a closed stdin session, instead of failing the whole repair
worker after an otherwise recoverable automation run.
- Accepted scoped `scripts/run-opengrep.sh --error -- <paths>` validation hints
so automerge repair execution does not fail preflight before normalizing
OpenClaw repairs to the changed-surface gate.
- Accepted spaced `auto merge` command aliases everywhere `automerge` and
`auto-merge` are accepted, including the top-level `/auto merge` shorthand.
- Updated issue implementation command comments after a fix PR opens, linking
the generated PR from the original ClawSweeper status comment instead of
leaving the acknowledgement at "queued".
- Recovered issue implementation workers from state propagation races by
reconstructing minimal `source: issue_implementation` jobs from the dispatched
job path instead of skipping the worker as stale.
- Routed trusted ClawSweeper verdicts with P0/P1/P2/P3 findings through the
repair loop even when the same review also contains a pass marker.
- Made `/clawsweeper stop` revoke repair-loop labels and block older
automerge/autofix comments from continuing, so a trusted pass marker cannot
clear a human-review pause and merge after a maintainer stop.
- Kept review continuations warm when the normal backlog is below the target active shard floor.
- Retried transient Codex edit-pass transport failures where the Codex tool router reports a closed stdin session, instead of failing the whole repair worker after an otherwise recoverable automation run.
- Accepted scoped `scripts/run-opengrep.sh --error -- <paths>` validation hints so automerge repair execution does not fail preflight before normalizing OpenClaw repairs to the changed-surface gate.
- Accepted spaced `auto merge` command aliases everywhere `automerge` and `auto-merge` are accepted, including the top-level `/auto merge` shorthand.
- Updated issue implementation command comments after a fix PR opens, linking the generated PR from the original ClawSweeper status comment instead of leaving the acknowledgement at "queued".
- Recovered issue implementation workers from state propagation races by reconstructing minimal `source: issue_implementation` jobs from the dispatched job path instead of skipping the worker as stale.
- Routed trusted ClawSweeper verdicts with P0/P1/P2/P3 findings through the repair loop even when the same review also contains a pass marker.
- Made `/clawsweeper stop` revoke repair-loop labels and block older automerge/autofix comments from continuing, so a trusted pass marker cannot clear a human-review pause and merge after a maintainer stop.
## 0.1.0 - 2026-05-03

242
README.md
View File

@ -1,75 +1,103 @@
# ClawSweeper
# 🦞🧹 ClawSweeper
ClawSweeper is the conservative maintenance bot for OpenClaw repositories. It
currently covers `openclaw/openclaw`, `openclaw/clawhub`, and self-review for
`openclaw/clawsweeper`.
keeps the backlog reviewed, keeps maintainer-visible GitHub comments tidy, and
turns narrow trusted findings into guarded repair or automerge work.
It has two independent lanes:
The current production targets are `openclaw/openclaw`, `openclaw/clawhub`, and
self-review for `openclaw/clawsweeper`.
- issue/PR sweeper: keeps one markdown report per open issue or PR, publishes
one durable Codex automated review comment when useful, and only closes items
when the evidence is strong
- commit sweeper: reviews code-bearing commits that land on `main`, writes one
canonical markdown report per commit, and optionally publishes a GitHub Check
Run for that commit
The OpenClaw-hosted ClawSweeper instance is not a public review service and does
not provide free reviews for third-party repositories. If you want ClawSweeper
for your own project, fork this repository, deploy it in your own organization,
and configure that self-hosted instance for your repositories.
At a high level ClawSweeper:
- reviews open issues and pull requests on a schedule and on exact GitHub events
- writes one durable markdown report per item in generated state
- syncs one marker-backed public review comment per issue or PR, edited in place
- closes only unchanged, high-confidence, policy-allowed proposals
- routes maintainer commands such as `@clawsweeper review`,
`@clawsweeper fix`, `@clawsweeper autofix`, and `@clawsweeper automerge`
- repairs opted-in PRs through a bounded Codex review/fix loop before merge
- can open guarded implementation PRs for strict, reproducible bug issues
- reviews code-bearing commits that land on target `main` branches
- publishes dashboard, audit, repair, and activity state to
`openclaw/clawsweeper-state`
ClawSweeper is not a generic auto-close bot. Review is proposal-only, apply is
guarded, Codex never gets write credentials during review, and every GitHub
mutation is rechecked against live target state immediately before it happens.
## Capabilities
- **Repository profiles:** per-repository rules live in
`src/repository-profiles.ts`, so OpenClaw, ClawHub, and ClawSweeper can share
the same engine while keeping different apply limits.
- **Issue and PR intake:** scheduled runs scan open issues and pull requests,
while target repositories can forward exact issue/PR events with
`repository_dispatch` for low-latency one-item reviews.
- **Codex review reports:** each issue or PR becomes
`records/<repo-slug>/items/<number>.md` with the decision, evidence, proposed
maintainer-facing comment, runtime metadata, and GitHub snapshot hash.
- **Durable review comments:** ClawSweeper syncs one marker-backed public review
comment per item and edits it in place instead of posting repeated comments.
When a review starts and no ClawSweeper comment exists yet, it posts a short
crustacean-friendly status placeholder first, then replaces that same comment
with the completed review. Completed comments include a dedicated security
review section for supply-chain, permission, secret-handling, and code
execution concerns. Pull request comments include hidden verdict markers, and
actionable PR follow-up includes a hidden
`clawsweeper-action:fix-required` marker for the trusted ClawSweeper repair
loop. See
[`docs/pr-review-comments.md`](docs/pr-review-comments.md).
- **Guarded apply:** apply mode re-fetches live GitHub state, checks labels,
maintainer authorship, paired issue/PR state, snapshot drift, and repository
profile rules before commenting or closing anything.
- **Archive and reopen handling:** closed or already-closed reports move to
`records/<repo-slug>/closed/<number>.md`; reopened archived items move back to
`items/` as stale work.
- **Generated state:** `openclaw/clawsweeper-state` stores durable `records/`,
`jobs/`, `results/`, and rendered dashboard output so this repo stays focused
on source, workflows, docs, and tests.
- **Workflow status state:** `pnpm run status` updates tracked per-repository
status JSON under `results/sweep-status/` in the state repo so long-running
workflows can publish progress without changing report data.
- **Audit:** `pnpm run audit` compares live GitHub state with report storage and
can publish audit state under `results/audit/` in the state repo without
mutating issues or PRs.
- **Reconcile:** `pnpm run reconcile` repairs report placement drift such as
reopened archived records or closed items still sitting in `items/`.
- **Work candidates:** valid, narrow items can be marked as
`queue_fix_pr` candidates for manual ClawSweeper repair promotion.
- **Commit review:** push events on target `main` branches can dispatch to
`.github/workflows/commit-review.yml`, which expands the commit range, skips
non-code-only commits cheaply, starts one Codex worker per code-bearing
commit, and writes `records/<repo-slug>/commits/<sha>.md`.
- **Manual reruns and backfills:** both lanes support manual workflow dispatch.
Commit review supports exact SHAs, historic ranges with `before_sha`, and an
`additional_prompt` input for one-off review instructions.
- **Commit report queries:** `pnpm commit-reports -- --since 24h`,
`--findings`, `--non-clean`, `--repo`, and `--author` make the flat per-SHA
commit storage easy to review by time window without date folders.
- **Optional commit checks:** commit reports are the source of truth; target
commit Check Runs are disabled by default and can be enabled per run or repo.
- **ClawSweeper repair dispatch:** commit reports with `result: findings` can
dispatch to the repair intake, where an audit record is written and a PR is
created only when the finding is narrow, non-security, and still relevant on
latest `main`.
### Issue and PR Reviews
Scheduled runs scan open issues and pull requests, while target repositories can
forward exact issue/PR events with `repository_dispatch` for low-latency
one-item reviews. Each review writes
`records/<repo-slug>/items/<number>.md` with the decision, evidence, proposed
maintainer-facing comment, runtime metadata, and GitHub snapshot hash.
ClawSweeper syncs one marker-backed public review comment per item and edits it
in place instead of posting repeated comments. If a review starts before a
completed comment exists, it first posts a short status placeholder, then
replaces that same comment with the final review. Pull request comments include
hidden verdict/action markers so trusted repair and automerge flows can continue
without scraping visible prose. See
[`docs/pr-review-comments.md`](docs/pr-review-comments.md).
### Apply and State
Apply mode re-fetches live GitHub state, checks labels, maintainer authorship,
paired issue/PR state, snapshot drift, and repository profile rules before
commenting or closing anything. Closed or already-closed reports move to
`records/<repo-slug>/closed/<number>.md`; reopened archived items move back to
`items/` as stale work.
Generated state lives in `openclaw/clawsweeper-state`: durable `records/`,
`jobs/`, `results/`, audit output, workflow status JSON, repair ledgers, and the
rendered dashboard. This repository stays focused on source, workflows, docs,
and tests.
### Repair and Automerge
Maintainer commands can opt PRs into `autofix` or `automerge`, dispatch a fresh
exact-head review, and run a bounded Codex review/fix loop. Codex handles the
code repair and local validation loop; deterministic executor steps own every
GitHub mutation, branch push, label update, and final merge gate.
Automerge waits for exact-head review, required checks, mergeability, and policy
gates. If repair was needed, the mutable status comment records each review,
repair, re-review, and merge step with timing and links. The final merge result
summarizes both the original PR change and any ClawSweeper fixups.
For issues, strict bug reviews that are high-confidence reproducible, do not
already have a linked PR, and do not require feature/config expansion can
dispatch Codex to open one guarded implementation PR labeled
`clawsweeper:autogenerated`.
### Commit Reviews
Push events on target `main` branches can dispatch to
`.github/workflows/commit-review.yml`. The workflow expands the commit range,
skips non-code-only commits cheaply, starts one Codex worker per code-bearing
commit, and writes `records/<repo-slug>/commits/<sha>.md`.
Commit reports are the source of truth. Optional target commit Check Runs are
disabled by default and can be enabled per run or repository. Reports with
`result: findings` can dispatch to repair intake when the finding is narrow,
non-security, and still relevant on latest `main`.
### Operations
Repository-specific rules live in `src/repository-profiles.ts`, so OpenClaw,
ClawHub, and ClawSweeper can share the same engine while keeping different apply
limits. Both review and repair lanes support manual workflow dispatch, reruns,
and backfills. `pnpm commit-reports -- --since 24h`, `--findings`,
`--non-clean`, `--repo`, and `--author` query flat per-SHA commit storage
without date buckets.
## Guardrails
@ -163,13 +191,17 @@ Live dashboard and generated state: https://github.com/openclaw/clawsweeper-stat
## How It Works
ClawSweeper is split into two operational systems:
ClawSweeper is split into four operational lanes:
- issue/PR sweeper: scheduler, review lane, apply lane, audit, reconcile, and
durable state publishing
- commit sweeper: main-branch commit dispatch, cheap code/non-code
classification, one Codex review worker per code-bearing commit, report
publishing, and optional target commit checks
- review lane: scheduled and event-driven issue/PR reviews, durable reports, and
public review comment sync
- apply lane: guarded close/comment mutations, audit, reconcile, and state
publishing
- repair lane: maintainer-command routing, autofix, automerge, issue
implementation PRs, and repair result publishing
- commit review lane: main-branch commit dispatch, cheap code/non-code
classification, one Codex review worker per code-bearing commit, and optional
target commit checks
### Scheduler
@ -198,12 +230,13 @@ Review is proposal-only. It never closes items.
- Manual runs can pass `item_number` or comma-separated `item_numbers` to review
exact Audit Health findings without scanning for a normal batch.
- Each shard checks out the selected target repository at `main`.
- Codex reviews with `gpt-5.5`, high reasoning, fast service tier, and a
- Codex reviews with `gpt-5.5`, high reasoning, the default service tier, and a
10-minute per-item timeout.
- Each item becomes a flat report under
`records/<repo-slug>/items/<number>.md` with the decision, evidence,
Codex `/review`-style PR findings, suggested comment, runtime metadata, and
GitHub snapshot hash.
GitHub snapshot hash. When GitHub exposes a merged closing PR for an issue,
the report records that PR and the close comment links it as fix provenance.
- High-confidence allowed close decisions become `proposed_close`.
- After publish, the lane checks the selected items' single marker-backed Codex
review comment. Missing comments and missing metadata are synced immediately;
@ -251,6 +284,30 @@ sync stale public review comments, but closing remains guarded by apply so a
fresh GitHub snapshot, labels, maintainer-authorship, and unchanged item state
are checked immediately before mutation.
### Repair Lane
Repair starts from maintainer intent or trusted ClawSweeper review metadata. The
comment router accepts commands from target repositories, validates maintainer
permissions, updates one mutable command/status comment, and dispatches the
appropriate repair job.
- `autofix` and `automerge` adopt the PR branch and run exact-head review before
making changes.
- If review or CI finds actionable issues, Codex rebases, addresses PR review
comments, fixes CI, runs the requested validation, and returns a structured
repair artifact.
- The deterministic executor applies the artifact, pushes only after validation,
re-dispatches exact-head review, and waits for required checks.
- `automerge` merges only after review verdict, checks, mergeability, changelog,
security, maintainer stop/approve state, and repository policy gates pass.
- Issue implementation is narrower: only strict, reproducible bugs with no
linked PR and no feature/config expansion can open a generated PR.
Repair internals are documented in
[`docs/repair/README.md`](docs/repair/README.md), and the automerge state
machine is documented in
[`docs/repair/automerge-flow.md`](docs/repair/automerge-flow.md).
### Commit Review Lane
Commit review is intentionally separate from issue/PR cleanup. It never closes
@ -261,8 +318,8 @@ items, writes comments, or fixes code.
- Manual runs can pass `commit_sha`, optional `before_sha`, optional
`additional_prompt`, `enabled`, and `create_checks`.
- The receiver verifies the selected commits are reachable from `origin/main`.
- Before selecting and reviewing commits, the receiver waits 15 minutes by
default (`CLAWSWEEPER_COMMIT_REVIEW_SETTLE_SECONDS=900`) so a push range has
- Before selecting and reviewing commits, the receiver waits 60 seconds by
default (`CLAWSWEEPER_COMMIT_REVIEW_SETTLE_SECONDS=60`) so a push range has
time to settle across GitHub and the runner.
- The plan job expands ranges, pages large backfills at GitHub's matrix limit,
and classifies each commit before Codex starts.
@ -343,8 +400,8 @@ source ~/.profile
corepack enable
pnpm install
pnpm run build
pnpm run plan -- --target-repo openclaw/openclaw --batch-size 5 --shard-count 100 --max-pages 250 --codex-model gpt-5.5 --codex-reasoning-effort high --codex-service-tier fast
pnpm run review -- --target-repo openclaw/openclaw --target-dir ../openclaw --batch-size 5 --max-pages 250 --artifact-dir artifacts/reviews --codex-model gpt-5.5 --codex-reasoning-effort high --codex-service-tier fast --codex-timeout-ms 600000
pnpm run plan -- --target-repo openclaw/openclaw --batch-size 5 --shard-count 70 --max-pages 250 --codex-model gpt-5.5 --codex-reasoning-effort high
pnpm run review -- --target-repo openclaw/openclaw --target-dir ../openclaw --batch-size 5 --max-pages 250 --artifact-dir artifacts/reviews --codex-model gpt-5.5 --codex-reasoning-effort high --codex-timeout-ms 600000
pnpm run apply-artifacts -- --target-repo openclaw/openclaw --artifact-dir artifacts/reviews --skip-dashboard
pnpm run audit -- --target-repo openclaw/openclaw --max-pages 250 --sample-limit 25 --update-dashboard
pnpm run reconcile -- --target-repo openclaw/openclaw --dry-run
@ -400,13 +457,28 @@ default, subject to the selected repository profile; pass `target_repo`,
`apply_kind=issue`, or `apply_kind=pull_request` to narrow a manual run.
Scheduled runs cover the configured product profiles. `openclaw/openclaw` runs
normal backfill every 5 minutes with up to 100 review shards when due backlog
exists; `openclaw/clawhub` runs on offset review/apply/audit crons so its
reports live under `records/openclaw-clawhub/` without colliding with default
repo records. `openclaw/clawsweeper` has a scheduled read-only audit row and is
normal backfill every 5 minutes with up to 70 review shards when the system is
quiet; `openclaw/clawhub` runs on offset review/apply/audit crons so its reports
live under `records/openclaw-clawhub/` without colliding with default repo
records. `openclaw/clawsweeper` has a scheduled read-only audit row and is
available for manual and event self-review smoke tests. Broad hot-intake sweeps
cap scheduled fan-out at 50 one-item shards per run; exact event reviews still
use one shard.
cap scheduled fan-out at 35 one-item shards per run when quiet; exact event
reviews still use one shard. Normal review, hot intake, and commit review are
background lanes, so they shrink automatically while repair or exact-item work
is active. Throughput defaults live in
[docs/limits.md](docs/limits.md) and `config/automation-limits.json`.
### Worker Budget
ClawSweeper has one main capacity knob:
`config/automation-limits.json` -> `workers.max`. The current value is `100`.
Quiet-system lane limits are derived from that number: normal review gets up to
70 shards, hot intake up to 35 shards, commit review 5 commits per page, and
repair/issue implementation 40 live workers. Exact-item review, repair, and
issue implementation are priority work; normal review, hot intake, and commit
review are background work and automatically yield when priority work is active.
Use `workers.max` first when turning total Codex usage up or down; use the
individual environment overrides only for temporary lane-specific exceptions.
Target repositories can opt into event-level latency by installing the
dispatcher workflow in [docs/target-dispatcher.md](docs/target-dispatcher.md).
@ -474,6 +546,8 @@ Required `clawsweeper` app permissions:
authorization context.
- Pull requests: read/write, for PR comments, labels, merge readiness, repair PRs,
and guarded automerge.
- Workflows: write, for adopted automerge repairs that need to rebase or update
source branches containing `.github/workflows/*` changes.
- Actions: read/write on `openclaw/clawsweeper`, for run cancellation, manual
dispatch, self-heal, and commit-review continuations.
- Checks: write on target repositories when commit Check Runs should be
@ -497,4 +571,4 @@ Target repository setup:
should be published
- optionally set `CLAWSWEEPER_COMMIT_REVIEW_SETTLE_SECONDS=0` for manual
backfills where the target commit range is already settled; the default is
`900`
`60`

View File

@ -0,0 +1,7 @@
{
"workers": {
"max": 100,
"reserve_for_interactive": 10,
"minimum_background": 10
}
}

View File

@ -0,0 +1,46 @@
{
"schema_version": 1,
"repositories": [
{
"target_repo": "openclaw/clawhub",
"display_name": "ClawHub",
"checkout_dir": "clawhub",
"community_url": "https://clawhub.ai/",
"prompt_note": "Use the ClawHub source tree and current main branch. Review every issue and PR with the same evidence standard, but only propose auto-close for pull requests that are certainly implemented on main. Keep everything else open.",
"apply_close_rules": {
"issue": [],
"pull_request": ["implemented_on_main"]
}
},
{
"target_repo": "openclaw/clawsweeper",
"display_name": "ClawSweeper",
"checkout_dir": "clawsweeper",
"prompt_note": "Use the ClawSweeper source tree and current main branch. Review bot automation, workflow, and documentation changes conservatively. Only propose auto-close for pull requests that are certainly implemented on main; keep issues open for maintainer triage.",
"apply_close_rules": {
"issue": [],
"pull_request": ["implemented_on_main"]
}
},
{
"target_repo": "openclaw/fs-safe",
"display_name": "fs-safe",
"checkout_dir": "fs-safe",
"prompt_note": "Use the fs-safe source tree and current main branch. Review filesystem-safety, path-handling, and package changes conservatively. Only propose auto-close for pull requests that are certainly implemented on main; keep issues open for maintainer triage.",
"apply_close_rules": {
"issue": [],
"pull_request": ["implemented_on_main"]
}
}
],
"openclaw_fallback": {
"owner": "openclaw",
"deny_repositories": ["openclaw/clawsweeper-state", "openclaw/.github"],
"allow_repo_name_pattern": "^[A-Za-z0-9_.-]+$",
"prompt_note": "Use the {target_repo} source tree and current main branch. This repository is using the generic OpenClaw onboarding profile, so keep review conservative: issues are review/comment-only, and pull requests may be auto-closed only when the exact change is certainly already implemented on main.",
"apply_close_rules": {
"issue": [],
"pull_request": ["implemented_on_main"]
}
}
}

View File

@ -103,6 +103,22 @@ CLAWSWEEPER_COMMIT_REVIEW_SETTLE_SECONDS=60
Use `0` for settled manual backfills or a larger value during GitHub event
lag incidents.
Commit review is a background lane. It defaults to 5 commits per workflow page
when the system is quiet, but the receiver asks the central worker scheduler for
capacity before each page. Active repair, exact-item review, and sweep work can
lower the page size so commit review does not consume capacity needed by
maintainer-visible work. The checked-in default comes from
`config/automation-limits.json`; adjust the live workflow on
`openclaw/clawsweeper` only when the org has enough rate-limit headroom:
```text
CLAWSWEEPER_COMMIT_REVIEW_PAGE_SIZE=5
```
The receiver clamps this between 1 and 100. Setting the variable bypasses the
dynamic default for that run; leave it unset when the central scheduler should
decide. Large push ranges continue in later workflow pages.
`openclaw/clawhub` commit dispatches are skipped while
`CLAWSWEEPER_ENABLE_CLAWHUB` is not `1`. Turn that receiver variable on only
after the ClawSweeper GitHub App is installed on `openclaw/clawhub`; otherwise

View File

@ -90,18 +90,24 @@ by SHA/range rather than detaching the whole target repository at the commit.
## Scaling
GitHub Actions matrices are capped at 256 jobs per workflow run. Commit Sweeper
therefore pages large ranges:
Commit Sweeper is background work. It defaults to 5 commits per workflow page
when the system is quiet, but the receiver asks the central worker scheduler for
the effective page size before dispatching the matrix. Active repair,
exact-item review, and sweep work can lower commit review to keep capacity
available for maintainer-visible work. The checked-in default lives in
`config/automation-limits.json`. The receiver clamps
`CLAWSWEEPER_COMMIT_REVIEW_PAGE_SIZE` between 1 and 100, then pages large ranges:
- select up to 256 commits
- select up to the configured page size
- classify them cheaply
- start one matrix worker per code-bearing commit
- write skipped reports for non-code commits
- commit all reports
- dispatch the next page when more commits remain
A 200-commit push runs in one workflow run. A 600-commit historic backfill runs
as multiple continuation runs.
A 200-commit push runs as multiple continuation runs at the effective page size.
Leave `CLAWSWEEPER_COMMIT_REVIEW_PAGE_SIZE` unset to use dynamic scheduling.
Raise the page size only when the org has enough rate-limit headroom.
## Cheap Classification

122
docs/limits.md Normal file
View File

@ -0,0 +1,122 @@
# Automation Limits
Read when changing ClawSweeper throughput, Codex fan-out, commit review paging,
or repair dispatch capacity.
`config/automation-limits.json` is the source of truth for the global worker
budget. It deliberately has only one main knob, `workers.max`, because that is
the number we normally tune when Codex or GitHub rate limits get tight. Most
lane-specific limits are derived from that budget; safety thresholds such as
close age floors, apply delays, retry counts, and comment caps stay near the
code that owns those decisions.
GitHub repository variables still override selected live limits. When a variable
is unset, workflows read the checked-in budget after checkout. The one exception
is the `workflow_dispatch.inputs.shard_count.default` value in
`.github/workflows/sweep.yml`: GitHub renders that UI before checkout, so it
must remain a YAML literal. `pnpm run check:limits` verifies that literal and the
docs stay in sync with the derived budget.
The mental model:
- `workers.max` is the global Codex capacity budget.
- Priority lanes are repair, issue implementation, and exact-item review.
- Background lanes are normal review, hot intake, and commit review.
- Background lanes shrink when priority work is already active.
- Runtime overrides are escape hatches, not the normal tuning surface.
## Worker Budget
| Name | Current | Meaning |
| --- | ---: | --- |
| `workers.max` | 100 | Maximum global Codex worker budget used to derive lane limits. |
| `workers.reserve_for_interactive` | 10 | Worker slots background lanes leave open for exact/manual/urgent work. |
| `workers.minimum_background` | 10 | Target floor for background progress when enough global capacity is available. |
## Derived Limits
Derived limits are intentionally percentages of `workers.max`. With
`workers.max = 100`, the quiet-system ceilings are easy to read directly:
normal review can use 70 workers, hot intake can use 35, commit review can use
5 commits per page, and repair lanes can dispatch 40 live workers.
| Name | Current | Meaning |
| --- | ---: | --- |
| `review_shards.normal_default` | 70 | Quiet-system normal review shard ceiling. |
| `review_shards.normal_active_floor` | 30 | Minimum active normal review shards to keep queued for `openclaw/openclaw`. |
| `review_shards.hot_intake_default` | 35 | Quiet-system broad hot-intake review shard ceiling. |
| `review_shards.exact_item_default` | 1 | Exact-item hot-intake shard count. |
| `review_shards.hard_cap` | 100 | Maximum accepted review shard count. |
| `commit_review.page_size_default` | 5 | Commits selected per commit-review page. |
| `commit_review.page_size_hard_cap` | 100 | Maximum commit-review page size. |
| `repair_live_runs.default` | 40 | Default live repair workflow run cap for manual dispatch/requeue/self-heal. |
| `repair_live_runs.hard_cap` | 100 | Absolute live repair run cap accepted by the CLI. |
| `repair_live_runs.automerge_default` | 40 | Live repair run cap for automerge comment-router dispatches. |
| `repair_live_runs.issue_implementation_default` | 40 | Live repair run cap for issue-to-PR implementation intake. |
| `issue_implementation.dispatches_per_sweep_default` | 4 | Maximum implementation intake jobs queued from one review publish run. |
Formula summary:
- normal review: 70% of `workers.max`
- normal active floor: 30% of `workers.max`
- hot intake: 35% of `workers.max`
- commit review page size: 5% of `workers.max`
- repair, automerge repair, and issue implementation: 40% of `workers.max`
- issue implementation dispatches per sweep: 4% of `workers.max`
- hard caps: `workers.max`
## Dynamic Scheduling
Normal review, hot intake, and commit review are background lanes. Before they
dispatch, the workflow asks `pnpm run workflow -- worker-limit <lane>` for the
current allowance.
The scheduler does this for background lanes:
1. start with `workers.max`
2. subtract active priority work, currently repair workers plus exact-item sweep
runs
3. subtract active background work already known to the workflow, including
commit-review pages and other active normal/hot sweep runs
4. reserve `workers.reserve_for_interactive`
5. cap the result at the lane's derived quiet-system ceiling
6. return at least 1 so an enabled lane can still make slow progress
Priority lanes do not subtract the interactive reserve. They cap themselves at
their derived lane ceiling and at the remaining global budget after other active
priority work.
Examples with the current config:
- Quiet system: normal review gets 70, hot intake gets 35, commit review gets 5.
- 30 active repair workers and 20 active background workers: normal review gets
40 because `100 - 10 reserve - 30 priority - 20 background = 40`.
- 90 active priority workers: commit review gets 1, so commit review yields but
does not fully stall.
Use these commands to inspect the effective values from a checkout:
```bash
pnpm run --silent workflow -- worker-config
pnpm run --silent workflow -- limit review_shards.normal_default
pnpm run --silent workflow -- worker-limit normal_review
pnpm run --silent workflow -- worker-limit commit_review --active-critical 90
```
Change `workers.max` first when tuning rate-limit pressure. For example, setting
`workers.max` to `80` automatically makes quiet normal review `56`, hot intake
`28`, commit review `4`, repair `32`, and hard caps `80`.
## Runtime Overrides
- `CLAWSWEEPER_COMMIT_REVIEW_PAGE_SIZE` overrides
`commit_review.page_size_default`.
- `CLAWSWEEPER_MAX_LIVE_WORKERS` overrides `repair_live_runs.default`.
- `CLAWSWEEPER_AUTOMERGE_MAX_LIVE_WORKERS` overrides
`repair_live_runs.automerge_default`.
- `CLAWSWEEPER_AUTO_IMPLEMENT_MAX_LIVE_WORKERS` overrides
`repair_live_runs.issue_implementation_default`.
- `CLAWSWEEPER_AUTO_IMPLEMENT_MAX_DISPATCH_PER_SWEEP` overrides
`issue_implementation.dispatches_per_sweep_default`.
- Manual `sweep.yml` dispatch `shard_count` overrides
`review_shards.normal_default`, then clamps to `review_shards.hard_cap`.

View File

@ -186,16 +186,21 @@ default. The agent receives the Discord target in the prompt and should use the
message tool only when the event is surprising, actionable, risky, or otherwise
operationally useful. For routine events it replies exactly `NO_REPLY`.
The notifier also applies a cheap deterministic prefilter before calling
OpenClaw. Routine bot comments, comment edits, metadata edits, duplicate PR
synchronizes, and successful automation events are skipped unless they contain
an explicit ClawSweeper command or mention. This keeps noisy GitHub churn from
consuming hook-session model turns.
The workflow skips native and forwarded pull request synchronize events plus
successful workflow-run events before checkout because the notifier always
treats them as routine. The notifier also applies a cheap deterministic
prefilter before calling OpenClaw. Routine bot comments, comment edits, metadata
edits, duplicate PR synchronizes, and successful automation events are skipped
unless they contain an explicit ClawSweeper command or mention. This keeps noisy
GitHub churn from consuming hook-session model turns.
The workflow intentionally uses a lean uncached Node/pnpm setup instead of the
shared cached pnpm action. This event stream can burst dozens of runs at once,
and downloading the cache action itself has proven slower and less reliable than
a direct install/build path for the small notifier.
The workflow intentionally uses the runner-provided Node runtime plus a lean
uncached pnpm install instead of `actions/setup-node` or the shared cached pnpm
action. This event stream can burst dozens of runs at once, and downloading
extra setup/cache actions has proven slower and less reliable than a direct
install/build path for the small notifier. The activity notifier is kept
compatible with the runner's Node 20+ runtime even though the broader project
gate still uses Node 24.
The activity prompt always treats GitHub titles, comments, review bodies, and
issue text as untrusted data. It must not follow instructions embedded in those

View File

@ -38,12 +38,27 @@ For a PR that needs work, the visible comment starts with:
Codex review: needs changes before merge.
```
For an external PR that lacks after-fix real behavior proof, the visible comment
starts with:
```text
Codex review: needs real behavior proof before merge.
```
The body should include the strongest actionable, non-overlapping sections the
report has:
- `**Summary**` from the typed `changeSummary` field, not from the
merge verdict or maintainer follow-up summary; when `reproductionAssessment`
is present, this section also includes a compact `Reproducibility:` line
- `**Real behavior proof**` near the top for PRs, from the typed
`realBehaviorProof` field. When proof is missing, mock-only, or insufficient,
this section should tell contributors that terminal screenshots, console
output, copied live output, linked artifacts, recordings, and redacted logs
count even for non-visual CLI or text changes. Ordinary app screenshots count
only for behavior they directly show; browser runtime, network, CSP, and
security proof needs visible diagnostic output, not a "no visible console
violation" claim
- `**Next step before merge**` for PRs, or `**Next step**` for issues, from the
work-candidate reason or next action
- `**Security**` from the typed `securityReview` field, so supply-chain,
@ -99,6 +114,11 @@ hands, ClawSweeper emits a human-only verdict:
<!-- clawsweeper-verdict:needs-human item=<number> sha=<pull-head-sha> confidence=<confidence> -->
```
Missing, mock-only, or insufficient `realBehaviorProof` is always human-only:
ClawSweeper must not emit `clawsweeper-action:fix-required` or pass/automerge
markers for proof-only blockers because automation cannot prove the
contributor's real setup for them.
Clean/close-style PR verdicts also stay human-only from the repair point of
view. Closing remains outside the repair loop.
@ -117,10 +137,11 @@ ClawSweeper caps trusted repair dispatches:
- `CLAWSWEEPER_MAX_REPAIRS_PER_PR=10` total automatic repair
iterations per PR by default.
- `CLAWSWEEPER_MAX_REPAIRS_PER_HEAD=1` repair dispatch per PR head
- `CLAWSWEEPER_MAX_REPAIRS_PER_HEAD=2` repair dispatches per PR head
SHA by default.
The per-head cap prevents duplicate workers for the same commit. The per-PR
The per-head cap prevents unbounded duplicate workers for the same commit while
leaving room for one infrastructure retry. The per-PR
cap stops an automatic review/repair loop after ten ClawSweeper-triggered
iterations even if each repair pushes a new head SHA.

View File

@ -98,9 +98,9 @@ Each cluster job:
Codex does not receive a GitHub token during classification. The runner preflights GitHub state before model execution, then Codex receives those artifacts and returns JSON only. When a reviewed fix artifact is executed, Codex gets a temporary target checkout without GitHub credentials; the deterministic executor owns commit, push, PR creation, and source-PR closeout using the short-lived GitHub App token exposed to the executor as `GH_TOKEN`. Commit author metadata defaults to `clawsweeper-repair` and can be overridden with `CLAWSWEEPER_GIT_USER_NAME` and `CLAWSWEEPER_GIT_USER_EMAIL`; this is separate from the GitHub token used to push. The applicator re-fetches the target item, checks `updated_at`, blocks unsafe closeouts, writes idempotent close comments, closes supported duplicate/superseded/fixed-by-candidate actions, and can squash-merge explicitly allowed clean PR actions.
Merge is deliberately harder than closeout. A merge action must include `merge_preflight` proving security clearance, resolved human comments, resolved review-bot findings, a passed Codex `/review`, addressed review findings, and clean validation commands. The fix executor gives Codex the normalized changed-surface validation gate up front, so the agentic edit loop is edit, run validation, fix validation fallout, rerun validation, and only then return. The deterministic executor still re-runs validation as the final safety rail, then feeds any remaining validation failure back through a narrow Codex validation-fix pass, runs Codex `/review`, feeds actionable review findings back into Codex for up to three review-fix rounds by default, revalidates after each pass, and resolves PR review threads when permitted. The applicator also checks live unresolved GitHub review threads immediately before merge.
Merge is deliberately harder than closeout. A merge action must include `merge_preflight` proving security clearance, resolved human comments, resolved review-bot findings, addressed review findings, and clean validation commands. The fix executor gives Codex the normalized changed-surface validation gate up front, so the agentic edit loop is edit, run validation, fix validation fallout, rerun validation, and only then return. The deterministic executor still re-runs validation as the final safety rail, then feeds any remaining validation failure back through a narrow Codex validation-fix pass, runs Codex `/review`, feeds actionable review findings back into Codex for the configured review-fix budget, and revalidates after each pass. If the final internal `/review` still finds something actionable, the worker gives Codex one last review-fix prompt and pushes only if changed-surface validation passes; the normal exact-head ClawSweeper review, GitHub checks, and live unresolved-thread checks still gate the merge.
Replacement fix work uses a recoverable target branch named `clawsweeper/<cluster-id>`. The executor resumes that branch if it already exists and pushes checkpoint commits after agent edits and review-fix edits, adding `Co-authored-by` trailers for non-bot source PR authors when a contributor PR is replaced. It then opens or updates the PR only after validation and Codex `/review` pass. If `/review` still blocks the merge after retries, the run writes a blocked fix report and leaves the checkpoint branch recoverable instead of losing the patch.
Replacement fix work uses a recoverable target branch named `clawsweeper/<cluster-id>`. The executor resumes that branch if it already exists and pushes checkpoint commits after agent edits and review-fix edits, adding `Co-authored-by` trailers for non-bot source PR authors when a contributor PR is replaced. It then opens or updates the PR only after validation and internal review/fix handling. If validation or Codex itself still blocks after retries, the run writes a blocked fix report and leaves the checkpoint branch recoverable instead of losing the patch.
Runs for the same job path and mode are queued instead of running concurrently. The workflow uses Node 24, `blacksmith-4vcpu-ubuntu-2404` for cluster planning/review, and `blacksmith-16vcpu-ubuntu-2404` for fix/apply execution. Fix execution prepares the target checkout with Corepack and the target `pnpm` package manager before validation; the execution job caches Codex, npm, Corepack, and the target pnpm store. Fix validation is pinned to OpenClaw's fast changed-lane posture by default: `pnpm check:changed` plus diff checks are the hard local gate, and target validation commands normalize to `pnpm check:changed` unless `CLAWSWEEPER_TARGET_VALIDATION_MODE=strict` or `CLAWSWEEPER_STRICT_TARGET_VALIDATION=1` is explicitly set. That normalized gate is also passed to Codex in the write prompt; Codex is expected to run it, fix failures it introduced, and report the exact command/result before returning. Unrelated flaky main CI, broad `pnpm check`, full tests, live, docker, and e2e lanes do not block narrow ClawSweeper Repair fixes by default.
@ -120,9 +120,9 @@ Full worker prompts, Codex transcripts, and raw artifacts stay in GitHub Actions
- `route_security`: quarantines true security-sensitive refs without poisoning unrelated cluster work.
- `needs_human`: only product-direction, trust-boundary, canonical-choice, merge-path, or contributor-credit decisions that remain unclear after the hydrated artifact and single-item review/check/decide pass.
- Automated reviewer feedback must be cleared during autonomous PR work. Greptile, Codex, Asile, CodeRabbit, Copilot, and similar bot comments must be addressed, proven non-actionable, or escalated before any merge or post-merge closeout recommendation.
- Merge preflight: no PR can merge until `CLAWSWEEPER_ALLOW_MERGE=1`, security issues are cleared, comments are resolved, Codex `/review` has passed, findings are addressed, and changed-surface validation is clean. With the merge gate closed, ClawSweeper Repair labels merge-ready targets for human review instead of merging.
- Final base sync: before pushing a repaired branch, ClawSweeper fetches latest `origin/main`. If main moved after validation, the worker rebases again; conflict resolution goes back through Codex, then validation and Codex `/review` rerun before the branch can be pushed.
- Repair ladder: make the useful contributor PR mergeable when its branch is writable; same-repo PRs are writable by the GitHub App contents permission even when the raw maintainer-edit flag is false. Otherwise replace draft, stale, unmergeable, uneditable, or unsafe branches with a narrow credited fix PR. When fix PR mode is enabled, "wait or replace" is already answered: replace, preserve credit and labels, then supersede only the source PR that could not be safely updated.
- Merge preflight: no PR can merge until `CLAWSWEEPER_ALLOW_MERGE=1`, security issues are cleared, comments are resolved, review findings are addressed, changed-surface validation is clean, and the pushed head passes exact-head ClawSweeper review plus GitHub checks. With the merge gate closed, ClawSweeper Repair labels merge-ready targets for human review instead of merging.
- Final base sync: before pushing a repaired branch, ClawSweeper fetches latest `origin/main`. If main moved after validation, the worker rebases once more and pushes that synchronized head; conflict resolution still goes back through Codex, but the fresh exact-head ClawSweeper review and GitHub checks gate the final merge instead of repeating local validation indefinitely. Set `CLAWSWEEPER_FINAL_BASE_SYNC_ATTEMPTS` above `1` only for controlled backfills where extra local validation is worth the latency.
- Repair ladder: make the useful contributor PR mergeable when its branch is writable; same-repo PRs are writable by the GitHub App contents permission even when the raw maintainer-edit flag is false. If a fork push is rejected because the rebase would create or update workflow files without effective workflow permission, publish the already-prepared repair as a base-repo replacement PR instead of rerunning Codex. Otherwise replace draft, stale, unmergeable, uneditable, or unsafe branches with a narrow credited fix PR. When fix PR mode is enabled, "wait or replace" is already answered: replace, preserve credit and labels, then supersede only the source PR that could not be safely updated.
## Maintainer Comment Commands
@ -230,10 +230,12 @@ pnpm run repair:import-gitcrawl-low-signal -- --limit 20 --batch-size 5 --mode a
pnpm run repair:import-gitcrawl -- --from-gitcrawl --limit 40 --mode autonomous --suffix autonomous-smoke --allow-instant-close --allow-merge --allow-fix-pr --allow-post-merge-close
# Dispatch reviewed jobs. Dispatch, requeue, and self-heal refuse to exceed
# 50 live cluster-worker runs by default; tune with CLAWSWEEPER_MAX_LIVE_WORKERS
# or --max-live-workers. With --wait-for-capacity, dispatch can drain a larger
# file list in capacity-sized waves instead of refusing the whole batch.
CLAWSWEEPER_MAX_LIVE_WORKERS=50 pnpm run repair:dispatch -- jobs/openclaw/inbox/cluster-example.md \
# 40 live cluster-worker runs by default. That cap is derived from workers.max in
# config/automation-limits.json; tune the global budget there first, or use
# CLAWSWEEPER_MAX_LIVE_WORKERS/--max-live-workers for a one-lane override. With
# --wait-for-capacity, dispatch can drain a larger file list in capacity-sized
# waves instead of refusing the whole batch.
CLAWSWEEPER_MAX_LIVE_WORKERS=40 pnpm run repair:dispatch -- jobs/openclaw/inbox/cluster-example.md \
--mode autonomous \
--runner blacksmith-4vcpu-ubuntu-2404 \
--execution-runner blacksmith-16vcpu-ubuntu-2404
@ -294,7 +296,7 @@ CLAWSWEEPER_ALLOW_EXECUTE=1 pnpm run repair:tag-clawsweeper -- --live --apply
# Retry failed jobs once. This briefly opens the execution gate, waits for the
# dispatched workers to start, records the self-heal ledger, and closes the gate.
pnpm run repair:self-heal -- --execute --open-execute-window --max-jobs 5 \
--max-live-workers 50 \
--max-live-workers 40 \
--runner blacksmith-4vcpu-ubuntu-2404 \
--execution-runner blacksmith-16vcpu-ubuntu-2404
```
@ -323,7 +325,7 @@ The workflow needs:
model is `gpt-5.5`; repair workers default to high reasoning on the fast
service tier, and accidental `xhigh` reasoning overrides are normalized back
to `high`
- optional `CLAWSWEEPER_MAX_LIVE_WORKERS` variable for dispatch/requeue/self-heal worker fan-out; default is `50`
- optional `CLAWSWEEPER_MAX_LIVE_WORKERS` variable for dispatch/requeue/self-heal worker fan-out; default is derived from `workers.max` and is currently `40`
- optional `CLAWSWEEPER_MAX_ACTIVE_PRS_PER_AREA` variable for replacement PR backpressure; default is `50` open ClawSweeper PRs per touched area, `0` disables the area cap, and common changelog/release-note files are ignored for this check
- ClawSweeper commit-finding repair PRs are labeled `clawsweeper:commit-finding`
- optional `CLAWSWEEPER_CODEX_TIMEOUT_MS`, `CLAWSWEEPER_FIX_CODEX_TIMEOUT_MS`,
@ -333,6 +335,8 @@ The workflow needs:
timeout and a 40 minute execute-step cap so long edit/test passes still leave
room for internal `/review`, post-flight, and timeout artifact upload instead
of falling into a 30-second review floor near the end of the run.
- optional `CLAWSWEEPER_CODEX_RETRY_DELAY_MS` variable for edit-worker backoff
after retryable Codex transport or TPM rate-limit exits; default is `15000`.
- If a contributor branch changes while a repair is preparing its push, the
executor records `requeue_required: true` and the same workflow dispatches a
fresh repair run for the latest head after publishing the result. This keeps
@ -343,11 +347,11 @@ The workflow needs:
debug artifacts. `CLAWSWEEPER_GIT_NETWORK_TIMEOUT_MS` and
`CLAWSWEEPER_GH_COMMAND_TIMEOUT_MS` can override the Git and GitHub CLI
portions separately.
- optional `CLAWSWEEPER_CODEX_REVIEW_ATTEMPTS` and `CLAWSWEEPER_RESOLVE_REVIEW_THREADS` variables for agentic merge-prep review loops; the review attempt default is `4`, giving the first review plus up to three Codex review-fix rounds before the run blocks
- optional `CLAWSWEEPER_CODEX_REVIEW_ATTEMPTS` and `CLAWSWEEPER_RESOLVE_REVIEW_THREADS` variables for agentic merge-prep review loops; the review attempt default is `4`, with the last failed internal review converted into one final Codex review-fix pass when changed-surface validation can still prove the branch safe to push for exact-head review
- optional `CLAWSWEEPER_MAX_REPAIRS_PER_PR` and
`CLAWSWEEPER_MAX_REPAIRS_PER_HEAD` variables for trusted
ClawSweeper review feedback; defaults are `10` automatic repair iterations per
PR and `1` repair per PR head SHA. The per-PR cap is total across changing
PR and `2` repairs per PR head SHA. The per-PR cap is total across changing
head SHAs and stops the automatic review/repair loop.
- In-flight branch repair workers re-fetch the live PR before mutation and block
if `clawsweeper:human-review` is present, so a trusted needs-human verdict or

View File

@ -124,6 +124,10 @@ if the rebase or known mechanical conflict resolvers cannot finish cleanly, it
falls back to the normal Codex fix worker. The mechanical set includes
isolated `CHANGELOG.md` conflicts and generated config checksum conflicts where
the replayed commit changed only selected checksum entries.
If GitHub rejects a fork-branch repair push because the synchronized branch
would create or update workflow files without effective workflow permission, the
worker keeps the prepared repair and publishes it as a credited replacement PR
from the base repository instead of starting Codex over.
During Codex repair, changed-surface validation failures are loop inputs, not
immediate terminal outcomes. The executor feeds a failed `pnpm check:changed`
@ -224,17 +228,18 @@ ClawSweeper has three layers of duplicate protection:
stale labelled PRs can be repaired or re-reviewed without a fresh comment;
- trusted ClawSweeper repairs are capped per PR and per PR head SHA.
The default caps are ten automatic repair iterations per PR and one
auto-repair dispatch per PR head SHA:
The default caps are ten automatic repair iterations per PR and two
auto-repair dispatches per PR head SHA:
```bash
CLAWSWEEPER_MAX_REPAIRS_PER_PR=10
CLAWSWEEPER_MAX_REPAIRS_PER_HEAD=1
CLAWSWEEPER_MAX_REPAIRS_PER_HEAD=2
```
That means many ClawSweeper comments on the same commit trigger at most one
repair run. If ClawSweeper pushes a new commit, the PR head SHA changes and a
new ClawSweeper finding can trigger one more repair run, until the PR reaches
That means many ClawSweeper comments on the same commit trigger at most two
repair runs, leaving room for one infrastructure retry without an operator
reset. If ClawSweeper pushes a new commit, the PR head SHA changes and a new
ClawSweeper finding can trigger another bounded repair run, until the PR reaches
ten automatic ClawSweeper-triggered repair iterations. The per-PR cap is total
across all head SHAs and stops the automatic review/repair loop even when every
iteration produces a new commit.
@ -341,7 +346,7 @@ Important knobs:
- `CLAWSWEEPER_MAX_REPAIRS_PER_PR` controls total automatic repair
iterations per PR; default `10`.
- `CLAWSWEEPER_MAX_REPAIRS_PER_HEAD` controls per-head repair caps;
default `1`.
default `2`.
- `CLAWSWEEPER_AUTOMERGE_TRANSIENT_WAIT_MS` controls in-run merge-state and
check polling before the router records a waiting automerge action; default
`600000`.

View File

@ -146,7 +146,8 @@ The cluster worker has two jobs:
2. `execute`
- runs only for `execute` or `autonomous`
- mints a write GitHub App token when configured
- mints a write GitHub App token, including workflow-file write permission,
when configured
- downloads worker artifacts
- runs `execute-fix-artifact`
- runs `apply-result`

View File

@ -361,7 +361,7 @@ ClawSweeper PRs and PRs labeled `clawsweeper:autofix` or
ClawSweeper comments include `clawsweeper-verdict:*` markers plus a
`clawsweeper-action:fix-required` marker when ClawSweeper should wake up. The
router dispatches at most ten automatic repair iterations per PR and at most
one auto-repair per PR head SHA by default, controlled by
two auto-repairs per PR head SHA by default, controlled by
`CLAWSWEEPER_MAX_REPAIRS_PER_PR` and
`CLAWSWEEPER_MAX_REPAIRS_PER_HEAD`. The per-PR cap is total across
head SHA changes, so the automatic loop stops after ten ClawSweeper-triggered

View File

@ -4,6 +4,10 @@ Read when changing `.github/workflows/sweep.yml`, `src/clawsweeper.ts` planner
selection, review cadence, dashboard capacity fields, or GitHub Actions
concurrency for issue/PR review and apply.
The global worker budget comes from `config/automation-limits.json`; see
[Automation Limits](limits.md) for the derived lane limits and GitHub variable
overrides.
ClawSweeper has three issue/PR scheduler paths:
- exact event review for one target issue or pull request
@ -12,12 +16,12 @@ ClawSweeper has three issue/PR scheduler paths:
The lanes share report storage and apply rules, but they intentionally do not
share throughput. Event review and hot intake keep new maintainer-visible work
fast. Normal backfill keeps older records moving with up to 100 concurrent Codex
review shards. Normal `openclaw/openclaw` review has an active floor of 50
shards for scheduled runs and workflow-dispatch continuations: due items win
first, and if fewer than 50 items are due, the planner fills the floor with the
stalest currently-reviewed eligible items so review capacity stays warm around
the clock.
fast. Normal backfill keeps older records moving with up to 70 concurrent Codex
review shards when the system is quiet. Normal `openclaw/openclaw` review has an
active floor of 30 shards for scheduled runs and workflow-dispatch
continuations: due items win first, and if fewer than 30 items are due, the
planner fills the floor with the stalest currently-reviewed eligible items so
review capacity stays warm around the clock.
## Workflow
@ -27,6 +31,9 @@ Important source files:
- `src/clawsweeper.ts`: item selection, cadence, planning, review, dashboard,
and status JSON
- `config/target-repositories.json`: configured non-core target repositories
and the conservative `openclaw/*` exact-review fallback
- `docs/target-repositories.md`: target onboarding and rollout checklist
- `src/repair/workflow-utils.ts`: GitHub Actions output shaping for plans
- `results/sweep-status/<repo-slug>.json`: generated state consumed by the
dashboard
@ -38,6 +45,8 @@ normal review cannot overlap another normal review for the same target repo.
GitHub may keep one pending run for a concurrency group; newer scheduled runs
can replace older pending runs, but they do not cancel a running normal review
because `cancel-in-progress` is only true for exact `repository_dispatch` runs.
Manual exact-item `workflow_dispatch` reviews use an exact-item concurrency
group, so targeted maintainer checks do not wait behind broad normal backfill.
## Schedules
@ -62,9 +71,25 @@ because `cancel-in-progress` is only true for exact `repository_dispatch` runs.
- self-review is primarily manual or event-driven; scheduled audit keeps the
dashboard health row fresh
`openclaw/fs-safe`:
- exact event review: enabled through the target repository dispatcher
- scheduled review/apply/audit: not enabled yet
- issues are review/comment-only; PRs may auto-close only when already
implemented on `main`
Other `openclaw/*` repositories:
- exact event/manual review: supported through the generic conservative
fallback after the target dispatcher and GitHub App installation are present
- scheduled review/apply/audit: not enabled automatically
- issues are review/comment-only; PRs may auto-close only when already
implemented on `main`
Manual `workflow_dispatch` can override `target_repo`, `item_number`,
`item_numbers`, `batch_size`, `shard_count`, `hot_intake`, and apply inputs.
Exact item dispatches use the event path instead of the planner matrix.
Exact item dispatches use a dedicated concurrency group and exact planner
matrix rather than the broad normal-review queue.
Exact event review also starts Codex before generated-state hydration. The
single-item review only needs the target repository and live GitHub item state;
@ -101,8 +126,15 @@ config checksum three-way conflicts, push the repaired branch, then wait for
exact-head review and GitHub checks. For substantive automerge repairs, Codex
owns the initial rebase plus PR-comment, CI, and local-test repair loop; the
executor still owns every GitHub mutation and reruns the normalized validation
gate before push. The default shepherd wait is ten minutes with 15-second polls,
controlled by
gate before push. If `main` moves during that final validation, the worker does
one final base sync by default and lets the immediate exact-head review plus
GitHub checks validate the pushed head; `CLAWSWEEPER_FINAL_BASE_SYNC_ATTEMPTS`
can raise that only when extra local passes are intentionally worth the delay.
Likewise, the last internal Codex `/review` is not a dead end: if it still finds
an actionable issue, the worker can run one final review-fix pass, require
changed-surface validation to pass, push the repaired branch, and leave the
immediate exact-head review plus GitHub checks as the merge authority.
The default shepherd wait is ten minutes with 15-second polls, controlled by
`CLAWSWEEPER_AUTOMERGE_SHEPHERD_WAIT_MS` and
`CLAWSWEEPER_AUTOMERGE_SHEPHERD_POLL_MS`. Terminal check failures stop the
shepherd wait immediately and dispatch the router so the failed-check repair
@ -125,17 +157,26 @@ Capacity is shard-level. A review shard processes its selected item numbers
sequentially, so maximum concurrent Codex sessions equals the number of nonempty
review shard jobs, not `batch_size * shard_count`.
Defaults:
Capacity also has priority. Exact-item review, repair, automerge repair, and
issue implementation are priority work because they unblock a specific PR,
issue, or maintainer command. Normal review, hot intake, and commit review are
background work because they keep the backlog fresh but can safely slow down
when priority work is busy. The workflow asks the central worker scheduler for a
lane limit before dispatching background work; see
[`docs/limits.md`](limits.md) for the config, formulas, and examples.
Current defaults:
- exact event review: 1 shard, 1 item
- exact manual hot intake: 1 shard, 1 item
- broad hot intake: 50 shards, batch size 1, scans up to 10 GitHub pages
- scheduled normal backfill: 100 shards, batch size 1, scans up to 250 GitHub
pages
- normal active floor: 50 shards for `openclaw/openclaw` scheduled runs and
- broad hot intake: up to 35 shards when quiet, batch size 1, scans up to 10
GitHub pages
- scheduled normal backfill: up to 70 shards when quiet, batch size 1, scans up
to 250 GitHub pages
- normal active floor: 30 shards for `openclaw/openclaw` scheduled runs and
workflow-dispatch continuations; stale current-review backfill is eligible
after 30 minutes
- manual normal backfill: defaults to 100 shards, batch size 3, scans up to 250
- manual normal backfill: defaults to 70 shards, batch size 3, scans up to 250
GitHub pages unless overridden, and stops early once scanned due candidates
fill planned capacity
@ -146,7 +187,7 @@ Planning is also the runtime build point for matrix review. The plan job install
with pinned Node 24 and `pnpm@10.33.2`, builds `dist/` once, and uploads that
runtime artifact. Review shards download the built `dist/` and run
`node dist/clawsweeper.js review` directly instead of running a per-shard pnpm
install and build. This keeps 50-100 shard waves from stampeding the npm
install and build. This keeps 35-70 shard waves from stampeding the npm
registry or Corepack metadata endpoints.
Each review shard also wraps the review command in a shell timeout derived from
@ -164,15 +205,24 @@ because they may rebase and push generated records.
Normal backfill now runs every 5 minutes for `openclaw/openclaw`. Because its
concurrency group allows only one running normal backfill per target repo, the
effect is a continuous drain loop: when due backlog exists, the active run can
hold about 100 Codex review shards with one item per shard, and the next
hold about 70 Codex review shards with one item per shard, and the next
scheduled tick is available as the backstop or pending continuation. Manual
normal reviews keep the larger default batch size for targeted catch-up runs.
The quiet-system ceiling is not a promise that every scheduled run dispatches
that many shards. The `mode` step checks active repair workers, exact-item sweep
runs, and commit-review pages, then asks `worker-limit normal_review` or
`worker-limit hot_intake` for the current allowance. If repair/automerge is
busy, background sweep dispatches fewer shards and leaves capacity for the
specific work that is closest to a merge or maintainer request.
The active floor is not a separate lane and does not change close/apply safety.
It only changes normal planning when due backlog is below the desired floor:
after selecting all due candidates, the planner fills up to 50 nonempty shards
after selecting all due candidates, the planner fills up to 30 nonempty shards
with eligible items whose latest complete review is at least 30 minutes old.
Capacity status reports this as `floor: due backlog below active floor`.
Capacity status reports this as `floor: due backlog below active floor`. If the
central worker scheduler returns fewer than 30 allowed shards, the smaller
worker allowance wins.
On saturated queues, normal planning stops scanning as soon as it has enough due
candidates to fill `batch_size * shard_count`. `dueBacklog` remains the due
@ -224,7 +274,6 @@ pnpm run --silent plan -- \
--codex-model gpt-5.5 \
--codex-reasoning-effort high \
--codex-sandbox danger-full-access \
--codex-service-tier fast \
--min-active-shards "$MIN_ACTIVE_SHARDS" \
--min-backfill-review-age-minutes "$MIN_BACKFILL_REVIEW_AGE_MINUTES"
```
@ -265,8 +314,10 @@ Capacity reasons:
## Status and Dashboard
Planning and publish steps call `pnpm run status`, which writes structured JSON
under `results/sweep-status/` in generated state. The README dashboard reads
that JSON and shows:
under `results/sweep-status/<repo-slug>.json` in generated state. Every sweep
workflow status update must pass the active `--target-repo` so a ClawHub,
ClawSweeper, or OpenClaw lane updates only its own dashboard row. The README
dashboard reads that JSON and shows:
- active Codex target
- planned review items
@ -280,6 +331,14 @@ that JSON and shows:
current run. It is not a live process count from GitHub Actions. For live worker
count, inspect active review shard jobs on the current workflow run.
The live scheduler estimate happens before planning and is intentionally coarse:
it counts active repair-cluster workflow runs as priority work, active exact-item
sweep runs as priority work, active commit-review workflow runs as background
work weighted by the configured commit page size, and other active normal/hot
sweep runs as background work weighted by their quiet-system ceilings. GitHub
Actions can start or finish jobs after that estimate, so the scheduler is a
throttle, not a distributed lock.
Planning status intentionally does not run `pnpm run reconcile`. Reconciliation
can scan many live GitHub pages and has delayed review shard startup. The
critical path records the planned counts and publishes only
@ -306,7 +365,7 @@ or syncs the durable ClawSweeper review comment.
Broad normal review publishes records first, then dispatches durable review
comment sync into the separate apply/comment-sync lane. This includes scheduled
runs and workflow-dispatch continuations, so slow GitHub comment writes do not
hold the normal review concurrency group or delay the next 100-shard backfill
hold the normal review concurrency group or delay the next 70-shard backfill
wave. Exact issue/PR reviews and repository-dispatch item runs still sync their
selected comments inline before finishing.
@ -332,6 +391,13 @@ repo, start/end timestamps, and review-step outcome. Publish includes artifact
and metric counts in the status detail so setup noise, missing artifacts, and
real review failures can be separated while monitoring.
Each item report also records durable review cost proxies in front matter and a
`Review Telemetry` section: prompt characters, static prompt characters, GitHub
context characters, output schema characters, additional prompt characters,
context collection milliseconds, and Codex review milliseconds. These fields are
intended for scheduler and prompt-budget experiments, so later throughput work
can compare time and token proxies without scraping transient workflow logs.
The generated state checkout uses a blobless partial clone, but it intentionally
keeps full commit history by default. Publish jobs rebase and retry state writes
after races, and shallow state history can make those retries less reliable.
@ -374,8 +440,9 @@ schedule remains the fallback if dispatch is delayed.
Useful commands:
```bash
gh run list --repo openclaw/clawsweeper --workflow sweep.yml --limit 20 \
--json databaseId,displayTitle,event,status,conclusion,createdAt,headSha,url
gh run list --repo openclaw/clawsweeper --limit 100 \
--json databaseId,workflowName,displayTitle,event,status,conclusion,createdAt,headSha,url \
--jq '.[] | select(.workflowName == "ClawSweeper")'
gh run view <run-id> --repo openclaw/clawsweeper --json jobs \
--jq '[.jobs[] | select(.name | startswith("Review shard")) | select(.status=="in_progress")] | length'

View File

@ -22,6 +22,13 @@ For issue and PR dispatch, copy this workflow into each target repository as
`.github/workflows/clawsweeper-dispatch.yml`, or merge these triggers and the
`Dispatch exact ClawSweeper review` step into an existing combined dispatcher:
Target repositories no longer need a TypeScript profile before exact event
review can run. Any installed `openclaw/*` repository that is not denied in
`config/target-repositories.json` uses the conservative generic profile:
issues stay open, and PRs can auto-close only when already implemented on
`main`. Add a config entry only when the repo should appear in the dashboard or
needs repo-specific review guidance.
```yaml
name: ClawSweeper Dispatch
@ -119,7 +126,7 @@ jobs:
fi
body_file="$RUNNER_TEMP/clawsweeper-comment-body.txt"
printf '%s\n' "$COMMENT_BODY" > "$body_file"
if ! grep -Eiq '(^|[[:space:]])@clawsweeper\b|(^|[[:space:]])/(clawsweeper|review|automerge|autoclose)\b' "$body_file"; then
if ! grep -Eiq '(^|[[:space:]])@clawsweeper\b|(^|[[:space:]])/(clawsweeper|review|re-review|rerun[ -]?review|status|explain|fix|build|implement|create[ -]?pr|fix[ -]?issue|autofix|auto[ -]?fix|automerge|auto[ -]?merge|approve|stop|autoclose)\b' "$body_file"; then
echo "No ClawSweeper command found in comment."
exit 0
fi

View File

@ -0,0 +1,64 @@
# Target Repositories
Read when enabling ClawSweeper for another OpenClaw repository, changing
`config/target-repositories.json`, or debugging `Unsupported target repo`
failures.
ClawSweeper has two target-repository paths:
- configured dashboard targets in `config/target-repositories.json`
- a conservative generic fallback for exact event/manual reviews of
`openclaw/*` repositories
`openclaw/openclaw` remains a built-in profile because it has broader
auto-close policy. Other configured targets default to safer repo-local rules:
issues are review/comment-only, and PRs may auto-close only when the same
change is certainly already implemented on `main`.
## Generic OpenClaw Fallback
The fallback lets a newly installed OpenClaw repo dispatch to ClawSweeper
without a TypeScript change. It is intentionally narrow:
- owner must be `openclaw`
- repo name must match `allow_repo_name_pattern`
- denied repositories are rejected
- issues cannot be auto-closed
- PRs can auto-close only for `implemented_on_main`
- scheduled dashboard/backfill rows are not added automatically
This is enough for event-driven review after the target repo has the dispatcher
workflow and GitHub App installation. It is not a blanket scheduled rollout.
## Add One Repository
1. Install the ClawSweeper GitHub App on the target repository.
2. Add or merge the target dispatcher from
[`docs/target-dispatcher.md`](target-dispatcher.md).
3. Ensure the target repo can read the org or repo
`CLAWSWEEPER_APP_PRIVATE_KEY` secret.
4. Open, edit, or comment on a target issue/PR and confirm a dispatcher run
appears in the target repo.
5. Confirm the receiver run appears in
`https://github.com/openclaw/clawsweeper/actions`.
6. Confirm the target item gets one durable ClawSweeper review comment.
For a repo that should appear in the README dashboard or scheduled queues, add
it to `config/target-repositories.json` with an explicit prompt note and
close-policy block. Keep the default policy unless the repo has a documented
reason to allow broader issue closes.
## Add Many Repositories
Batch rollout should be incremental:
- install the app and dispatcher on a small group first
- leave scheduled backfill off
- verify event review/comment sync on one issue or PR per repo
- add config entries for repos that should show in the dashboard
- enable scheduled backfill/apply only after repo-specific safety rules exist
If a target dispatch reaches ClawSweeper but receiver token creation fails, the
App is usually not installed on that target repo. If the target workflow skips
before dispatch, the target repo usually cannot access
`CLAWSWEEPER_APP_PRIVATE_KEY`.

View File

@ -16,7 +16,16 @@ Reports store the lane fields in frontmatter:
- `work_cluster_refs`, `work_validation`, and `work_likely_files`
The dashboard shows fresh `queue_fix_pr` reports whose `work_status` is
`candidate`. This is a manual promotion queue for the repair lane.
`candidate`. This is a manual promotion queue for the repair lane. For each
fresh candidate, apply/reconcile also generates
`records/<repo-slug>/plans/<number>.md` from the existing report fields. The
dashboard links both the source report and the generated coding plan so
maintainers can promote from a concise implementation view without editing the
durable report.
Plan artifacts are generated state. They are removed when the item closes,
archives, becomes stale, or is reclassified away from `queue_fix_pr`; regenerate
them from the source report instead of editing them by hand.
## Reproducible Bug Auto-Implementation

View File

@ -29,6 +29,13 @@ owner discussion, green checks, or a focused fix that should be preserved.
generated baselines, other extensions, runtime surfaces, or review artifacts.
- Bot/review spam: repeated bot pings or copied bot output without author-owned
fixes, especially when checks are still red.
- Missing or mock-only real behavior proof: an external PR claims a behavior fix
but provides no after-fix evidence from a real setup, or only lists unit tests,
mocks, snapshots, lint, typechecks, or CI. Ask for screenshots, terminal
screenshots, console output, copied live output, linked artifacts, recordings,
or redacted runtime logs instead. For browser runtime, network, CSP, or
security changes, an ordinary app screenshot or "no visible console violation"
claim is not enough without visible diagnostic output.
## Evidence bar
@ -98,6 +105,8 @@ needed.
- Security-sensitive PRs are not low-signal cleanup. Route them to central
OpenClaw security handling instead of ClawSweeper Repair.
- A green PR with a focused bug fix and clear reproduction.
- A PR with sufficient after-fix real behavior proof from a real setup, even if
the proof is a terminal screenshot, console excerpt, or redacted runtime log.
- A PR with recent maintainer review, assignment, or active author follow-up.
- A unique bug report with reproduction detail, even if noisy.
- Anything that would require technical judgment about correctness beyond the

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/clawsweeper",
"version": "0.2.0",
"version": "0.2.1",
"private": true,
"license": "MIT",
"type": "module",
@ -41,6 +41,7 @@
"repair:notify-events": "node dist/repair/notify-events.js",
"repair:notify-github-activity": "node dist/repair/notify-github-activity.js",
"repair:publish-event-result": "node dist/repair/publish-event-result.js",
"repair:update-command-status": "node dist/repair/update-command-status.js",
"workflow": "node dist/repair/workflow-utils.js",
"repair:promote-stuck-jobs": "node dist/repair/promote-stuck-jobs.js",
"repair:sweep-openclaw-jobs": "node dist/repair/sweep-openclaw-jobs.js",
@ -57,15 +58,16 @@
"test:coverage": "pnpm run build:all && node --test --experimental-test-coverage --test-coverage-include='dist/**/*.js' --test-coverage-exclude='dist/repair/*.test.js' --test-coverage-lines=49 --test-coverage-branches=66 --test-coverage-functions=57 test/*.test.ts test/repair/*.test.ts dist/repair/*.test.js",
"test:coverage:changed": "pnpm run build:all && node --test --experimental-test-coverage --test-coverage-include='dist/repair/fix-prompt-builder.js' --test-coverage-lines=85 --test-coverage-branches=85 --test-coverage-functions=85 test/repair/*.test.ts dist/repair/*.test.js",
"check:active-surface": "node scripts/check-active-surface.ts",
"check:limits": "node scripts/check-limits.ts",
"lint": "pnpm run lint:src && pnpm run lint:repair && pnpm run lint:scripts",
"lint:src": "oxlint src/*.ts --tsconfig tsconfig.json --type-aware --deny-warnings --report-unused-disable-directives -D correctness",
"lint:repair": "oxlint src/repair --tsconfig tsconfig.repair.json --deny-warnings --report-unused-disable-directives -D correctness",
"lint:scripts": "oxlint scripts test --deny-warnings --report-unused-disable-directives -D correctness",
"format": "oxfmt --write src scripts test package.json tsconfig.json tsconfig.repair.json .oxfmtrc.json schema .github/actions .github/workflows",
"format:check": "oxfmt --check src scripts test package.json tsconfig.json tsconfig.repair.json .oxfmtrc.json schema .github/actions .github/workflows",
"format": "oxfmt --write src scripts test package.json tsconfig.json tsconfig.repair.json .oxfmtrc.json config schema .github/actions .github/workflows",
"format:check": "oxfmt --check src scripts test package.json tsconfig.json tsconfig.repair.json .oxfmtrc.json config schema .github/actions .github/workflows",
"oxformat": "pnpm run format",
"oxformat:check": "pnpm run format:check",
"check": "pnpm run check:active-surface && pnpm run build:all && pnpm run lint && pnpm run test:unit && pnpm run test:repair && pnpm run test:coverage:changed && pnpm run test:coverage && pnpm run format:check"
"check": "pnpm run check:active-surface && pnpm run check:limits && pnpm run build:all && pnpm run lint && pnpm run test:unit && pnpm run test:repair && pnpm run test:coverage:changed && pnpm run test:coverage && pnpm run format:check"
},
"devDependencies": {
"@types/node": "^25.6.0",

View File

@ -58,8 +58,10 @@ item reports broken existing behavior and the expected behavior is already
defined by current docs, tests, CLI/API contract, or established behavior. Do
not classify requests for a new capability, config option, flag, mode,
provider, workflow, fallback, UX change, or policy choice as bugs; use
`feature`, `support`, `admin`, `docs`, `cleanup`, `security`, or `unclear`
instead. Set `requiresNewFeature`, `requiresNewConfigOption`, and
`feature`, `skill`, `support`, `admin`, `docs`, `cleanup`, `security`, or
`unclear` instead. Set `itemCategory: "skill"` when the primary change is an
optional skill bundle, skill documentation, or skill-only PR that can live
outside OpenClaw core. Set `requiresNewFeature`, `requiresNewConfigOption`, and
`requiresProductDecision` independently. Any true value means the item is not a
strict bug-fix automation candidate even if useful.
@ -84,6 +86,8 @@ likely owner.
For PRs, include a dedicated security review pass in addition to the functional review. Inspect whether the diff could introduce a security or supply-chain regression, especially when it touches CI workflows, GitHub Action refs, dependency sources, lockfiles, install/build/release scripts, package publishing metadata, secrets handling, permissions, downloaded artifacts, generated/vendor/minified files, or other code execution paths. Check whether those changes are consistent with the PR title, body, discussion, and stated purpose before deciding. Be cautious when a small or unrelated functional change also introduces new third-party code execution, broadens secret or permission access, changes package resolution, adds lifecycle hooks, downloads and executes artifacts, or mixes infrastructure changes into otherwise cosmetic work. Do not infer malicious intent without concrete evidence. Always summarize this pass in `securityReview`; set `status: "cleared"` when the diff has no concrete security or supply-chain concern, `status: "needs_attention"` when there is a concrete concern, and `status: "not_applicable"` for non-PR items without a security-sensitive report. Put concrete security concerns in `securityReview.concerns` with file/line when possible, and also include blocking concerns in `risks` and `evidence` when they affect the merge/close decision.
For PRs, include a dedicated `realBehaviorProof` assessment before any pass, automerge, or repair verdict. External PRs must show that the contributor ran the changed behavior after the fix in a real setup. Unit tests, mocks, snapshots, lint, typechecks, and CI are supplemental only; they are not real behavior proof by themselves. Treat screenshots, recordings, terminal screenshots, console output, copied live output, linked artifacts, and redacted runtime logs as valid proof, including for non-visual CLI, console, text, or error-message changes. Prefer asking for screenshots or videos when they can show the behavior, including terminal screenshots for text or console changes, while keeping logs and live output acceptable. Remind contributors to redact private information like IP addresses, API keys, phone numbers, non-public endpoints, and other private details before posting evidence. A plain app screenshot is sufficient only for behavior it directly shows. Do not mark screenshot-only proof sufficient for browser runtime, CSP, CORS, `connect-src`, auth callback, network, or security changes when the proof only says no console error, warning, or violation is visible; require console output, a network trace, terminal/live output, logs, a recording with diagnostics, or a linked artifact that actually shows the runtime path. Use your tools and best judgement: inspect the PR body, comments, links, screenshots, videos, logs, terminal output, and changed behavior context; you may download/open GitHub attachment links, generate stills or contact sheets from videos, inspect terminal screenshots and logs, and compare the proof against the PR diff. Use the provided scratch directory for downloaded artifacts and keep the target checkout read-only. Use `status: "sufficient"` only when the evidence convincingly shows after-fix real behavior and an observed improved result. Use `status: "missing"` when proof is absent, `status: "mock_only"` when proof is only tests/mocks/CI, `status: "insufficient"` when the evidence is unrelated, unviewable, too weak, or does not show the changed real behavior after the fix, `status: "override"` when the PR has `proof: override`, and `status: "not_applicable"` for non-PR items or maintainer/bot PRs where the gate does not apply. When proof is missing, mock-only, or insufficient, set `needsContributorAction: true`, make the PR a human-only merge blocker, and do not request ClawSweeper repair markers because automation cannot prove the contributor's setup for them.
For PRs, also emit Codex `/review`-style findings in `reviewFindings`.
Review the diff as another engineer's proposed patch and list every discrete,
actionable bug the author would likely fix. Findings must be introduced by the
@ -108,11 +112,13 @@ Use reason-specific anchors:
`git show -s --format=%H%n%cI%n%s <sha>`, `git tag --contains <sha>`,
`git branch --contains <sha>`, `git show <tag>:CHANGELOG.md`, and
`gh release list/view` when available. Determine the fix/proof commit, the
commit timestamp, and whether that commit is included in a shipped release. If
the fix shipped, name the exact release tag/version. If it is only on current
`main`, say that and include the commit timestamp. If you cannot establish
either the shipped release or the main-only timestamp with high confidence,
keep the item open.
commit timestamp, whether a merged PR closed the issue, and whether that
commit is included in a shipped release. If the GitHub context includes a
merged `closingPullRequests` entry, mention that PR as provenance when it
matches the implementation evidence. If the fix shipped, name the exact
release tag/version. If it is only on current `main`, say that and include the
commit timestamp. If you cannot establish either the shipped release or the
main-only timestamp with high confidence, keep the item open.
- For `clawhub`, inspect `VISION.md` and the relevant plugin/skill/MCP/channel/provider docs or APIs, then confirm the request can be satisfied outside core without a missing extension API.
- For `duplicate_or_superseded`, read the canonical related report/PR from the provided context or `gh`, and explain whether it is open, closed, merged, or already shipped.
- For `not_actionable_in_repo`, read enough discussion/context to confirm the action belongs to repo/project administration, third-party setup, external ownership, or historical cleanup rather than OpenClaw code/docs.
@ -128,7 +134,7 @@ Close only when the evidence is strong and the repository policy allows it. Allo
- `implemented_on_main`: current `main` already implements or fixes the request well enough.
- `cannot_reproduce`: you tried a reasonable reproduction path against current `main` and it does not reproduce, or the report is obsolete and no longer matches current behavior.
- `clawhub`: useful idea, but it belongs as a ClawHub skill/plugin rather than OpenClaw core. Use `VISION.md` as the scope anchor. Prefer this when the requested capability is optional integration/provider/channel/skill/bundle/MCP work, can be built with current skill/MCP/plugin surfaces, has no concrete missing core extension API, and has no protected maintainer signal. This includes service-specific channels, providers, optional skills, and plugin-discovery/publishing ideas when the current plugin or bundle-style interface is sufficient. Keep open when the item reports a regression in bundled core behavior, identifies a missing plugin API needed before external implementation is possible, involves security/core hardening, or clearly needs explicit maintainer product judgment.
- `clawhub`: useful idea, but it belongs as a ClawHub skill/plugin rather than OpenClaw core. Use `VISION.md` as the scope anchor. Prefer this when the requested capability is optional integration/provider/channel/skill/bundle/MCP work, can be built with current skill/MCP/plugin surfaces, has no concrete missing core extension API, and has no protected maintainer signal. This includes service-specific channels, providers, optional skills, and plugin-discovery/publishing ideas when the current plugin or bundle-style interface is sufficient. For OpenClaw PRs that only add bundled skills under paths like `skills/<vendor>/**`, set `itemCategory: "skill"` and prefer `closeReason: "clawhub"` with high confidence; the close comment should ask the contributor to upload or publish it through ClawHub.com instead of bundling it in OpenClaw core. Keep open when the item reports a regression in bundled core behavior, identifies a missing plugin API needed before external implementation is possible, involves security/core hardening, or clearly needs explicit maintainer product judgment.
- `duplicate_or_superseded`: another issue/PR already tracks the same remaining work, or the linked discussion/PR clearly supersedes this item. Link the canonical item and explain whether it is open or closed/merged. For clusters with the same root cause, keep one canonical issue open and close satellites when their unique logs, platforms, or context can be preserved by linking them in the close comment. Unique evidence blocks duplicate close only when it implies a distinct root cause, platform-specific fix, or separate remaining product behavior.
- `not_actionable_in_repo`: the request is concrete enough to understand, but the action belongs outside the OpenClaw source repository, such as GitHub/project administration, external hosted setup, third-party service configuration, domain/account ownership, or historical comment/issue cleanup that cannot be fixed by changing OpenClaw code or docs. Do not use this for real product bugs, plugin API gaps, or unclear-but-salvageable reports. Use this for setup/support reports, one-line reports, screenshot-only reports, or credential-redaction incidents only when current code/docs show the behavior is expected or externally configured and the item lacks a concrete source-level reproduction. Do not keep these open only to collect support logs; the close comment should ask for credential rotation/redaction when relevant and point to the exact diagnostic command or docs page needed for a new actionable report.
- `incoherent`: the item is too unclear or internally contradictory after reading the title/body/comments.
@ -211,8 +217,10 @@ implemented by GitHub or by apply.
In user-visible prose, avoid bare self-references to the current item such as
`#123`, `Issue #123`, `PR #123`, or quoted closing syntax like `Fixes #123`.
Write `this issue` or `this PR` instead. Keep other issue/PR references as
normal `#123` links when they point to different items.
Write `this issue` or `this PR` instead. For every other issue or PR reference,
use the full GitHub URL, such as `https://github.com/owner/repo/issues/123` or
`https://github.com/owner/repo/pull/123`; do not write bare `#123`, `Issue
#123`, or `PR #123` references in public prose.
Keep open when the current item appears paired with an open issue or PR by the
same author. Contributor issues and PRs commonly arrive as a pair for the same
@ -316,6 +324,24 @@ typed concerns when the patch or discussion raises a concrete security issue,
and `not_applicable` for ordinary non-PR issue triage where no patch security
review applies.
Always fill `realBehaviorProof`. For external PRs, this is a merge gate, not a
nice-to-have. Missing, mock-only, or insufficient proof should appear near the
top of the public review as "needs real behavior proof before merge"; tell the
contributor that screenshots or videos are preferred when they can show the
behavior; terminal screenshots, console output, copied live output, linked artifacts,
recordings, and redacted logs count. Remind contributors to redact private
information like IP addresses, API keys, phone numbers, non-public endpoints,
and other private details before posting evidence. For non-visual browser
runtime, network, CSP, or security behavior, do not accept an ordinary app
screenshot or "no visible console violation" claim without visible diagnostic
output. If the proof links to public or GitHub-hosted media, inspect it when
possible before deciding. Also tell contributors that after they add proof,
updating the PR body should trigger a fresh ClawSweeper review automatically; if
it does not, they can ask a maintainer to comment `@clawsweeper re-review`. Use
`evidenceKind: "none"` when proof is absent or mock-only, and set
`needsContributorAction: false` only for `sufficient`, `override`, or
`not_applicable`.
Always fill the work-lane fields too. For non-candidates, use
`workCandidate: "none"`, low confidence/priority, an empty `workPrompt`, and
empty arrays. For manual-review items, use `workCandidate: "manual_review"` and

View File

@ -22,6 +22,7 @@
"solutionAssessment",
"reviewFindings",
"securityReview",
"realBehaviorProof",
"overallCorrectness",
"overallConfidenceScore",
"fixedRelease",
@ -151,6 +152,7 @@
"bug",
"regression",
"feature",
"skill",
"docs",
"cleanup",
"support",
@ -158,7 +160,7 @@
"security",
"unclear"
],
"description": "Primary item category. Use bug only for broken existing behavior with an already-defined expected behavior; feature/config/product changes are not bugs."
"description": "Primary item category. Use bug only for broken existing behavior with an already-defined expected behavior; use skill for optional skill bundles or skill-only pull requests; feature/config/product changes are not bugs."
},
"reproductionStatus": {
"type": "string",
@ -291,6 +293,47 @@
}
}
},
"realBehaviorProof": {
"type": "object",
"additionalProperties": false,
"description": "Dedicated PR proof assessment. For external PRs, after-fix evidence from a real setup is required; tests, mocks, snapshots, lint, typechecks, and CI are supplemental only.",
"required": ["status", "summary", "evidenceKind", "needsContributorAction"],
"properties": {
"status": {
"type": "string",
"enum": [
"sufficient",
"missing",
"mock_only",
"insufficient",
"not_applicable",
"override"
]
},
"summary": {
"type": "string",
"description": "One concise sentence explaining whether the PR body contains after-fix real behavior proof. Do not call screenshot-only proof sufficient for browser runtime, CSP, CORS, network, or security changes when it only claims no console error or violation is visible."
},
"evidenceKind": {
"type": "string",
"description": "Use screenshot only for directly visible UI or flow proof. For browser runtime, CSP, CORS, network, or security proof, prefer terminal, logs, live_output, or linked_artifact unless the media visibly includes the diagnostic output.",
"enum": [
"screenshot",
"recording",
"terminal",
"logs",
"live_output",
"linked_artifact",
"none",
"not_applicable"
]
},
"needsContributorAction": {
"type": "boolean",
"description": "True when the contributor must add or improve real behavior proof before merge. This must be false for sufficient, override, and not_applicable statuses."
}
}
},
"overallCorrectness": {
"type": "string",
"enum": ["patch is correct", "patch is incorrect", "not a patch"],

View File

@ -1,6 +1,7 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { socialCardPng } from "./social-card.mjs";
const root = process.cwd();
const docsDir = path.join(root, "docs");
@ -80,6 +81,7 @@ for (const page of pages) {
fs.writeFileSync(path.join(outDir, "clawsweeper.svg"), clawSvg(), "utf8");
fs.writeFileSync(path.join(outDir, "favicon.svg"), faviconSvg(), "utf8");
fs.writeFileSync(path.join(outDir, "social-card.png"), socialCardPng());
fs.writeFileSync(path.join(outDir, ".nojekyll"), "", "utf8");
fs.writeFileSync(path.join(outDir, "CNAME"), `${customDomain}\n`, "utf8");
fs.writeFileSync(
@ -325,7 +327,13 @@ function layout({ page, html, toc, prev, next, sectionName }) {
<meta property="og:description" content="${escapeAttr(description)}">
<meta property="og:type" content="website">
<meta property="og:url" content="https://${customDomain}/${page.outRel === "index.html" ? "" : page.outRel}">
<meta name="twitter:card" content="summary">
<meta property="og:image" content="https://${customDomain}/social-card.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="ClawSweeper: conservative OpenClaw maintenance bot">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="https://${customDomain}/social-card.png">
<meta name="twitter:image:alt" content="ClawSweeper: conservative OpenClaw maintenance bot">
<link rel="icon" type="image/svg+xml" href="${rootPrefix}favicon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@ -385,7 +393,7 @@ function landingHero(rootPrefix) {
<div class="hero-text">
<p class="eyebrow">OpenClaw - maintenance bot</p>
<h1>Sideways through the <em>backlog</em>.<br>Sweep what's safe.<br>Leave the rest.</h1>
<p class="lede">ClawSweeper is the conservative maintenance bot for OpenClaw. It reads every open issue, pull request, and commit on <code>main</code>; it writes one durable review comment per item; and it only proposes a close when the evidence is strong.</p>
<p class="lede">ClawSweeper is the conservative maintenance bot for OpenClaw. It reviews issues, pull requests, and code-bearing commits; keeps one durable public comment per item; and turns narrow trusted findings into guarded repair or automerge work.</p>
<div class="cta">
<a class="cta-primary" href="${rootPrefix}scheduler.html">Read the docs</a>
<a class="cta-secondary" href="${repoUrl}" rel="noopener">View on GitHub</a>
@ -402,7 +410,7 @@ function landingBody() {
const features = [
[
"One report per item",
"Every open issue and PR becomes <code>records/&lt;repo&gt;/items/&lt;n&gt;.md</code>: decision, evidence, proposed comment, snapshot hash. Nothing else.",
"Every reviewed issue and PR becomes <code>records/&lt;repo&gt;/items/&lt;n&gt;.md</code>: decision, evidence, proposed comment, runtime metadata, and snapshot hash.",
"report",
],
[
@ -416,8 +424,8 @@ function landingBody() {
"shield",
],
[
"Two independent lanes",
"Issue/PR sweeper and commit sweeper run separately. Commit reviews land at <code>records/&lt;repo&gt;/commits/&lt;sha&gt;.md</code> with optional Check Runs.",
"Four operational lanes",
"Review, apply, repair, and commit review run as separate lanes. Each lane has its own state, gates, and GitHub Actions path.",
"lanes",
],
[
@ -427,7 +435,7 @@ function landingBody() {
],
[
"Repair, gated",
"When a finding is narrow, non-security, and still relevant on latest <code>main</code>, the repair lane can open one ClawSweeper PR. Everything else stays a proposal.",
"Opted-in PRs can run through review, fix, re-review, and merge. Strict reproducible bug issues can open one guarded generated PR.",
"wrench",
],
];
@ -440,19 +448,24 @@ function landingBody() {
const lanes = [
{
name: "Issue / PR Sweep",
name: "Review Lane",
href: "scheduler.html",
desc: "Scheduled scan of every open issue and PR. Three planner paths: exact event, hot intake, full sweep.",
desc: "Scheduled and event-driven issue/PR reviews. Planner paths: exact event, hot intake, normal backfill.",
},
{
name: "Commit Sweep",
href: "commit-sweeper.html",
desc: "Reviews code-bearing commits on <code>main</code>. Skips non-code commits cheaply. Optional Check Runs.",
name: "Apply Lane",
href: "scheduler.html#apply-lane",
desc: "Guarded comment and close mutations. Re-fetches live GitHub state before every write.",
},
{
name: "Repair Lane",
href: "repair/",
desc: 'Bounded "review, fix, re-review, merge" loop for one opted-in PR. Pinned to a reviewed head SHA.',
desc: 'Bounded "review, fix, re-review, merge" loop for opted-in PRs and strict generated bug PRs.',
},
{
name: "Commit Review Lane",
href: "commit-sweeper.html",
desc: "Reviews code-bearing commits on <code>main</code>. Skips non-code commits cheaply. Optional Check Runs.",
},
];
const laneCards = lanes
@ -472,21 +485,22 @@ function landingBody() {
<ul class="snippet-list">
<li><strong>Read</strong> - GitHub snapshot, prior report, repository profile, paired issue/PR state.</li>
<li><strong>Write</strong> - one markdown report per item or commit, with a hashed snapshot.</li>
<li><strong>Propose</strong> - a single durable comment, edited in place. A close only when the rule fits.</li>
<li><strong>Act</strong> - one durable comment, guarded apply, and repair only through explicit trusted gates.</li>
</ul>
</div>
<pre class="snippet" aria-hidden="true"><code><span class="prompt">$</span> pnpm sweep --repo openclaw/openclaw
<span class="comment"># scan 142 open items - 4 hot, 138 idle</span>
<span class="comment"># report records/openclaw-openclaw/items/812.md</span>
<span class="comment"># comment edited (marker: clawsweeper:review)</span>
<span class="comment"># proposal: close 3 - implemented on main</span>
<span class="comment"># apply gate: ok - 3 closed - 0 errors</span>
<pre class="snippet" aria-hidden="true"><code><span class="prompt">$</span> pnpm run plan -- --target-repo openclaw/openclaw --shard-count 100
<span class="comment"># exact item numbers selected for review shards</span>
<span class="prompt">$</span> pnpm run review -- --target-repo openclaw/openclaw --artifact-dir artifacts/reviews
<span class="comment"># records/openclaw-openclaw/items/812.md</span>
<span class="comment"># durable comment marker: clawsweeper:review</span>
<span class="prompt">$</span> pnpm run apply-decisions -- --target-repo openclaw/openclaw --limit 20
<span class="comment"># guarded close/comment mutations only after live re-fetch</span>
<span class="prompt">$</span> pnpm commit-reports -- --since 24h --findings
<span class="comment"># 6 commits reviewed - 1 finding (non-security)</span>
<span class="comment"># dispatched to repair intake</span></code></pre>
</section>
<section class="lanes-row" aria-label="The lanes">
<h2>Three lanes, one engine</h2>
<h2>Four lanes, one engine</h2>
<div class="lanes">${laneCards}</div>
</section>
<section class="rules" aria-label="Guardrails">

View File

@ -5,6 +5,7 @@ import path from "node:path";
const root = process.cwd();
const activeRoots: string[] = [
".github/workflows",
"config",
"src",
"test",
"docs",
@ -50,6 +51,7 @@ const retiredPatterns: { label: string; pattern: RegExp }[] = [
{ label: "retired ClawSweeper read token", pattern: /\bCLAWSWEEPER_READ_GH_TOKEN\b/ },
{ label: "retired repair Codex token", pattern: /\bCLAWSWEEPER_CODEX_GH_TOKEN\b/ },
{ label: "retired review token", pattern: /\bCLAWSWEEPER_REVIEW_GH_TOKEN\b/ },
{ label: "unsupported gh run list workflow flag", pattern: /\bgh run list\b.*--workflow\b/ },
];
type Finding = {

175
scripts/check-limits.ts Normal file
View File

@ -0,0 +1,175 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
type WorkerConfig = {
workers: {
max: number;
reserve_for_interactive: number;
minimum_background: number;
};
};
type AutomationLimits = {
review_shards: {
normal_default: number;
normal_active_floor: number;
hot_intake_default: number;
exact_item_default: number;
hard_cap: number;
};
commit_review: {
page_size_default: number;
page_size_hard_cap: number;
};
repair_live_runs: {
default: number;
hard_cap: number;
automerge_default: number;
issue_implementation_default: number;
};
issue_implementation: {
dispatches_per_sweep_default: number;
};
};
const root = process.cwd();
const config = JSON.parse(
fs.readFileSync(path.join(root, "config", "automation-limits.json"), "utf8"),
) as WorkerConfig;
const limits = deriveAutomationLimits(config);
const expectations: { file: string; label: string; pattern: RegExp }[] = [
{
file: ".github/workflows/sweep.yml",
label: "manual workflow_dispatch shard_count default",
pattern: new RegExp(
`shard_count:[\\s\\S]{0,180}default: "${limits.review_shards.normal_default}"`,
),
},
{
file: "README.md",
label: "manual plan shard-count example",
pattern: new RegExp(`--shard-count ${limits.review_shards.normal_default}\\b`),
},
{
file: "docs/commit-dispatcher.md",
label: "commit review page size env example",
pattern: new RegExp(
`CLAWSWEEPER_COMMIT_REVIEW_PAGE_SIZE=${limits.commit_review.page_size_default}\\b`,
),
},
{
file: "docs/commit-sweeper.md",
label: "commit review page size default",
pattern: new RegExp(`defaults to ${limits.commit_review.page_size_default}\\b`),
},
{
file: "docs/repair/README.md",
label: "repair live run default",
pattern: new RegExp(`CLAWSWEEPER_MAX_LIVE_WORKERS=${limits.repair_live_runs.default}\\b`),
},
{
file: "docs/scheduler.md",
label: "normal review shard default",
pattern: new RegExp(`${limits.review_shards.normal_default} concurrent Codex\\s+review shards`),
},
{
file: "docs/scheduler.md",
label: "normal active shard floor",
pattern: new RegExp(`fewer than ${limits.review_shards.normal_active_floor} items are due`),
},
{
file: "docs/scheduler.md",
label: "hot intake shard default",
pattern: new RegExp(
`broad hot intake: up to ${limits.review_shards.hot_intake_default} shards`,
),
},
{
file: "docs/limits.md",
label: "limits documentation references source file",
pattern: /config\/automation-limits\.json/,
},
];
for (const [limitPath, value] of Object.entries(flattenLimits(limits))) {
expectations.push({
file: "docs/limits.md",
label: `${limitPath} documented current value`,
pattern: new RegExp(`\\| \`${escapeRegExp(limitPath)}\` \\| ${value} \\|`),
});
}
for (const [limitPath, value] of Object.entries(flattenLimits(config))) {
expectations.push({
file: "docs/limits.md",
label: `${limitPath} documented worker config value`,
pattern: new RegExp(`\\| \`${escapeRegExp(limitPath)}\` \\| ${value} \\|`),
});
}
const missing: string[] = [];
for (const expectation of expectations) {
const text = fs.readFileSync(path.join(root, expectation.file), "utf8");
if (!expectation.pattern.test(text)) {
missing.push(`${expectation.file}: ${expectation.label}`);
}
}
if (missing.length > 0) {
console.error("Automation limits drift check failed:");
for (const item of missing) console.error(`- ${item}`);
process.exit(1);
}
function flattenLimits(value: unknown, prefix = ""): Record<string, number> {
const out: Record<string, number> = {};
if (!isRecord(value)) return out;
for (const [key, child] of Object.entries(value)) {
const childPath = prefix ? `${prefix}.${key}` : key;
if (Number.isInteger(child)) {
out[childPath] = child;
} else {
Object.assign(out, flattenLimits(child, childPath));
}
}
return out;
}
function deriveAutomationLimits(workerConfig: WorkerConfig): AutomationLimits {
const max = workerConfig.workers.max;
return {
review_shards: {
normal_default: percent(max, 70),
normal_active_floor: percent(max, 30),
hot_intake_default: percent(max, 35),
exact_item_default: 1,
hard_cap: max,
},
commit_review: {
page_size_default: percent(max, 5),
page_size_hard_cap: max,
},
repair_live_runs: {
default: percent(max, 40),
hard_cap: max,
automerge_default: percent(max, 40),
issue_implementation_default: percent(max, 40),
},
issue_implementation: {
dispatches_per_sweep_default: percent(max, 4),
},
};
}
function percent(max: number, value: number): number {
return Math.max(1, Math.floor((max * value) / 100));
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

293
scripts/social-card.mjs Normal file
View File

@ -0,0 +1,293 @@
import zlib from "node:zlib";
const WIDTH = 1200;
const HEIGHT = 630;
const COLORS = {
ink: "#06181c",
paper: "#fdf6e9",
shell: "#f4ead7",
reef: "#0b3a3f",
tide: "#0a6a72",
kelp: "#13848e",
coral: "#ec5b3c",
crab: "#d9472b",
crabDark: "#7d2613",
sun: "#f4a93a",
sand: "#e9d7b1",
};
const FONT = {
A: ["01110", "10001", "10001", "11111", "10001", "10001", "10001"],
B: ["11110", "10001", "10001", "11110", "10001", "10001", "11110"],
C: ["01111", "10000", "10000", "10000", "10000", "10000", "01111"],
D: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"],
E: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"],
F: ["11111", "10000", "10000", "11110", "10000", "10000", "10000"],
G: ["01111", "10000", "10000", "10111", "10001", "10001", "01111"],
H: ["10001", "10001", "10001", "11111", "10001", "10001", "10001"],
I: ["11111", "00100", "00100", "00100", "00100", "00100", "11111"],
J: ["00111", "00010", "00010", "00010", "00010", "10010", "01100"],
K: ["10001", "10010", "10100", "11000", "10100", "10010", "10001"],
L: ["10000", "10000", "10000", "10000", "10000", "10000", "11111"],
M: ["10001", "11011", "10101", "10101", "10001", "10001", "10001"],
N: ["10001", "11001", "10101", "10011", "10001", "10001", "10001"],
O: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"],
P: ["11110", "10001", "10001", "11110", "10000", "10000", "10000"],
Q: ["01110", "10001", "10001", "10001", "10101", "10010", "01101"],
R: ["11110", "10001", "10001", "11110", "10100", "10010", "10001"],
S: ["01111", "10000", "10000", "01110", "00001", "00001", "11110"],
T: ["11111", "00100", "00100", "00100", "00100", "00100", "00100"],
U: ["10001", "10001", "10001", "10001", "10001", "10001", "01110"],
V: ["10001", "10001", "10001", "10001", "10001", "01010", "00100"],
W: ["10001", "10001", "10001", "10101", "10101", "10101", "01010"],
X: ["10001", "10001", "01010", "00100", "01010", "10001", "10001"],
Y: ["10001", "10001", "01010", "00100", "00100", "00100", "00100"],
Z: ["11111", "00001", "00010", "00100", "01000", "10000", "11111"],
0: ["01110", "10001", "10011", "10101", "11001", "10001", "01110"],
1: ["00100", "01100", "00100", "00100", "00100", "00100", "01110"],
2: ["01110", "10001", "00001", "00010", "00100", "01000", "11111"],
3: ["11110", "00001", "00001", "01110", "00001", "00001", "11110"],
4: ["10010", "10010", "10010", "11111", "00010", "00010", "00010"],
5: ["11111", "10000", "10000", "11110", "00001", "00001", "11110"],
6: ["01110", "10000", "10000", "11110", "10001", "10001", "01110"],
7: ["11111", "00001", "00010", "00100", "01000", "01000", "01000"],
8: ["01110", "10001", "10001", "01110", "10001", "10001", "01110"],
9: ["01110", "10001", "10001", "01111", "00001", "00001", "01110"],
".": ["00000", "00000", "00000", "00000", "00000", "01100", "01100"],
" ": ["00000", "00000", "00000", "00000", "00000", "00000", "00000"],
"-": ["00000", "00000", "00000", "11111", "00000", "00000", "00000"],
};
export function socialCardPng() {
const card = new Raster(WIDTH, HEIGHT);
card.background();
card.roundRect(68, 66, 680, 498, 30, rgba(COLORS.paper, 0.96));
card.roundRect(82, 80, 652, 470, 22, rgba("#f7ecd3", 0.94));
card.rect(82, 532, 652, 16, rgba(COLORS.coral, 1));
card.rect(82, 516, 424, 16, rgba(COLORS.sun, 1));
card.rect(506, 516, 228, 16, rgba(COLORS.kelp, 1));
card.text("OPENCLAW MAINTENANCE BOT", 112, 112, 4, COLORS.coral, 1);
card.text("CLAW", 110, 166, 22, COLORS.ink, 1);
card.text("SWEEPER", 112, 330, 14, COLORS.reef, 1);
card.text("REVIEW APPLY REPAIR COMMIT", 112, 478, 4, COLORS.tide, 0);
card.roundRect(804, 96, 310, 88, 24, rgba(COLORS.paper, 0.16));
card.text("FOUR", 838, 128, 6, COLORS.paper, 1);
card.text("LANES", 1002, 128, 6, COLORS.sun, 1);
card.roundRect(790, 220, 352, 260, 32, rgba(COLORS.paper, 0.1));
card.lobster(966, 332, 1.02);
card.roundRect(806, 506, 318, 52, 18, rgba(COLORS.ink, 0.36));
card.text("CLAW SWEEP", 846, 522, 5, COLORS.paper, 1);
card.text("CLAWSWEEPER.BOT", 812, 574, 4, COLORS.sand, 1);
return encodePng(card.width, card.height, card.data);
}
class Raster {
constructor(width, height) {
this.width = width;
this.height = height;
this.data = Buffer.alloc(width * height * 4);
}
background() {
const top = rgb(COLORS.reef);
const bottom = rgb(COLORS.ink);
for (let y = 0; y < this.height; y++) {
const t = y / (this.height - 1);
const row = mix(top, bottom, t);
for (let x = 0; x < this.width; x++) {
const wave = Math.sin((x + y * 0.8) / 34) * 4 + Math.sin((x - y * 1.4) / 91) * 6;
const grain = ((x * 17 + y * 31) % 23) - 11;
this.raw(x, y, row[0] + wave + grain, row[1] + wave * 0.8, row[2] + wave * 0.55, 255);
}
}
for (let y = 0; y < this.height; y += 38)
this.line(0, y, this.width, y + 110, rgba("#ffffff", 0.025), 2);
this.circle(1088, 86, 132, rgba(COLORS.coral, 0.13));
this.circle(1016, 594, 220, rgba(COLORS.tide, 0.2));
this.circle(760, -40, 180, rgba(COLORS.sun, 0.12));
}
lobster(cx, cy, scale) {
const s = scale;
const crab = rgba(COLORS.crab, 1);
const dark = rgba(COLORS.crabDark, 1);
const ink = rgba(COLORS.ink, 1);
this.line(cx - 76 * s, cy + 28 * s, cx - 154 * s, cy + 82 * s, dark, 12 * s);
this.line(cx + 76 * s, cy + 28 * s, cx + 154 * s, cy + 82 * s, dark, 12 * s);
this.circle(cx - 170 * s, cy + 88 * s, 34 * s, crab);
this.circle(cx + 170 * s, cy + 88 * s, 34 * s, crab);
this.circle(cx - 182 * s, cy + 76 * s, 20 * s, crab);
this.circle(cx + 182 * s, cy + 76 * s, 20 * s, crab);
this.circle(cx, cy + 38 * s, 88 * s, crab);
this.ellipse(cx, cy + 30 * s, 98 * s, 64 * s, crab);
this.line(cx - 54 * s, cy - 22 * s, cx - 54 * s, cy - 58 * s, ink, 4 * s);
this.line(cx + 54 * s, cy - 22 * s, cx + 54 * s, cy - 58 * s, ink, 4 * s);
this.circle(cx - 54 * s, cy - 66 * s, 12 * s, rgba(COLORS.paper, 1));
this.circle(cx + 54 * s, cy - 66 * s, 12 * s, rgba(COLORS.paper, 1));
this.circle(cx - 50 * s, cy - 66 * s, 4 * s, ink);
this.circle(cx + 58 * s, cy - 66 * s, 4 * s, ink);
this.line(cx - 48 * s, cy + 72 * s, cx + 48 * s, cy + 72 * s, rgba(COLORS.paper, 0.45), 4 * s);
this.line(cx - 24 * s, cy + 95 * s, cx + 24 * s, cy + 95 * s, ink, 4 * s);
for (const dir of [-1, 1]) {
this.line(cx + dir * 62 * s, cy + 85 * s, cx + dir * 120 * s, cy + 150 * s, ink, 6 * s);
this.line(cx + dir * 28 * s, cy + 96 * s, cx + dir * 46 * s, cy + 170 * s, ink, 6 * s);
}
this.line(cx + 168 * s, cy + 104 * s, cx + 216 * s, cy + 186 * s, rgba("#8a5a2c", 1), 7 * s);
this.roundRect(cx + 196 * s, cy + 180 * s, 42 * s, 48 * s, 8 * s, rgba(COLORS.sun, 1));
for (let i = 0; i < 5; i++) {
this.line(
cx + (202 + i * 8) * s,
cy + 188 * s,
cx + (194 + i * 12) * s,
cy + 226 * s,
rgba("#8a5a2c", 1),
2 * s,
);
}
}
text(text, x, y, scale, color, gap = 1) {
let cursor = x;
for (const char of text.toUpperCase()) {
const glyph = FONT[char] || FONT[" "];
for (let gy = 0; gy < glyph.length; gy++) {
for (let gx = 0; gx < glyph[gy].length; gx++) {
if (glyph[gy][gx] === "1")
this.rect(cursor + gx * scale, y + gy * scale, scale, scale, rgba(color, 1));
}
}
cursor += 5 * scale + gap * scale;
}
}
raw(x, y, r, g, b, a) {
if (x < 0 || y < 0 || x >= this.width || y >= this.height) return;
const i = (Math.floor(y) * this.width + Math.floor(x)) * 4;
this.data[i] = clamp(r);
this.data[i + 1] = clamp(g);
this.data[i + 2] = clamp(b);
this.data[i + 3] = clamp(a);
}
pixel(x, y, color) {
if (x < 0 || y < 0 || x >= this.width || y >= this.height) return;
const i = (Math.floor(y) * this.width + Math.floor(x)) * 4;
const a = color[3] / 255;
this.data[i] = clamp(color[0] * a + this.data[i] * (1 - a));
this.data[i + 1] = clamp(color[1] * a + this.data[i + 1] * (1 - a));
this.data[i + 2] = clamp(color[2] * a + this.data[i + 2] * (1 - a));
this.data[i + 3] = 255;
}
rect(x, y, w, h, color) {
const x0 = Math.max(0, Math.floor(x));
const y0 = Math.max(0, Math.floor(y));
const x1 = Math.min(this.width, Math.ceil(x + w));
const y1 = Math.min(this.height, Math.ceil(y + h));
for (let yy = y0; yy < y1; yy++) for (let xx = x0; xx < x1; xx++) this.pixel(xx, yy, color);
}
roundRect(x, y, w, h, r, color) {
this.rect(x + r, y, w - 2 * r, h, color);
this.rect(x, y + r, w, h - 2 * r, color);
this.circle(x + r, y + r, r, color);
this.circle(x + w - r, y + r, r, color);
this.circle(x + r, y + h - r, r, color);
this.circle(x + w - r, y + h - r, r, color);
}
circle(cx, cy, r, color) {
this.ellipse(cx, cy, r, r, color);
}
ellipse(cx, cy, rx, ry, color) {
const x0 = Math.floor(cx - rx);
const y0 = Math.floor(cy - ry);
const x1 = Math.ceil(cx + rx);
const y1 = Math.ceil(cy + ry);
for (let y = y0; y <= y1; y++) {
for (let x = x0; x <= x1; x++) {
const dx = (x - cx) / rx;
const dy = (y - cy) / ry;
if (dx * dx + dy * dy <= 1) this.pixel(x, y, color);
}
}
}
line(x0, y0, x1, y1, color, width = 1) {
const steps = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0));
for (let i = 0; i <= steps; i++) {
const t = steps === 0 ? 0 : i / steps;
this.circle(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t, width / 2, color);
}
}
}
function encodePng(width, height, rgbaData) {
const stride = width * 4 + 1;
const raw = Buffer.alloc(stride * height);
for (let y = 0; y < height; y++) {
raw[y * stride] = 0;
rgbaData.copy(raw, y * stride + 1, y * width * 4, (y + 1) * width * 4);
}
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
chunk("IHDR", Buffer.concat([u32(width), u32(height), Buffer.from([8, 6, 0, 0, 0])])),
chunk("IDAT", zlib.deflateSync(raw, { level: 9 })),
chunk("IEND", Buffer.alloc(0)),
]);
}
function chunk(type, data) {
const name = Buffer.from(type, "ascii");
return Buffer.concat([
u32(data.length),
name,
data,
u32(crc32(Buffer.concat([name, data])) >>> 0),
]);
}
function u32(value) {
const out = Buffer.alloc(4);
out.writeUInt32BE(value >>> 0);
return out;
}
function crc32(buffer) {
let crc = 0xffffffff;
for (const byte of buffer) {
crc ^= byte;
for (let i = 0; i < 8; i++) crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
}
return crc ^ 0xffffffff;
}
function rgb(hex) {
const value = hex.replace("#", "");
return [
parseInt(value.slice(0, 2), 16),
parseInt(value.slice(2, 4), 16),
parseInt(value.slice(4, 6), 16),
];
}
function rgba(hex, alpha) {
return [...rgb(hex), Math.round(alpha * 255)];
}
function mix(a, b, t) {
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
}
function clamp(value) {
return Math.max(0, Math.min(255, Math.round(value)));
}

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@ export function codexEnv(options: CodexEnvOptions = {}): NodeJS.ProcessEnv {
delete env.GH_TOKEN;
delete env.GITHUB_TOKEN;
delete env.COMMIT_SWEEPER_TARGET_GH_TOKEN;
delete env.CLAWSWEEPER_PROOF_INSPECTION_TOKEN;
delete env.CLAWSWEEPER_APP_ID;
delete env.CLAWSWEEPER_APP_PRIVATE_KEY;
delete env.OPENAI_API_KEY;

View File

@ -36,7 +36,7 @@ interface CommitMetadata {
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const DEFAULT_CODEX_MODEL = "gpt-5.5";
const DEFAULT_REASONING_EFFORT = "high";
const DEFAULT_SERVICE_TIER = "fast";
const DEFAULT_SERVICE_TIER = "";
const COMMIT_REVIEW_CHECK_NAME = "ClawSweeper Commit Review";
function run(command: string, commandArgs: string[], options: { cwd?: string } = {}): string {
@ -294,20 +294,19 @@ function runCodex(options: {
}),
"utf8",
);
const codexConfig = [
`model_reasoning_effort="${options.reasoningEffort}"`,
'forced_login_method="api"',
'approval_policy="never"',
];
if (options.serviceTier) codexConfig.splice(1, 0, `service_tier="${options.serviceTier}"`);
const result = spawnSync(
"codex",
[
"exec",
"-m",
options.model,
"-c",
`model_reasoning_effort="${options.reasoningEffort}"`,
"-c",
`service_tier="${options.serviceTier}"`,
"-c",
'forced_login_method="api"',
"-c",
'approval_policy="never"',
...codexConfig.flatMap((config) => ["-c", config]),
"-C",
options.targetDir,
"--output-last-message",

185
src/limits.ts Normal file
View File

@ -0,0 +1,185 @@
import { readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
export type WorkerConfig = {
workers: {
max: number;
reserve_for_interactive: number;
minimum_background: number;
};
};
export type AutomationLimits = {
review_shards: {
normal_default: number;
normal_active_floor: number;
hot_intake_default: number;
exact_item_default: number;
hard_cap: number;
};
commit_review: {
page_size_default: number;
page_size_hard_cap: number;
};
repair_live_runs: {
default: number;
hard_cap: number;
automerge_default: number;
issue_implementation_default: number;
};
issue_implementation: {
dispatches_per_sweep_default: number;
};
};
export type WorkerLane =
| "normal_review"
| "hot_intake"
| "commit_review"
| "repair"
| "automerge_repair"
| "issue_implementation"
| "exact_item";
export const WORKER_CONFIG = readWorkerConfig();
export const AUTOMATION_LIMITS = deriveAutomationLimits(WORKER_CONFIG);
export function readWorkerConfig(
filePath = join(repoRoot(), "config", "automation-limits.json"),
): WorkerConfig {
const parsed = JSON.parse(readFileSync(filePath, "utf8")) as unknown;
return validateWorkerConfig(parsed);
}
export function deriveAutomationLimits(config: WorkerConfig): AutomationLimits {
const max = config.workers.max;
return {
review_shards: {
normal_default: percent(max, 70),
normal_active_floor: percent(max, 30),
hot_intake_default: percent(max, 35),
exact_item_default: 1,
hard_cap: max,
},
commit_review: {
page_size_default: percent(max, 5),
page_size_hard_cap: max,
},
repair_live_runs: {
default: percent(max, 40),
hard_cap: max,
automerge_default: percent(max, 40),
issue_implementation_default: percent(max, 40),
},
issue_implementation: {
dispatches_per_sweep_default: percent(max, 4),
},
};
}
export function workerLimit(
lane: WorkerLane,
{
activeCritical = 0,
activeBackground = 0,
config = WORKER_CONFIG,
limits = AUTOMATION_LIMITS,
}: {
activeCritical?: number;
activeBackground?: number;
config?: WorkerConfig;
limits?: AutomationLimits;
} = {},
): number {
if (lane === "exact_item") return limits.review_shards.exact_item_default;
if (lane === "repair") return priorityLimit(limits.repair_live_runs.default, activeCritical);
if (lane === "automerge_repair")
return priorityLimit(limits.repair_live_runs.automerge_default, activeCritical);
if (lane === "issue_implementation")
return priorityLimit(limits.repair_live_runs.issue_implementation_default, activeCritical);
if (lane === "commit_review")
return backgroundLimit(
limits.commit_review.page_size_default,
activeCritical,
activeBackground,
);
if (lane === "hot_intake")
return backgroundLimit(
limits.review_shards.hot_intake_default,
activeCritical,
activeBackground,
);
return backgroundLimit(limits.review_shards.normal_default, activeCritical, activeBackground);
function priorityLimit(laneMax: number, active: number): number {
const available = Math.max(1, config.workers.max - nonNegative(active));
return Math.max(1, Math.min(laneMax, available));
}
function backgroundLimit(laneMax: number, active: number, background: number): number {
const rawAvailable =
config.workers.max -
config.workers.reserve_for_interactive -
nonNegative(active) -
nonNegative(background);
if (rawAvailable <= 0) return 1;
const withFloor =
rawAvailable >= config.workers.minimum_background ? rawAvailable : Math.max(1, rawAvailable);
return Math.max(1, Math.min(laneMax, withFloor));
}
}
function validateWorkerConfig(value: unknown): WorkerConfig {
if (!isRecord(value)) throw new Error("automation limits must be an object");
return {
workers: {
max: positiveInteger(value, "workers.max"),
reserve_for_interactive: nonNegativeInteger(value, "workers.reserve_for_interactive"),
minimum_background: positiveInteger(value, "workers.minimum_background"),
},
};
}
function percent(max: number, value: number): number {
return Math.max(1, Math.floor((max * value) / 100));
}
function positiveInteger(root: Record<string, unknown>, path: string): number {
const value = getPath(root, path);
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
throw new Error(`automation limit ${path} must be a positive integer`);
}
return value;
}
function nonNegativeInteger(root: Record<string, unknown>, path: string): number {
const value = getPath(root, path);
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
throw new Error(`automation limit ${path} must be a non-negative integer`);
}
return value;
}
function nonNegative(value: number): number {
return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
}
function getPath(root: Record<string, unknown>, path: string): unknown {
let cursor: unknown = root;
for (const segment of path.split(".")) {
if (!isRecord(cursor) || !(segment in cursor)) {
throw new Error(`automation limit ${path} is missing`);
}
cursor = cursor[segment];
}
return cursor;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function repoRoot(): string {
return resolve(dirname(fileURLToPath(import.meta.url)), "..");
}

View File

@ -1,4 +1,13 @@
export function isRetryableCodexTransportError(value: unknown): boolean {
const message = String(value ?? "");
return /write_stdin failed: stdin is closed|stdin is closed for this session/i.test(message);
return /write_stdin failed: stdin is closed|stdin is closed for this session|rate limit reached|tokens per min|\bTPM\b|requests per min|\b429\b|temporarily unavailable|overloaded|please try again in \d+(?:ms|s)/i.test(
message,
);
}
export function isCodexContextLimitError(value: unknown): boolean {
const message = String(value ?? "");
return /Requested \d+\. Please try again with a smaller input|context (?:length|window)|maximum context|too many tokens|token limit|input is too large/i.test(
message,
);
}

View File

@ -104,6 +104,29 @@ export function issueImplementationJobPath(repo: string, issueNumber: JsonValue)
return `jobs/${owner}/inbox/${issueImplementationClusterId(repo, issueNumber)}.md`;
}
export function createCachedLabelNumberLookup(fetchNumbers: (label: string) => JsonValue[]) {
const cache = new Map<string, number[]>();
return (label: string) => {
const key = String(label ?? "");
const cached = cache.get(key);
if (cached) return [...cached];
const numbers = uniquePositiveIntegers(fetchNumbers(key));
cache.set(key, numbers);
return [...numbers];
};
}
function uniquePositiveIntegers(values: JsonValue): number[] {
if (!Array.isArray(values)) return [];
return [
...new Set(
values
.map((value: JsonValue) => Number(value))
.filter((number: number) => Number.isInteger(number) && number > 0),
),
];
}
export function renderAutomergeJob({
repo,
issueNumber,
@ -446,6 +469,14 @@ export function existingModeStatusBlocksReplay({
);
}
export function pausedModeStatusBlocksReplay({
hasPauseLabels,
hasExistingModeStatusResponse,
forceReprocess,
}: LooseRecord = {}) {
return Boolean(hasPauseLabels) && Boolean(hasExistingModeStatusResponse) && !forceReprocess;
}
export function isMaintainerCommandAllowed({
authorAssociation,
repositoryPermission = null,
@ -656,10 +687,18 @@ export function parseTrustedAutomation(
const body = String(comment?.body ?? "");
const verdict = clawsweeperMarker(body, "verdict");
const actionMarker = clawsweeperMarker(body, "action");
const securityMarker = clawsweeperMarker(body, "security");
if (verdict?.action === "human-review") {
return trustedHumanReview({
author,
reason: `structured ClawSweeper verdict: ${verdict.action}${markerReasonSuffix(verdict.attrs)}`,
reason: trustedHumanReviewReason(body, verdict),
marker: verdict,
});
}
if (verdict?.action === "needs-human" && securityMarker?.action === "security-sensitive") {
return trustedHumanReview({
author,
reason: trustedHumanReviewReason(body, verdict),
marker: verdict,
});
}
@ -673,7 +712,7 @@ export function parseTrustedAutomation(
if (verdict?.action === "needs-human") {
return trustedHumanReview({
author,
reason: `structured ClawSweeper verdict: ${verdict.action}${markerReasonSuffix(verdict.attrs)}`,
reason: trustedHumanReviewReason(body, verdict),
marker: verdict,
});
}
@ -724,6 +763,48 @@ function trustedCommentHasPriorityFinding(body: string) {
return /(?:^|\n)\s*(?:[-*]\s*)?(?:\*\*)?\[P[0-3]\]/i.test(String(body ?? ""));
}
function trustedHumanReviewReason(body: string, verdict: LooseRecord | null) {
const details = [
markdownSection(body, "Next step before merge"),
markdownSection(body, "Security"),
firstReviewFinding(body),
].filter(Boolean);
const suffix = markerReasonSuffix(verdict?.attrs);
const fallback = verdict?.action
? `structured ClawSweeper verdict: ${verdict.action}${suffix}`
: "ClawSweeper requested human review";
if (details.length === 0) return fallback;
return `${compactReason(details.join("; "), 420)}${suffix}`;
}
function markdownSection(body: string, heading: string) {
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = String(body ?? "").match(
new RegExp(
`(?:^|\\n)\\*\\*${escaped}\\*\\*\\s*\\n([\\s\\S]*?)(?=\\n\\n\\*\\*|\\n<details|\\n<!--|$)`,
"i",
),
);
return compactReason(match?.[1] ?? "", 220);
}
function firstReviewFinding(body: string) {
const section = markdownSection(body, "Review findings");
const finding = section.match(/(?:^|;\s*|\n)\s*[-*]\s*(.+?)(?:$|;\s*|\n)/)?.[1] ?? "";
return finding ? compactReason(`Review finding: ${finding}`, 180) : "";
}
function compactReason(value: JsonValue, max = 300) {
const text = String(value ?? "")
.replace(/`/g, "")
.replace(/\s+/g, " ")
.replace(/\bNeeds attention:\s+Needs attention:\s+/i, "Needs attention: ")
.trim();
if (!text) return "";
if (text.length <= max) return text;
return `${text.slice(0, Math.max(0, max - 1)).trimEnd()}...`;
}
export function renderResponse(command: LooseRecord, dispatched: LooseRecord) {
const markerId = command.comment_version_key ?? command.comment_id;
const marker = [
@ -800,7 +881,7 @@ export function renderResponse(command: LooseRecord, dispatched: LooseRecord) {
`- Label: \`${label}\`${clearedHumanReview ? " (pause labels cleared)" : ""}`,
repairQueued
? repairDispatchLine(dispatched.repair, "- Action")
: "- Action: exact-head review queued.",
: reviewDispatchLine(dispatched.clawsweeper, "- Action", "exact-head review queued"),
"- Flow: review this head, repair/rebase only if needed, then re-review the exact repaired head before merge.",
].join("\n")
: `Reason: ${command.reason ?? `${mode} requires a pull request`}.`,
@ -818,7 +899,11 @@ export function renderResponse(command: LooseRecord, dispatched: LooseRecord) {
: "ClawSweeper could not start a re-review for this item.",
"",
dispatched?.clawsweeper
? "I asked ClawSweeper to review this item again."
? [
"I asked ClawSweeper to review this item again.",
reviewDispatchLine(dispatched.clawsweeper, "Action", "item re-review queued"),
"Result: the existing ClawSweeper review comment will be edited in place when the review finishes.",
].join("\n")
: `Reason: ${command.reason ?? "re-review requires an open issue or PR"}.`,
].join("\n");
}
@ -1013,6 +1098,17 @@ function repairDispatchLine(dispatched: LooseRecord, label: string): string {
: `${label}: repair worker queued.`;
}
function reviewDispatchLine(dispatched: LooseRecord, label: string, action: string): string {
const runUrl = typeof dispatched.run_url === "string" ? dispatched.run_url : "";
const workflow = String(dispatched.workflow ?? "").trim();
const event = String(dispatched.event ?? "").trim();
const suffix = [workflow ? `workflow \`${workflow}\`` : "", event ? `event \`${event}\`` : ""]
.filter(Boolean)
.join(", ");
const detail = suffix ? ` (${suffix})` : "";
return runUrl ? `${label}: ${action}. Run: ${runUrl}` : `${label}: ${action}${detail}.`;
}
export function usesSharedAutomergeStatus(command: LooseRecord) {
return AUTOMERGE_STATUS_INTENTS.has(String(command.intent ?? ""));
}

View File

@ -33,6 +33,7 @@ import {
automergeTransientWaitConfig,
buildAutomergeMergeArgs,
commandHasAction,
createCachedLabelNumberLookup,
hasCommandResponseMarker,
commandStatusMarker,
commandStatusMarkerPrefix,
@ -42,6 +43,7 @@ import {
issueImplementationClusterId,
issueImplementationJobPath,
parseCommand,
pausedModeStatusBlocksReplay,
parseTrustedAutomation,
repairableCheckBlockers,
repairLoopStopPauseReason,
@ -131,6 +133,11 @@ const collaboratorPermissionCache = new Map();
const activeRepairRunsByPrefix = new Map<string, LooseRecord[]>();
const liveTargetCache = new Map<number, LooseRecord>();
const issueCommentsCache = new Map<number, JsonValue[]>();
const openIssueNumbersByLabel = createCachedLabelNumberLookup((label) =>
ghPaged<JsonValue>(
`repos/${targetRepo}/issues?state=open&labels=${encodeURIComponent(label)}&per_page=100`,
).map((issue: JsonValue) => issue.number),
);
const comments = measure("list_candidate_comments", () => listCandidateComments());
const rawCommands: LooseRecord[] = [];
@ -501,6 +508,22 @@ function classifyCommand(command: LooseRecord): JsonValue {
) {
return { ...next, status: "skipped", reason: `${mode} already enabled for this PR` };
}
if (
pausedModeStatusBlocksReplay({
hasPauseLabels: pauseLabels.length > 0,
hasExistingModeStatusResponse: hasExistingModeStatusResponse(
command.issue_number,
command.intent,
),
forceReprocess,
})
) {
return {
...next,
status: "skipped",
reason: "PR is paused for human review",
};
}
const actions: LooseRecord[] = [];
if (!target.job_path) {
actions.push({
@ -734,11 +757,7 @@ function classifyAutomergePass(
if (pauseLabels.length > 0) {
return { ...command, status: "skipped", reason: "PR is paused for human review" };
}
const pauseLabelActions = pauseLabelsOn(command.target).map((label) => ({
action: "remove_label",
label,
status: execute ? "pending" : "planned",
}));
const pauseLabelActions: LooseRecord[] = [];
const failedCheckBlockers = repairableCheckBlockers(command.target?.checks);
if (failedCheckBlockers.length > 0) {
return classifyPassedAutomergeRepair(
@ -1490,6 +1509,8 @@ function repairJobModeForCommand(command: LooseRecord) {
}
function dispatchClawSweeperReview(command: LooseRecord) {
const commandStatus =
command.intent === "re_review" ? { command_status_marker: commandStatusMarker(command) } : {};
const payload = JSON.stringify({
event_type: "clawsweeper_item",
client_payload: {
@ -1497,6 +1518,7 @@ function dispatchClawSweeperReview(command: LooseRecord) {
item_number: String(command.issue_number),
item_kind: command.target?.kind ?? "",
additional_prompt: freeformReviewPrompt(command),
...commandStatus,
},
});
const result = ghSpawn(
@ -2376,11 +2398,7 @@ function isGitHubNotFoundError(error: unknown) {
}
function listOpenIssueNumbersWithLabel(label: string) {
return ghPaged<JsonValue>(
`repos/${targetRepo}/issues?state=open&labels=${encodeURIComponent(label)}&per_page=100`,
)
.map((issue: JsonValue) => Number(issue.number))
.filter((number) => Number.isInteger(number) && number > 0);
return openIssueNumbersByLabel(label);
}
function isClawSweeperReviewMarkerComment(comment: JsonValue) {

View File

@ -2,6 +2,7 @@ import type { JsonValue, LooseRecord } from "./json-types.js";
import { DEFAULT_ALLOWED_REPOSITORY_PERMISSIONS } from "./comment-router-core.js";
import { currentProjectRepo, readMaxLiveWorkers } from "./lib.js";
import { assertRepo, commaSet, positiveInteger } from "./comment-router-utils.js";
import { AUTOMATION_LIMITS } from "./limits.js";
import {
DEFAULT_HEAD_PREFIX,
DEFAULT_TARGET_REPO,
@ -110,7 +111,7 @@ export function readCommentRouterConfig(args: LooseRecord): CommentRouterConfig
"max-live-workers":
args["automerge-max-live-workers"] ??
process.env.CLAWSWEEPER_AUTOMERGE_MAX_LIVE_WORKERS ??
50,
AUTOMATION_LIMITS.repair_live_runs.automerge_default,
}),
automergeRunNamePrefix: stringSetting(
args["automerge-run-name-prefix"] ?? process.env.CLAWSWEEPER_AUTOMERGE_RUN_NAME_PREFIX,
@ -125,7 +126,7 @@ export function readCommentRouterConfig(args: LooseRecord): CommentRouterConfig
"max-autoclose-targets",
),
maxAutoRepairsPerHead: positiveInteger(
args["max-auto-repairs-per-head"] ?? process.env.CLAWSWEEPER_MAX_REPAIRS_PER_HEAD ?? 1,
args["max-auto-repairs-per-head"] ?? process.env.CLAWSWEEPER_MAX_REPAIRS_PER_HEAD ?? 2,
"max-auto-repairs-per-head",
),
maxAutoRepairsPerPr: positiveInteger(

View File

@ -7,7 +7,6 @@ import {
activeRepairWorkflowRunForJob,
assertLiveWorkerCapacity,
currentProjectRepo,
liveWorkerCapacity,
parseArgs,
parseJob,
readMaxLiveWorkers,
@ -17,6 +16,7 @@ import {
} from "./lib.js";
import { sleepMs } from "./timing.js";
import { REPAIR_CLUSTER_WORKFLOW } from "./constants.js";
import { AUTOMATION_LIMITS } from "./limits.js";
const args = parseArgs(process.argv.slice(2));
const defaultRunner = process.env.CLAWSWEEPER_WORKER_RUNNER ?? "blacksmith-4vcpu-ubuntu-2404";
@ -36,7 +36,7 @@ const activeRepairRunsByPrefix = new Map<string, LooseRecord[]>();
if (files.length === 0) {
console.error(
"usage: node scripts/dispatch-jobs.ts <job.md> [...] [--mode plan|execute|autonomous] [--runner label] [--execution-runner label] [--model model] [--max-live-workers 50] [--wait-for-capacity]",
`usage: node scripts/dispatch-jobs.ts <job.md> [...] [--mode plan|execute|autonomous] [--runner label] [--execution-runner label] [--model model] [--max-live-workers ${AUTOMATION_LIMITS.repair_live_runs.default}] [--wait-for-capacity]`,
);
process.exit(2);
}
@ -86,10 +86,9 @@ while (!failed && index < jobs.length) {
requested: 1,
maxLiveWorkers,
});
const refreshed = liveWorkerCapacity({ repo, workflow, requested: 1, maxLiveWorkers });
batchSize = Math.min(batchSize, Math.max(1, refreshed.available || capacity.available || 1));
batchSize = Math.min(batchSize, Math.max(1, capacity.available || 1));
console.log(
`live worker capacity: ${refreshed.active}/${refreshed.max_live_workers} active; dispatching next ${batchSize} run(s)`,
`live worker capacity: ${capacity.active}/${capacity.max_live_workers} active; dispatching next ${batchSize} run(s)`,
);
}

View File

@ -20,7 +20,7 @@ import {
replacementSourceLinkComment,
} from "./external-messages.js";
import { runCommand as run } from "./command-runner.js";
import { isRetryableCodexTransportError } from "./codex-transient.js";
import { isCodexContextLimitError, isRetryableCodexTransportError } from "./codex-transient.js";
import {
branchHasBaseDiff,
completeRebaseIfResolved,
@ -57,7 +57,11 @@ import {
COMMIT_FINDING_LABEL_COLOR,
COMMIT_FINDING_LABEL_DESCRIPTION,
} from "./constants.js";
import { buildFixPrompt, buildRepositoryContext } from "./fix-prompt-builder.js";
import {
buildFixPrompt,
buildRepositoryContext,
renderFixArtifactForPrompt,
} from "./fix-prompt-builder.js";
import { canTreatRebaseAsCompleteRepair } from "./fix-edit-policy.js";
import { applyMechanicalChangelogFix } from "./mechanical-changelog.js";
import { tryResolveMechanicalRebaseConflicts } from "./mechanical-rebase-conflicts.js";
@ -74,7 +78,12 @@ import {
} from "./source-pr-checkout.js";
import { mergeAutomergeTimelineSection } from "./automerge-status-timeline.js";
import { sleepMs } from "./timing.js";
import { isRepairBranchPushRace, repairBranchPushRaceReason } from "./repair-branch-push-errors.js";
import {
isRepairBranchPushBlocked,
isRepairBranchPushRace,
repairBranchPushBlockedReason,
repairBranchPushRaceReason,
} from "./repair-branch-push-errors.js";
import {
prepareTargetToolchain,
preflightTargetValidationPlan,
@ -264,6 +273,13 @@ function currentNetworkCommandTimeoutMs() {
return boundedTimeout(networkCommandTimeoutMs, remainingFixStepBudgetMs());
}
function currentCheckoutCloneTimeoutMs() {
return boundedTimeout(
Math.max(1_000, Number(process.env.CLAWSWEEPER_CHECKOUT_CLONE_TIMEOUT_MS ?? 120_000)),
remainingFixStepBudgetMs(),
);
}
function runGitNetwork(args: string[], cwd: string = targetDir) {
return run("git", args, {
cwd,
@ -528,13 +544,15 @@ try {
outcome = executeRepairBranch({ fixArtifact, targetDir });
} catch (error) {
const branchPushRaceReason = repairBranchPushRaceReason(error);
if (branchPushRaceReason) {
const branchPushBlockedReason = repairBranchPushBlockedReason(error);
const blockedReason = branchPushRaceReason ?? branchPushBlockedReason;
if (blockedReason) {
outcome = {
action: "repair_contributor_branch",
status: "blocked",
repair_strategy: fixArtifact.repair_strategy,
reason: branchPushRaceReason,
requeue_required: true,
reason: blockedReason,
requeue_required: Boolean(branchPushRaceReason),
};
} else {
report.actions.push({
@ -573,7 +591,15 @@ try {
"Codex produced no target repo changes; treating this allow_no_pr artifact as an audited no-PR outcome",
};
} else {
if (!isBlockedFixError(error)) throw error;
if (!isBlockedFixError(error)) {
updateAutomergeProgressStatus({
id: "repair-failed",
label: "repair failed",
status: "failed",
details: compactText(String(error?.message ?? error), 240),
});
throw error;
}
outcome = {
action: "execute_fix",
status: "blocked",
@ -587,16 +613,27 @@ report.status = outcome.status;
if (outcome.reason && !report.reason) report.reason = outcome.reason;
report.actions.push(outcome);
writeReport(report, resultPath);
updateAutomergeProgressStatus({
id: "repair-finished",
label: "repair finished",
status: outcome.status,
details: compactText(String(outcome.reason ?? outcome.action ?? "done"), 240),
headSha: outcome.commit ?? null,
});
function isBlockedFixError(error: JsonValue) {
if (isRepairBranchPushRace(error)) return true;
return /Codex produced no target repo changes|Codex \/review did not pass|Codex (?:fix worker|review-fix worker|\/review) timed out|Codex (?:fix worker|review-fix worker|\/review) failed|validation command failed|rebase (?:conflicts remain unresolved|produced additional conflicts)/i.test(
if (isRepairBranchPushBlocked(error)) return true;
if (isRetryableCodexTransportError(String(error?.message ?? error))) return true;
if (isCodexContextLimitError(String(error?.message ?? error))) return true;
return /Codex produced no target repo changes|Codex \/review did not pass|Codex (?:fix worker|review-fix worker|\/review) timed out|Codex (?:fix worker|review-fix worker|\/review) failed|validation command failed|command timed out after \d+ms: git (?:fetch|push)|rebase (?:conflicts remain unresolved|produced additional conflicts)/i.test(
String(error?.message ?? error),
);
}
function shouldFallbackToReplacementAfterRepairError(error: JsonValue) {
if (isRepairBranchPushRace(error)) return false;
if (isRepairBranchPushBlocked(error)) return false;
const message = String(error?.message ?? error);
if (/validation command failed|Codex |no merge base/i.test(message)) return false;
return /maintainer_can_modify=false|missing head repo\/ref|source PR #\d+ is (?:closed|merged)|permission denied|permission to [^\s]+ denied|remote rejected|could not push|repository not found|not found/i.test(
@ -688,15 +725,18 @@ function executeRepairBranch({ fixArtifact, targetDir }: LooseRecord) {
);
logProgress("fetching latest base for contributor repair", { base_branch: baseBranch });
runGitNetwork(["fetch", "origin", `${baseBranch}:refs/remotes/origin/${baseBranch}`], targetDir);
logProgress("fetching contributor branch", {
logProgress("fetching contributor PR head", {
source_pr: sourcePr.url,
head_repo: pull.head.repo.full_name,
head_ref: pull.head.ref,
});
runGitNetwork(
["fetch", `https://github.com/${pull.head.repo.full_name}.git`, `${pull.head.ref}:${branch}`],
checkoutSourcePullRequestHead({
targetDir,
);
run("git", ["checkout", branch], { cwd: targetDir });
repo: result.repo,
branch,
sourcePr,
pull,
});
ensureMergeBaseAvailable({ targetDir, baseBranch });
const sourceHead = currentHead(targetDir);
logProgress("preparing target toolchain", { source_head: sourceHead });
@ -740,6 +780,7 @@ function executeRepairBranch({ fixArtifact, targetDir }: LooseRecord) {
});
if (fastRepair.status === "ready") {
return pushRepairBranchAndUpdateStatus({
fixArtifact,
sourcePr,
pull,
sameRepoBranch,
@ -768,6 +809,7 @@ function executeRepairBranch({ fixArtifact, targetDir }: LooseRecord) {
});
(prep.merge_preflight as JsonValue).target = `#${sourcePr.number}`;
return pushRepairBranchAndUpdateStatus({
fixArtifact,
sourcePr,
pull,
sameRepoBranch,
@ -779,6 +821,7 @@ function executeRepairBranch({ fixArtifact, targetDir }: LooseRecord) {
}
function pushRepairBranchAndUpdateStatus({
fixArtifact,
sourcePr,
pull,
sameRepoBranch,
@ -813,7 +856,35 @@ function pushRepairBranchAndUpdateStatus({
});
if (livePauseBlock) return livePauseBlock;
const pushArgs = repairBranchPushArgs({ pull, rewritten: branchUpdate.rewritten });
runGitNetwork(pushArgs, targetDir);
try {
runGitNetwork(pushArgs, targetDir);
} catch (error) {
const blockedReason = repairBranchPushBlockedReason(error);
if (blockedReason && !sameRepoBranch) {
logProgress("repair branch push blocked; publishing prepared repair as replacement PR", {
source_pr: sourcePr.url,
head_repo: pull.head.repo.full_name,
head_ref: pull.head.ref,
reason: blockedReason,
});
report.actions.push({
action: "repair_contributor_branch",
status: "blocked",
target: sourcePr.url,
repair_strategy: fixArtifact.repair_strategy,
reason: blockedReason,
fallback: "open_fix_pr",
});
return openReplacementPrFromPreparedRepairCheckout({
fixArtifact,
sourcePr,
targetDir,
prep,
fallbackReason: blockedReason,
});
}
throw error;
}
const threadResolution = prepareReviewThreadsForMerge({
repo: result.repo,
number: sourcePr.number,
@ -866,6 +937,158 @@ function pushRepairBranchAndUpdateStatus({
};
}
function openReplacementPrFromPreparedRepairCheckout({
fixArtifact,
sourcePr,
targetDir,
prep,
fallbackReason,
}: LooseRecord) {
const baseBranch = String(process.env.CLAWSWEEPER_FIX_BASE_BRANCH ?? DEFAULT_BASE_BRANCH);
const contributorCredits = sourceContributorCredits({
fixArtifact,
targetDir,
repo: result.repo,
});
const branch = replacementBranchName(result.cluster_id);
const areaCapacityBlock = validateActivePrAreaCapacity({
fixArtifact,
targetDir,
branch,
repo: result.repo,
maxActivePrsPerArea,
});
if (areaCapacityBlock) {
return {
action: "open_fix_pr",
status: "blocked",
branch,
repair_strategy: "replace_uneditable_branch",
fallback_from: "repair_contributor_branch",
fallback_source_pr: sourcePr.url,
fallback_reason: fallbackReason,
...areaCapacityBlock,
};
}
ghAuthSetupGit(targetDir);
run("git", ["checkout", "-B", branch], { cwd: targetDir });
if (!branchHasBaseDiff({ targetDir, baseBranch })) {
logProgress("prepared replacement branch has no changes versus base; skipping PR create", {
branch,
base_branch: baseBranch,
commit: prep.commit,
});
return {
action: "open_fix_pr",
status: "skipped",
branch,
repair_strategy: "replace_uneditable_branch",
fallback_from: "repair_contributor_branch",
fallback_source_pr: sourcePr.url,
fallback_reason: fallbackReason,
commit: prep.commit,
checkpoint_commits: prep.checkpoint_commits,
merge_preflight: prep.merge_preflight,
supersede_sources: [],
contributor_credit: contributorCredits.map(publicContributorCredit),
reason: "prepared replacement branch has no changes versus base after repair",
};
}
pushRecoverableBranch({ targetDir, branch });
const provenance = externalMessageProvenance({
model,
reasoning: codexReasoningEffort,
reviewedSha: prep.commit,
});
const body = replacementPrBody({
fixArtifact,
fallbackReason,
clusterId: result.cluster_id,
provenance,
});
const bodyPath = path.join(workRoot, "replacement-pr-body.md");
fs.writeFileSync(bodyPath, body);
const prUrl =
findOpenPullRequestForBranch(branch, targetDir) ||
run(
"gh",
[
"pr",
"create",
"--repo",
result.repo,
"--base",
baseBranch,
"--head",
branch,
"--title",
fixArtifact.pr_title,
"--body-file",
bodyPath,
],
{ cwd: targetDir, env: ghEnv(), timeoutMs: currentNetworkCommandTimeoutMs() },
).trim();
const prNumber = pullRequestNumberFromUrl(prUrl);
if (prNumber) ensurePullRequestOpen({ number: prNumber, targetDir });
if (prNumber) labelReplacementPullRequest({ number: prNumber, targetDir, fixArtifact });
if (prNumber) (prep.merge_preflight as JsonValue).target = `#${prNumber}`;
const threadResolution = prNumber
? prepareReviewThreadsForMerge({
repo: result.repo,
number: prNumber,
targetDir,
resolveThreads: resolveReviewThreads,
})
: { status: "blocked", reason: "replacement PR URL did not include a PR number" };
const supersededSources = supersededReplacementSources({ fixArtifact, repo: result.repo }).filter(
(source: JsonValue) => pullRequestNumberFromUrl(source) !== prNumber,
);
const supersededSourceActions: JsonValue[] = [];
for (const source of supersededSources) {
const parsed = parsePullRequestUrl(source);
if (!parsed || parsed.repo !== result.repo) continue;
supersededSourceActions.push(
closeSupersededSourcePrs
? closeSupersededSourcePr({
source,
parsed,
replacementPrUrl: prUrl,
targetDir,
contributorCredits,
provenance,
})
: linkReplacementSourcePr({
source,
parsed,
replacementPrUrl: prUrl,
targetDir,
provenance,
}),
);
}
return {
action: "open_fix_pr",
status: "opened",
pr_url: prUrl,
branch,
repair_strategy: "replace_uneditable_branch",
fallback_from: "repair_contributor_branch",
fallback_source_pr: sourcePr.url,
fallback_reason: fallbackReason,
commit: prep.commit,
checkpoint_commits: prep.checkpoint_commits,
merge_preflight: prep.merge_preflight,
review_threads: threadResolution,
superseded_sources: supersededSources,
superseded_source_actions: supersededSourceActions,
contributor_credit: contributorCredits.map(publicContributorCredit),
};
}
function liveRepairPauseBlock({
pull,
number,
@ -1557,13 +1780,18 @@ function editValidatePrepareMerge({
throw new Error(`Codex fix worker timed out after ${workerTimeoutMs}ms`);
}
if (codexResult.error) {
const errorMessage = codexResult.error.message || String(codexResult.error);
if (attempt < maxEditAttempts && isRetryableCodexTransportError(errorMessage)) {
previousSummary = compactText(errorMessage, 360);
const errorDetail = codexFailureDetail(
codexResult,
codexResult.error.message || String(codexResult.error),
);
if (attempt < maxEditAttempts && isRetryableCodexTransportError(errorDetail)) {
previousSummary = compactText(errorDetail, 360);
const retryDelayMs = codexRetryDelayMs(errorDetail, attempt);
logProgress("retrying Codex edit pass after transient transport error", {
mode,
attempt,
max_attempts: maxEditAttempts,
retry_delay_ms: retryDelayMs,
});
updateAutomergeProgressStatus({
id: `codex-edit-${mode}-${attempt}`,
@ -1572,18 +1800,21 @@ function editValidatePrepareMerge({
details: "transient Codex transport error",
headSha: currentHead(targetDir),
});
sleepMs(retryDelayMs);
continue;
}
throw new Error(errorMessage);
throw new Error(codexFailureMessage("Codex fix worker failed", errorDetail));
}
if (codexResult.status !== 0) {
const errorMessage = codexResult.stderr || codexResult.stdout || "Codex fix worker failed";
if (attempt < maxEditAttempts && isRetryableCodexTransportError(errorMessage)) {
previousSummary = compactText(errorMessage, 360);
const errorDetail = codexFailureDetail(codexResult, "Codex fix worker failed");
if (attempt < maxEditAttempts && isRetryableCodexTransportError(errorDetail)) {
previousSummary = compactText(errorDetail, 360);
const retryDelayMs = codexRetryDelayMs(errorDetail, attempt);
logProgress("retrying Codex edit pass after transient transport error", {
mode,
attempt,
max_attempts: maxEditAttempts,
retry_delay_ms: retryDelayMs,
});
updateAutomergeProgressStatus({
id: `codex-edit-${mode}-${attempt}`,
@ -1592,9 +1823,10 @@ function editValidatePrepareMerge({
details: "transient Codex transport error",
headSha: currentHead(targetDir),
});
sleepMs(retryDelayMs);
continue;
}
throw new Error(errorMessage);
throw new Error(codexFailureMessage("Codex fix worker failed", errorDetail));
}
logProgress("Codex edit pass finished", { mode, attempt, status: codexResult.status });
updateAutomergeProgressStatus({
@ -1645,7 +1877,7 @@ function editValidatePrepareMerge({
let codexReview = null;
const maxFinalBaseSyncAttempts = Math.max(
1,
Number(process.env.CLAWSWEEPER_FINAL_BASE_SYNC_ATTEMPTS ?? 4),
Number(process.env.CLAWSWEEPER_FINAL_BASE_SYNC_ATTEMPTS ?? 1),
);
for (let attempt = 1; attempt <= maxFinalBaseSyncAttempts; attempt += 1) {
logProgress("starting validation/review loop", { mode, attempt });
@ -2004,6 +2236,72 @@ function blockedCodexWritePreflight(reason: string, detail: string) {
};
}
function codexFailureDetail(child: LooseRecord, fallback: string) {
const detail =
extractCodexJsonlFailure(child.stdout) ??
extractCodexJsonlFailure(child.stderr) ??
stripAnsi(String(child.stderr ?? child.stdout ?? "")).trim();
return detail || fallback;
}
function codexFailureMessage(label: string, detail: string) {
return `${label}: ${compactText(stripAnsi(detail || "no Codex output"), 900)}`;
}
function extractCodexJsonlFailure(value: JsonValue) {
const messages: string[] = [];
for (const line of String(value ?? "").split(/\r?\n/)) {
if (!line.trim()) continue;
let event;
try {
event = JSON.parse(line);
} catch {
continue;
}
if (event?.type === "error" && typeof event.message === "string") {
messages.push(event.message);
}
if (event?.type === "turn.failed" && typeof event.error?.message === "string") {
messages.push(event.error.message);
}
}
return messages.length > 0 ? messages[messages.length - 1] : null;
}
function stripAnsi(value: string) {
const text = String(value ?? "");
let out = "";
for (let index = 0; index < text.length; index += 1) {
if (text.charCodeAt(index) !== 27) {
out += text[index];
continue;
}
if (text[index + 1] !== "[") continue;
index += 2;
while (index < text.length) {
const code = text.charCodeAt(index);
if (code >= 0x40 && code <= 0x7e) break;
index += 1;
}
}
return out;
}
function codexRetryDelayMs(message: string, attempt: number) {
const parsed = parseCodexRetryAfterMs(message);
const fallback = Number(process.env.CLAWSWEEPER_CODEX_RETRY_DELAY_MS ?? 15_000);
const base = Number.isFinite(fallback) && fallback > 0 ? fallback : 15_000;
return Math.min(120_000, Math.max(parsed ?? 0, base * attempt));
}
function parseCodexRetryAfterMs(message: string) {
const match = String(message ?? "").match(/try again in\s+(\d+(?:\.\d+)?)(ms|s)\b/i);
if (!match) return null;
const value = Number(match[1]);
if (!Number.isFinite(value) || value < 0) return null;
return match[2]?.toLowerCase() === "s" ? Math.ceil(value * 1000) : Math.ceil(value);
}
function classifyCodexFailure(detail: string) {
const text = String(detail ?? "");
if (
@ -2119,18 +2417,61 @@ function validateAndReviewLoop({
}
lastReview.validation_commands_run = validationCommands;
if (isCleanCodexReview(lastReview)) return lastReview;
if (attempt === maxReviewAttempts) break;
if (attempt === maxReviewAttempts) {
const finalSummary = codexReviewFailureSummary(lastReview);
runCodexReviewFix({
fixArtifact,
targetDir,
mode,
review: lastReview,
attempt: `${attempt}-final`,
});
onReviewFix?.(`${attempt}-final`);
const finalValidationPlan = repairDeltaValidationPlan(
{ fixArtifact, targetDir, sourceHead },
currentTargetValidationOptions(),
);
validationCommands = runAllowedValidationCommands(
finalValidationPlan.commands,
targetDir,
finalValidationPlan.options,
baseBranch,
);
runDiffCheck({ targetDir, baseBranch });
return {
status: "passed_after_final_review_fix",
summary:
"Final Codex /review findings were sent through a last fix pass; changed-surface validation passed, and exact-head ClawSweeper review plus GitHub checks still gate merge after push.",
findings: [],
findings_addressed: true,
evidence: [
`Final review before fix: ${compactText(finalSummary, 700)}`,
"Changed-surface validation passed after the final review-fix pass.",
"Exact-head ClawSweeper review is dispatched after the branch push before automerge.",
],
validation_commands_run: validationCommands,
final_review_fix: {
status: "validation_passed",
previous_summary: compactText(finalSummary, 1000),
},
};
}
runCodexReviewFix({ fixArtifact, targetDir, mode, review: lastReview, attempt });
onReviewFix?.(attempt);
}
const summary =
lastReview?.summary ??
(Array.isArray(lastReview?.findings)
? lastReview.findings.map((finding: JsonValue) => finding.summary ?? finding).join("; ")
: "unknown");
const summary = codexReviewFailureSummary(lastReview);
throw new Error(`Codex /review did not pass after ${maxReviewAttempts} attempt(s): ${summary}`);
}
function codexReviewFailureSummary(review: LooseRecord | null): string {
return (
review?.summary ??
(Array.isArray(review?.findings)
? review.findings.map((finding: JsonValue) => finding.summary ?? finding).join("; ")
: "unknown")
);
}
function isFixableValidationError(error: JsonValue) {
const message = String(error?.message ?? error);
if (/no merge base|validation_script_missing/i.test(message)) return false;
@ -2188,7 +2529,7 @@ function runCodexReview({
"",
"Fix artifact:",
"```json",
JSON.stringify(fixArtifact, null, 2),
renderFixArtifactForPrompt(fixArtifact),
"```",
].join("\n");
const reviewTimeoutMs = currentCodexTimeoutMs();
@ -2297,6 +2638,7 @@ function runCodexReviewFix({ fixArtifact, targetDir, mode, review, attempt }: Lo
"",
"Rules:",
"- keep the patch narrow;",
"- keep shell output bounded; inspect targeted files and avoid broad repo-wide dumps;",
"- do not commit, push, open PRs, close PRs, or call gh;",
"- after edits, run the changed-surface validation command yourself before returning;",
"- if `pnpm check:changed` is available, run it before returning;",
@ -2311,7 +2653,7 @@ function runCodexReviewFix({ fixArtifact, targetDir, mode, review, attempt }: Lo
"",
"Fix artifact:",
"```json",
JSON.stringify(fixArtifact, null, 2),
renderFixArtifactForPrompt(fixArtifact),
"```",
].join("\n");
const reviewFixTimeoutMs = currentCodexTimeoutMs();
@ -2367,7 +2709,7 @@ function runCodexValidationFix({
validationPlan,
validationCommands = [],
}: LooseRecord) {
const validationError = compactText(String(error?.message ?? error), 4000);
const validationError = compactText(String(error?.message ?? error), 8000);
const changedFiles = run("git", ["diff", "--name-only"], { cwd: targetDir })
.split("\n")
.map((line) => line.trim())
@ -2378,6 +2720,7 @@ function runCodexValidationFix({
"Rules:",
"- keep the patch narrow;",
"- fix only issues introduced by the current repair branch or required to make its changed gate pass;",
"- keep shell output bounded; inspect targeted files and avoid broad repo-wide dumps;",
"- do not commit, push, open PRs, close PRs, or call gh;",
"- after edits, rerun the failed validation command yourself before returning;",
"- if `pnpm check:changed` is available, run it before returning;",
@ -2398,7 +2741,7 @@ function runCodexValidationFix({
"",
"Fix artifact:",
"```json",
JSON.stringify(fixArtifact, null, 2),
renderFixArtifactForPrompt(fixArtifact),
"```",
].join("\n");
const validationFixTimeoutMs = currentCodexTimeoutMs();
@ -2526,12 +2869,7 @@ function codexReviewSchemaPath() {
function ensureTargetCheckout(repo: string, targetDir: string) {
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(path.dirname(targetDir), { recursive: true });
run("gh", ["repo", "clone", repo, targetDir, "--", "--depth=1"], {
cwd: repoRoot(),
env: ghEnv(),
timeoutMs: currentNetworkCommandTimeoutMs(),
});
cloneTargetCheckout(repo, targetDir);
return;
}
if (!fs.existsSync(path.join(targetDir, ".git"))) {
@ -2541,6 +2879,70 @@ function ensureTargetCheckout(repo: string, targetDir: string) {
if (status) throw new Error(`target checkout has uncommitted changes: ${targetDir}`);
}
function cloneTargetCheckout(repo: string, targetDir: string) {
fs.mkdirSync(path.dirname(targetDir), { recursive: true });
setupGitHubCredentialHelper();
const timeoutMs = currentCheckoutCloneTimeoutMs();
const attempts = Math.max(1, Number(process.env.CLAWSWEEPER_CHECKOUT_CLONE_ATTEMPTS ?? 3));
let lastError = null;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
fs.rmSync(targetDir, { recursive: true, force: true });
try {
run("git", bloblessCloneArgs(repo, targetDir), {
cwd: repoRoot(),
env: ghEnv(),
timeoutMs,
});
return;
} catch (error) {
lastError = error;
logProgress("target checkout clone attempt failed", {
repo,
attempt,
attempts,
timeout_ms: timeoutMs,
error: compactText(String(error?.message ?? error), 500),
});
if (attempt === attempts) break;
}
}
fs.rmSync(targetDir, { recursive: true, force: true });
throw lastError instanceof Error ? lastError : new Error(String(lastError));
}
function setupGitHubCredentialHelper() {
if (!process.env.GH_TOKEN && !process.env.GITHUB_TOKEN) return;
try {
run("gh", ["auth", "setup-git", "--hostname", "github.com"], {
cwd: repoRoot(),
env: ghEnv(),
timeoutMs: Math.min(30_000, currentNetworkCommandTimeoutMs()),
});
} catch (error) {
logProgress("GitHub git credential setup failed; continuing", {
error: compactText(String(error?.message ?? error), 500),
});
}
}
function bloblessCloneArgs(repo: string, targetDir: string) {
return [
"clone",
"--filter=blob:none",
"--depth=1",
"--single-branch",
githubRepoCloneUrl(repo),
targetDir,
];
}
function githubRepoCloneUrl(repo: string) {
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) {
throw new Error(`invalid GitHub repository: ${repo}`);
}
return `https://github.com/${repo}.git`;
}
function setupGitIdentity(cwd: JsonValue) {
run("git", ["config", "user.name", clawsweeperGitUserName()], { cwd });
run("git", ["config", "user.email", clawsweeperGitUserEmail()], { cwd });

View File

@ -5,7 +5,11 @@ import os from "node:os";
import path from "node:path";
import test from "node:test";
import { buildFixPrompt, buildRepositoryContext } from "./fix-prompt-builder.js";
import {
buildFixPrompt,
buildRepositoryContext,
renderFixArtifactForPrompt,
} from "./fix-prompt-builder.js";
import type { LooseRecord } from "./json-types.js";
function promptFor(fixArtifact: LooseRecord): string {
@ -137,6 +141,83 @@ test("fix prompt includes rebase and previous no-diff recovery details", () => {
assert.match(prompt, /Fallback reason: source branch is stale/);
});
test("fix prompt compacts oversized artifacts before sending them to Codex", () => {
const hugeBody = "Codex review evidence with repeated context.\n".repeat(4000);
const prompt = buildFixPrompt({
fixArtifact: {
repo: "openclaw/openclaw",
repair_strategy: "repair_contributor_branch",
summary: "Fix a durable status comment lifecycle regression.",
source_prs: ["https://github.com/openclaw/openclaw/pull/77205"],
likely_files: ["src/clawsweeper.ts"],
validation_commands: ["pnpm check:changed"],
pr_body: hugeBody,
comments: Array.from({ length: 50 }, (_, index) => ({
author: `reviewer-${index}`,
body: hugeBody,
})),
},
branch: "clawsweeper/automerge-openclaw-openclaw-77205",
mode: "repair",
attempt: 1,
maxEditAttempts: 3,
repositoryContext: "candidate_files (1):\nsrc/clawsweeper.ts (100)",
isAutomergeRepair: true,
});
const artifactJson = renderFixArtifactForPrompt({
summary: "Fix a durable status comment lifecycle regression.",
pr_body: hugeBody,
});
assert.ok(prompt.length < 80_000, `prompt was ${prompt.length} chars`);
assert.ok(artifactJson.length <= 36_000, `artifact was ${artifactJson.length} chars`);
assert.match(prompt, /Original fix artifact was \d+ characters/);
assert.match(prompt, /source_prs/);
assert.match(prompt, /https:\/\/github\.com\/openclaw\/openclaw\/pull\/77205/);
assert.match(prompt, /pnpm check:changed/);
assert.match(prompt, /entries omitted/);
assert.match(prompt, /truncated \d+ chars/);
});
test("artifact compaction falls back to critical fields for pathological payloads", () => {
const tooManyKeys: LooseRecord = {
repo: "openclaw/openclaw",
source_prs: ["https://github.com/openclaw/openclaw/pull/77205"],
summary: "Keep the critical summary.",
validation_commands: ["pnpm check:changed"],
comments: Array.from({ length: 17 }, (_, index) => `comment ${index}`),
nested: { a: { b: { c: { d: { e: { f: "too deep" } } } } } },
};
for (let index = 0; index < 70; index += 1) {
tooManyKeys[`html_url_${index}`] =
`https://github.com/openclaw/openclaw/pull/77205#${"x".repeat(6000)}`;
}
const rendered = renderFixArtifactForPrompt(tooManyKeys);
const scalar = renderFixArtifactForPrompt("scalar context ".repeat(4000));
assert.ok(rendered.length < 8000, `artifact was ${rendered.length} chars`);
assert.match(rendered, /critical fields only/);
assert.match(rendered, /Keep the critical summary/);
assert.match(rendered, /pnpm check:changed/);
assert.match(scalar, /value was truncated/);
assert.match(scalar, /scalar context/);
const array = renderFixArtifactForPrompt(
Array.from({ length: 1000 }, (_, index) => ({
body: `array entry ${index} ${"x".repeat(200)}`,
})),
);
assert.match(array, /value was truncated/);
assert.match(array, /entries omitted/);
const hugeArray = renderFixArtifactForPrompt(
Array.from({ length: 1000 }, () => "x".repeat(10_000)),
);
assert.match(hugeArray, /critical fields only/);
assert.match(hugeArray, /prompt artifact hit/);
});
test("repository context ranks likely files and renders focused excerpts", () => {
const tmp = makeGitRepo({
"package.json": JSON.stringify({
@ -200,6 +281,23 @@ test("repository context handles missing candidates, huge files, and invalid pac
assert.match(context, /package_scripts: none/);
});
test("repository context renders first lines when discovery has no tokens", () => {
const tmp = makeGitRepo({
"package.json": JSON.stringify({ private: true }),
"README.md": Array.from({ length: 120 }, (_, index) => `line ${index + 1}`).join("\n"),
});
const context = buildRepositoryContext({
targetDir: tmp,
fixArtifact: {},
});
assert.match(context, /--- README\.md ---/);
assert.match(context, /1: line 1/);
assert.match(context, /80: line 80/);
assert.doesNotMatch(context, /120: line 120/);
});
test("repository context reports no candidates when nothing scores", () => {
const tmp = makeGitRepo({
"notes.bin": "no supported extension\n",

View File

@ -5,6 +5,12 @@ import { runCommand as run } from "./command-runner.js";
import type { JsonValue, LooseRecord } from "./json-types.js";
import { compactText } from "./text-utils.js";
const FIX_ARTIFACT_PROMPT_LIMIT = 36_000;
const FIX_ARTIFACT_ARRAY_LIMIT = 16;
const FIX_ARTIFACT_OBJECT_KEY_LIMIT = 60;
const REPOSITORY_CANDIDATE_LIMIT = 40;
const REPOSITORY_SNIPPET_LIMIT = 12_000;
export function buildFixPrompt({
fixArtifact,
branch,
@ -28,6 +34,7 @@ export function buildFixPrompt({
"- this is a writable checkout; make concrete file edits before returning;",
"- make the narrowest code change that satisfies the fix artifact;",
"- start by inspecting the repository paths below with rg/git ls-files/sed;",
"- keep shell output bounded: prefer targeted rg/sed/git commands, add --max-count/head/tail where useful, and do not dump broad repo-wide matches or huge files into the transcript;",
"- if likely_files are stale, missing, or glob-like, discover the real nearby files and edit those;",
"- always fetch latest origin/main and rebase or otherwise sync this branch onto that latest main before returning;",
"- run local git status/diff/log/rebase/merge commands needed to reconcile this branch with current origin/main;",
@ -70,13 +77,127 @@ export function buildFixPrompt({
"",
"Fix artifact:",
"```json",
JSON.stringify(fixArtifact, null, 2),
renderFixArtifactForPrompt(fixArtifact),
"```",
]
.filter(Boolean)
.join("\n");
}
export function renderFixArtifactForPrompt(fixArtifact: JsonValue) {
const raw = JSON.stringify(fixArtifact, null, 2);
if (raw.length <= FIX_ARTIFACT_PROMPT_LIMIT) return raw;
const compacted = compactPromptValue(fixArtifact);
const annotated =
compacted && typeof compacted === "object" && !Array.isArray(compacted)
? {
_prompt_compaction: `Original fix artifact was ${raw.length} characters; long strings, arrays, and nested objects were truncated for Codex context. Use local repository discovery and read-only GitHub inspection if more detail is needed.`,
...compacted,
}
: {
_prompt_compaction: `Original fix artifact was ${raw.length} characters; value was truncated for Codex context.`,
value: compacted,
};
const rendered = JSON.stringify(annotated, null, 2);
if (rendered.length <= FIX_ARTIFACT_PROMPT_LIMIT) return rendered;
return JSON.stringify(
finalPromptArtifactFallback(fixArtifact, raw.length, rendered.length),
null,
2,
);
}
function finalPromptArtifactFallback(
fixArtifact: JsonValue,
rawLength: number,
compactedLength: number,
) {
const record =
fixArtifact && typeof fixArtifact === "object" && !Array.isArray(fixArtifact)
? (fixArtifact as LooseRecord)
: {};
return {
_prompt_compaction: `Original fix artifact was ${rawLength} characters and compacted artifact was ${compactedLength} characters; using critical fields only for Codex context.`,
_truncated: `prompt artifact hit ${FIX_ARTIFACT_PROMPT_LIMIT} character cap`,
repo: record.repo ?? null,
cluster_id: record.cluster_id ?? null,
source_prs: compactPromptValue(record.source_prs ?? []),
source_issues: compactPromptValue(record.source_issues ?? []),
repair_strategy: record.repair_strategy ?? null,
summary: compactPromptValue(record.summary ?? ""),
pr_title: compactPromptValue(record.pr_title ?? ""),
affected_surfaces: compactPromptValue(record.affected_surfaces ?? []),
likely_files: compactPromptValue(record.likely_files ?? []),
validation_commands: compactPromptValue(record.validation_commands ?? []),
changelog_required: record.changelog_required ?? null,
};
}
function compactPromptValue(value: JsonValue, key = "", depth = 0): JsonValue {
if (value === null || typeof value === "number" || typeof value === "boolean") return value;
if (typeof value === "string") return compactPromptString(value, key, depth);
if (Array.isArray(value)) {
const limit = depth <= 1 ? FIX_ARTIFACT_ARRAY_LIMIT : Math.min(8, FIX_ARTIFACT_ARRAY_LIMIT);
const kept = value.slice(0, limit).map((entry) => compactPromptValue(entry, key, depth + 1));
if (value.length > limit) {
kept.push(
`[... ${value.length - limit} artifact entr${value.length - limit === 1 ? "y" : "ies"} omitted ...]`,
);
}
return kept;
}
if (typeof value !== "object") return String(value);
if (depth >= 5) return "[... nested artifact object omitted ...]";
const entries = Object.entries(value as LooseRecord);
const priority = new Set([
"repo",
"cluster_id",
"source_prs",
"source_issues",
"repair_strategy",
"summary",
"pr_title",
"affected_surfaces",
"likely_files",
"validation_commands",
"changelog_required",
"review_findings",
"fix_plan",
"actions",
]);
const sorted = entries.sort(([left], [right]) => {
const leftPriority = priority.has(left) ? 0 : 1;
const rightPriority = priority.has(right) ? 0 : 1;
return leftPriority - rightPriority;
});
const out: LooseRecord = {};
for (const [entryKey, entryValue] of sorted.slice(0, FIX_ARTIFACT_OBJECT_KEY_LIMIT)) {
out[entryKey] = compactPromptValue(entryValue, entryKey, depth + 1);
}
if (entries.length > FIX_ARTIFACT_OBJECT_KEY_LIMIT) {
out._omitted_keys = `${entries.length - FIX_ARTIFACT_OBJECT_KEY_LIMIT} low-priority artifact keys omitted`;
}
return out;
}
function compactPromptString(value: string, key: string, depth: number) {
const lowerKey = key.toLowerCase();
const limit = /url|html_url|sha|oid|idempotency/.test(lowerKey)
? 4096
: /body|comment|review|evidence|log|stdout|stderr|diff|patch|transcript|message/.test(lowerKey)
? 1200
: depth <= 1
? 2400
: 1600;
if (value.length <= limit) return value;
const marker = `\n...[truncated ${value.length - limit} chars]...\n`;
const available = Math.max(0, limit - marker.length);
const head = Math.ceil(available * 0.65);
return `${value.slice(0, head)}${marker}${value.slice(value.length - (available - head))}`;
}
function renderGitHubToolRule(isAutomergeRepair: boolean) {
if (!isAutomergeRepair) return "- do not push, open PRs, close PRs, or call gh;";
return "- do not push, open PRs, close PRs, comment, label, or merge; read-only `gh` commands are allowed for PR comments, review threads, check status, and check logs when available;";
@ -170,7 +291,10 @@ export function buildRepositoryContext({ fixArtifact, targetDir }: LooseRecord)
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
const scoredCandidates = scoreRepositoryFiles({ files, fixArtifact }).slice(0, 80);
const scoredCandidates = scoreRepositoryFiles({ files, fixArtifact }).slice(
0,
REPOSITORY_CANDIDATE_LIMIT,
);
const candidates = scoredCandidates.map((entry: JsonValue) => `${entry.file} (${entry.score})`);
const snippets = buildRepositorySnippets({
targetDir,
@ -195,6 +319,7 @@ export function buildRepositoryContext({ fixArtifact, targetDir }: LooseRecord)
function buildRepositorySnippets({ targetDir, candidates, fixArtifact }: LooseRecord) {
const tokens = discoveryTokens(fixArtifact).slice(0, 40);
const out: JsonValue[] = [];
let renderedLength = 0;
for (const candidate of candidates) {
const pathname = path.join(targetDir, candidate.file);
if (!fs.existsSync(pathname)) continue;
@ -203,10 +328,12 @@ function buildRepositorySnippets({ targetDir, candidates, fixArtifact }: LooseRe
const content = fs.readFileSync(pathname, "utf8");
const excerpt = focusedFileExcerpt(content, tokens);
if (!excerpt) continue;
out.push(`--- ${candidate.file} ---\n${excerpt}`);
if (out.join("\n\n").length > 18_000) break;
const rendered = `--- ${candidate.file} ---\n${excerpt}`;
renderedLength += rendered.length + (out.length > 0 ? 2 : 0);
out.push(rendered);
if (renderedLength > REPOSITORY_SNIPPET_LIMIT) break;
}
return out.join("\n\n").slice(0, 18_000);
return out.join("\n\n").slice(0, REPOSITORY_SNIPPET_LIMIT);
}
function focusedFileExcerpt(content: string, tokens: string[]) {
@ -215,6 +342,8 @@ function focusedFileExcerpt(content: string, tokens: string[]) {
const lowerTokens = tokens
.map((token) => token.toLowerCase())
.filter((token) => token.length >= 4);
if (lowerTokens.length === 0)
return renderSelectedExcerptLines(lines, firstLineIndexes(lines, 80));
for (let index = 0; index < lines.length; index += 1) {
const lower = lines[index]!.toLowerCase();
if (lowerTokens.some((token) => lower.includes(token))) {
@ -230,14 +359,25 @@ function focusedFileExcerpt(content: string, tokens: string[]) {
const selected =
matched.size > 0
? [...matched].sort((left, right) => left - right)
: lines.map((_, index) => index).slice(0, 80);
: firstLineIndexes(lines, 80);
return renderSelectedExcerptLines(lines, selected);
}
function firstLineIndexes(lines: readonly string[], limit: number) {
return Array.from({ length: Math.min(lines.length, limit) }, (_, index) => index);
}
function renderSelectedExcerptLines(lines: readonly string[], selected: readonly number[]) {
const rendered: string[] = [];
let previous = -2;
let renderedLength = 0;
for (const line of selected) {
if (line !== previous + 1) rendered.push("...");
rendered.push(`${line + 1}: ${lines[line]}`);
const renderedLine = `${line + 1}: ${lines[line]}`;
rendered.push(renderedLine);
renderedLength += renderedLine.length + 1;
previous = line;
if (rendered.join("\n").length > 3_200) break;
if (renderedLength > 3_200) break;
}
return rendered.join("\n");
}

View File

@ -47,8 +47,20 @@ export function ghJsonBestEffort<T = JsonValue>(
}
}
export function githubPaginatedPath(apiPath: string): string {
const [basePart, query = ""] = apiPath.split("?", 2);
const base = basePart ?? apiPath;
const params = new URLSearchParams(query);
if (!params.has("per_page")) params.set("per_page", "100");
const serialized = params.toString();
return serialized ? `${base}?${serialized}` : base;
}
export function ghPaged<T = JsonValue>(apiPath: string, options: GhRunOptions = {}): T[] {
const pages = ghJson<JsonValue[]>(["api", apiPath, "--paginate", "--slurp"], options);
const pages = ghJson<JsonValue[]>(
["api", githubPaginatedPath(apiPath), "--paginate", "--slurp"],
options,
);
if (!Array.isArray(pages)) return [];
return pages.flatMap((page: JsonValue) => (Array.isArray(page) ? (page as T[]) : []));
}
@ -57,7 +69,10 @@ export function ghPagedWithRetry<T = JsonValue>(
apiPath: string,
options: GhRetryOptions | number = {},
): T[] {
const pages = ghJsonWithRetry<JsonValue[]>(["api", apiPath, "--paginate", "--slurp"], options);
const pages = ghJsonWithRetry<JsonValue[]>(
["api", githubPaginatedPath(apiPath), "--paginate", "--slurp"],
options,
);
if (!Array.isArray(pages)) return [];
return pages.flatMap((page: JsonValue) => (Array.isArray(page) ? (page as T[]) : []));
}
@ -67,7 +82,7 @@ export async function ghPagedWithRetryAsync<T = JsonValue>(
options: GhRetryOptions | number = {},
): Promise<T[]> {
const pages = await ghJsonWithRetryAsync<JsonValue[]>(
["api", apiPath, "--paginate", "--slurp"],
["api", githubPaginatedPath(apiPath), "--paginate", "--slurp"],
options,
);
if (!Array.isArray(pages)) return [];

181
src/repair/limits.ts Normal file
View File

@ -0,0 +1,181 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { repoRoot } from "./paths.js";
export type WorkerConfig = {
workers: {
max: number;
reserve_for_interactive: number;
minimum_background: number;
};
};
export type AutomationLimits = {
review_shards: {
normal_default: number;
normal_active_floor: number;
hot_intake_default: number;
exact_item_default: number;
hard_cap: number;
};
commit_review: {
page_size_default: number;
page_size_hard_cap: number;
};
repair_live_runs: {
default: number;
hard_cap: number;
automerge_default: number;
issue_implementation_default: number;
};
issue_implementation: {
dispatches_per_sweep_default: number;
};
};
export type WorkerLane =
| "normal_review"
| "hot_intake"
| "commit_review"
| "repair"
| "automerge_repair"
| "issue_implementation"
| "exact_item";
export const WORKER_CONFIG = readWorkerConfig();
export const AUTOMATION_LIMITS = deriveAutomationLimits(WORKER_CONFIG);
export function readWorkerConfig(
filePath = join(repoRoot(), "config", "automation-limits.json"),
): WorkerConfig {
const parsed = JSON.parse(readFileSync(filePath, "utf8")) as unknown;
return validateWorkerConfig(parsed);
}
export function deriveAutomationLimits(config: WorkerConfig): AutomationLimits {
const max = config.workers.max;
return {
review_shards: {
normal_default: percent(max, 70),
normal_active_floor: percent(max, 30),
hot_intake_default: percent(max, 35),
exact_item_default: 1,
hard_cap: max,
},
commit_review: {
page_size_default: percent(max, 5),
page_size_hard_cap: max,
},
repair_live_runs: {
default: percent(max, 40),
hard_cap: max,
automerge_default: percent(max, 40),
issue_implementation_default: percent(max, 40),
},
issue_implementation: {
dispatches_per_sweep_default: percent(max, 4),
},
};
}
export function workerLimit(
lane: WorkerLane,
{
activeCritical = 0,
activeBackground = 0,
config = WORKER_CONFIG,
limits = AUTOMATION_LIMITS,
}: {
activeCritical?: number;
activeBackground?: number;
config?: WorkerConfig;
limits?: AutomationLimits;
} = {},
): number {
if (lane === "exact_item") return limits.review_shards.exact_item_default;
if (lane === "repair") return priorityLimit(limits.repair_live_runs.default, activeCritical);
if (lane === "automerge_repair")
return priorityLimit(limits.repair_live_runs.automerge_default, activeCritical);
if (lane === "issue_implementation")
return priorityLimit(limits.repair_live_runs.issue_implementation_default, activeCritical);
if (lane === "commit_review")
return backgroundLimit(
limits.commit_review.page_size_default,
activeCritical,
activeBackground,
);
if (lane === "hot_intake")
return backgroundLimit(
limits.review_shards.hot_intake_default,
activeCritical,
activeBackground,
);
return backgroundLimit(limits.review_shards.normal_default, activeCritical, activeBackground);
function priorityLimit(laneMax: number, active: number): number {
const available = Math.max(1, config.workers.max - nonNegative(active));
return Math.max(1, Math.min(laneMax, available));
}
function backgroundLimit(laneMax: number, active: number, background: number): number {
const rawAvailable =
config.workers.max -
config.workers.reserve_for_interactive -
nonNegative(active) -
nonNegative(background);
if (rawAvailable <= 0) return 1;
const withFloor =
rawAvailable >= config.workers.minimum_background ? rawAvailable : Math.max(1, rawAvailable);
return Math.max(1, Math.min(laneMax, withFloor));
}
}
function validateWorkerConfig(value: unknown): WorkerConfig {
if (!isRecord(value)) throw new Error("automation limits must be an object");
return {
workers: {
max: positiveInteger(value, "workers.max"),
reserve_for_interactive: nonNegativeInteger(value, "workers.reserve_for_interactive"),
minimum_background: positiveInteger(value, "workers.minimum_background"),
},
};
}
function percent(max: number, value: number): number {
return Math.max(1, Math.floor((max * value) / 100));
}
function positiveInteger(root: Record<string, unknown>, path: string): number {
const value = getPath(root, path);
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
throw new Error(`automation limit ${path} must be a positive integer`);
}
return value;
}
function nonNegativeInteger(root: Record<string, unknown>, path: string): number {
const value = getPath(root, path);
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
throw new Error(`automation limit ${path} must be a non-negative integer`);
}
return value;
}
function nonNegative(value: number): number {
return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
}
function getPath(root: Record<string, unknown>, path: string): unknown {
let cursor: unknown = root;
for (const segment of path.split(".")) {
if (!isRecord(cursor) || !(segment in cursor)) {
throw new Error(`automation limit ${path} is missing`);
}
cursor = cursor[segment];
}
return cursor;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View File

@ -1,16 +1,18 @@
import { ghJson } from "./github-cli.js";
import type { JsonValue, LooseRecord } from "./json-types.js";
import { REPAIR_CLUSTER_WORKFLOW } from "./constants.js";
import { AUTOMATION_LIMITS } from "./limits.js";
import { currentProjectRepo } from "./project-repo.js";
import { sleepMs } from "./timing.js";
const DEFAULT_MAX_LIVE_WORKERS = 50;
export const MAX_LIVE_WORKERS = 100;
const DEFAULT_MAX_LIVE_WORKERS = AUTOMATION_LIMITS.repair_live_runs.default;
export const MAX_LIVE_WORKERS = AUTOMATION_LIMITS.repair_live_runs.hard_cap;
export const DEFAULT_AUTOMERGE_REPAIR_RUN_NAME_PREFIX = "automerge repair ";
export const DEFAULT_REPAIR_RUN_NAME_PREFIX = "repair cluster ";
const DEFAULT_CAPACITY_POLL_MS = 30_000;
const DEFAULT_CAPACITY_TIMEOUT_MS = 30 * 60 * 1000;
const ACTIVE_WORKFLOW_STATUSES = ["queued", "in_progress", "waiting", "requested", "pending"];
const ACTIVE_WORKFLOW_STATUS_SET = new Set(ACTIVE_WORKFLOW_STATUSES);
export function readMaxLiveWorkers(args: LooseRecord = {}) {
return readMaxLiveWorkerLimit(
@ -109,24 +111,16 @@ export function listActiveWorkflowRuns({
workflow = REPAIR_CLUSTER_WORKFLOW,
runNamePrefix = "",
excludeRunNamePrefix = "",
fetchWorkflowRuns = fetchRecentWorkflowRuns,
}: LooseRecord = {}) {
const runs: LooseRecord[] = [];
for (const status of ACTIVE_WORKFLOW_STATUSES) {
const workflowRuns = ghJson([
"api",
"--method",
"GET",
`repos/${repo}/actions/workflows/${encodeURIComponent(workflow)}/runs`,
"-f",
`status=${status}`,
"-f",
"per_page=100",
"--jq",
".workflow_runs",
]);
if (Array.isArray(workflowRuns))
runs.push(...workflowRuns.map((run: JsonValue) => normalizeWorkflowRun(run, status)));
}
const fetchRuns =
typeof fetchWorkflowRuns === "function" ? fetchWorkflowRuns : fetchRecentWorkflowRuns;
const workflowRuns = fetchRuns({ repo, workflow });
const runs = Array.isArray(workflowRuns)
? workflowRuns
.filter(isActiveWorkflowRun)
.map((run: JsonValue) => normalizeWorkflowRun(run, String(run.status ?? "")))
: [];
return [
...new Map(runs.map((run: JsonValue) => [String(run.databaseId ?? run.id), run])).values(),
]
@ -137,6 +131,19 @@ export function listActiveWorkflowRuns({
);
}
function fetchRecentWorkflowRuns({ repo, workflow }: LooseRecord) {
return ghJson([
"api",
"--method",
"GET",
`repos/${repo}/actions/workflows/${encodeURIComponent(workflow)}/runs`,
"-f",
"per_page=100",
"--jq",
".workflow_runs",
]);
}
export function repairRunNamePrefixForJob(
jobPath: JsonValue,
automergeRunNamePrefix: JsonValue = DEFAULT_AUTOMERGE_REPAIR_RUN_NAME_PREFIX,
@ -217,17 +224,21 @@ function runMatchesNameFilter(
return true;
}
function normalizeWorkflowRun(run: LooseRecord, fallbackStatus: string) {
export function normalizeWorkflowRun(run: LooseRecord, fallbackStatus: string) {
return {
databaseId: run.databaseId ?? run.database_id ?? run.id,
status: run.status ?? fallbackStatus,
conclusion: run.conclusion ?? null,
createdAt: run.createdAt ?? run.created_at ?? null,
url: run.url ?? run.html_url ?? null,
url: run.html_url ?? run.url ?? null,
displayTitle: run.displayTitle ?? run.display_title ?? run.name ?? null,
};
}
function isActiveWorkflowRun(run: LooseRecord) {
return ACTIVE_WORKFLOW_STATUS_SET.has(String(run.status ?? ""));
}
function joinRepairRunNamePrefix(prefix: JsonValue, jobPath: string) {
const text = String(prefix ?? "");
if (!text || !jobPath) return `${text}${jobPath}`;

View File

@ -1,5 +1,22 @@
import type { JsonValue } from "./json-types.js";
export function repairBranchPushBlockedReason(error: JsonValue) {
const message = String((error as Error)?.message ?? error);
if (!message) return null;
if (
/refusing to allow a GitHub App to create or update workflow/i.test(message) &&
/\.github\/workflows\//i.test(message) &&
/without [`']?workflows[`']? permission/i.test(message)
) {
return "GitHub rejected the repair branch push because it updates workflow files and the ClawSweeper app token does not have workflows permission";
}
return null;
}
export function isRepairBranchPushBlocked(error: JsonValue) {
return repairBranchPushBlockedReason(error) !== null;
}
export function repairBranchPushRaceReason(error: JsonValue) {
const message = String((error as Error)?.message ?? error);
if (!message) return null;

View File

@ -17,6 +17,7 @@ import {
import { ghJson, ghText } from "./github-cli.js";
import { sleepMs } from "./timing.js";
import { REPAIR_CLUSTER_WORKFLOW } from "./constants.js";
import { AUTOMATION_LIMITS } from "./limits.js";
const DEFAULT_REPO = currentProjectRepo();
const DEFAULT_WORKFLOW = REPAIR_CLUSTER_WORKFLOW;
@ -46,7 +47,7 @@ const resolved = requestedRunId
if (!resolved.source_job) {
console.error(
"usage: node scripts/requeue-job.ts <job.md|run-id> [--mode plan|execute|autonomous] [--execute] [--open-execute-window] [--runner label] [--execution-runner label] [--model model] [--max-live-workers 50] [--wait-for-capacity]",
`usage: node scripts/requeue-job.ts <job.md|run-id> [--mode plan|execute|autonomous] [--execute] [--open-execute-window] [--runner label] [--execution-runner label] [--model model] [--max-live-workers ${AUTOMATION_LIMITS.repair_live_runs.default}] [--wait-for-capacity]`,
);
process.exit(2);
}
@ -214,18 +215,22 @@ function assertGateOpenIfNeeded(mode: string) {
}
function listClusterRuns() {
return ghJson([
const workflowName = workflowDisplayName(workflow);
return ghJson<LooseRecord[]>([
"run",
"list",
"--repo",
repo,
"--workflow",
workflow,
"--limit",
"50",
"200",
"--json",
"databaseId,headSha,status,conclusion,createdAt,url",
]);
"databaseId,workflowName,headSha,status,conclusion,createdAt,url",
]).filter((run: LooseRecord) => run.workflowName === workflowName);
}
function workflowDisplayName(workflowNameOrFile: string): string {
if (workflowNameOrFile === "repair-cluster-worker.yml") return "repair cluster worker";
return workflowNameOrFile;
}
function readGate(name: string) {

View File

@ -23,6 +23,7 @@ const DEFAULT_RUNNER = process.env.CLAWSWEEPER_WORKER_RUNNER ?? "blacksmith-4vcp
const DEFAULT_EXECUTION_RUNNER =
process.env.CLAWSWEEPER_EXECUTION_RUNNER ?? "blacksmith-16vcpu-ubuntu-2404";
const QUEUED_STATUSES = new Set(["queued", "requested", "waiting", "pending"]);
const ACTIVE_STATUSES = new Set([...QUEUED_STATUSES, "in_progress"]);
const args = parseArgs(process.argv.slice(2));
const repo = String(args.repo ?? DEFAULT_REPO);
@ -153,6 +154,7 @@ try {
function selectCandidates() {
const records = readRunRecords();
const attempts = readSelfHealLedger().attempts ?? [];
const activeSourceJobs = execute ? activeRepairSourceJobs() : new Map<string, string[]>();
const cutoffMs = Date.now() - maxAgeHours * 60 * 60 * 1000;
const attemptedJobs = new Set(
attempts.map((attempt: JsonValue) => attempt.source_job).filter(Boolean),
@ -186,6 +188,18 @@ function selectCandidates() {
});
return false;
})
.filter((record: JsonValue) => {
const sourceJob = String(record.source_job ?? "");
const activeRunIds = activeSourceJobs.get(sourceJob) ?? [];
if (activeRunIds.length === 0) return true;
skippedCandidates.push({
reason: "active_repair_run",
run_id: record.run_id ?? null,
source_job: sourceJob,
active_run_ids: activeRunIds,
});
return false;
})
.filter((record: JsonValue) => allowRepeat || !attemptedJobs.has(record.source_job))
.map((record: JsonValue) => {
const sourceJob = String(record.source_job ?? "");
@ -216,6 +230,32 @@ function sourceJobPath(sourceJob: string) {
return path.isAbsolute(sourceJob) ? sourceJob : path.join(repoRoot(), sourceJob);
}
function activeRepairSourceJobs() {
const jobs = new Map<string, string[]>();
let runs: LooseRecord[] = [];
try {
runs = listClusterRuns();
} catch (error) {
console.warn(`self-heal: cannot list active repair runs: ${ghErrorText(error)}`);
return jobs;
}
for (const run of runs) {
if (!ACTIVE_STATUSES.has(String(run.status ?? ""))) continue;
const sourceJob = sourceJobFromRunTitle(String(run.displayTitle ?? ""));
if (!sourceJob) continue;
const runId = String(run.databaseId ?? "");
jobs.set(sourceJob, [...(jobs.get(sourceJob) ?? []), runId].filter(Boolean));
}
return jobs;
}
function sourceJobFromRunTitle(title: string) {
const index = title.indexOf("jobs/");
if (index < 0) return null;
return title.slice(index).trim();
}
function dispatchCandidate(candidate: LooseRecord) {
const result = spawnSync(
"gh",
@ -289,11 +329,35 @@ function assertExecuteGateOpenIfNeeded(candidates: LooseRecord[]) {
function readRunRecords() {
const runsDir = path.join(repoRoot(), "results", "runs");
if (!fs.existsSync(runsDir)) return [];
return fs
.readdirSync(runsDir)
.filter((name: string) => name.endsWith(".json"))
.map((name: string) => JSON.parse(fs.readFileSync(path.join(runsDir, name), "utf8")));
const records = fs.existsSync(runsDir)
? fs
.readdirSync(runsDir)
.filter((name: string) => name.endsWith(".json"))
.map((name: string) => JSON.parse(fs.readFileSync(path.join(runsDir, name), "utf8")))
: [];
return [...records, ...liveRunRecords()];
}
function liveRunRecords() {
try {
return listClusterRuns()
.map((run: LooseRecord) => {
const sourceJob = sourceJobFromRunTitle(String(run.displayTitle ?? ""));
if (!sourceJob) return null;
return {
run_id: String(run.databaseId ?? ""),
source_job: sourceJob,
workflow_conclusion: run.conclusion ?? null,
workflow_created_at: run.createdAt ?? null,
workflow_updated_at: run.updatedAt ?? null,
run_url: run.url ?? null,
};
})
.filter(Boolean);
} catch (error) {
console.warn(`self-heal: cannot list live repair runs: ${ghErrorText(error)}`);
return [];
}
}
function readSelfHealLedger() {
@ -320,18 +384,22 @@ function selfHealLedgerPath() {
}
function listClusterRuns() {
return ghJson([
const workflowName = workflowDisplayName(workflow);
return ghJson<LooseRecord[]>([
"run",
"list",
"--repo",
repo,
"--workflow",
workflow,
"--limit",
"50",
"200",
"--json",
"databaseId,headSha,status,conclusion,createdAt,url",
]);
"databaseId,workflowName,displayTitle,headSha,status,conclusion,createdAt,updatedAt,url",
]).filter((run: LooseRecord) => run.workflowName === workflowName);
}
function workflowDisplayName(workflowNameOrFile: string): string {
if (workflowNameOrFile === "repair-cluster-worker.yml") return "repair cluster worker";
return workflowNameOrFile;
}
function readExecuteGate() {

View File

@ -281,30 +281,20 @@ function readOpenClawSweeperPrClusters() {
function readActiveClusterRuns() {
const repo = process.env.CLAWSWEEPER_REPO ?? "openclaw/clawsweeper";
const statuses = ["queued", "in_progress", "waiting", "requested", "pending"];
const runs: LooseRecord[] = [];
for (const status of statuses) {
try {
runs.push(
...ghJson([
"run",
"list",
"--repo",
repo,
"--workflow",
REPAIR_CLUSTER_WORKFLOW,
"--status",
status,
"--limit",
"100",
"--json",
"databaseId,status,conclusion,createdAt,updatedAt,url,displayTitle",
]),
);
} catch {
// Some statuses are not accepted on older gh versions; active PR detection is still useful.
}
}
const statuses = new Set(["queued", "in_progress", "waiting", "requested", "pending"]);
const workflowName = workflowDisplayName(REPAIR_CLUSTER_WORKFLOW);
const runs = ghJson<LooseRecord[]>([
"run",
"list",
"--repo",
repo,
"--limit",
"200",
"--json",
"databaseId,workflowName,status,conclusion,createdAt,updatedAt,url,displayTitle",
]).filter((run: LooseRecord) => {
return run.workflowName === workflowName && statuses.has(String(run.status));
});
const byId = new Map();
for (const run of runs) byId.set(String(run.databaseId), run);
return [...byId.values()].sort((left: JsonValue, right: JsonValue) =>
@ -312,6 +302,11 @@ function readActiveClusterRuns() {
);
}
function workflowDisplayName(workflow: string): string {
if (workflow === "repair-cluster-worker.yml") return "repair cluster worker";
return workflow;
}
function publicRow(row: LooseRecord) {
return Object.fromEntries(
Object.entries(row).filter(([, value]: JsonValue[]) => value !== undefined),

View File

@ -159,7 +159,7 @@ export function runAllowedValidationCommands(
}
if (shouldRetryValidationCommand({ parts, error, attempts, options })) continue;
throw new Error(
`validation command failed (${parts.join(" ")}): ${compactText(error.message, 1200)}`,
`validation command failed (${parts.join(" ")}): ${compactText(error.message, 12000)}`,
);
}
}

View File

@ -0,0 +1,127 @@
#!/usr/bin/env node
import { setTimeout as sleep } from "node:timers/promises";
import { ghPagedWithRetry, ghText } from "./github-cli.js";
import type { JsonValue, LooseRecord } from "./json-types.js";
import { repoRoot } from "./paths.js";
import { writePayload } from "./comment-router-utils.js";
const PROGRESS_START = "<!-- clawsweeper-command-progress:start -->";
const PROGRESS_END = "<!-- clawsweeper-command-progress:end -->";
type Options = {
repo: string;
itemNumber: string;
marker: string;
state: string;
detail: string;
runUrl: string;
waitMs: number;
};
const options = parseOptions(process.argv.slice(2));
await updateCommandStatus(options);
async function updateCommandStatus(options: Options) {
if (!options.marker) return;
validateRepo(options.repo);
validateItemNumber(options.itemNumber);
const comment = await findCommandStatusComment(options);
if (!comment?.id || typeof comment.body !== "string") {
console.warn(`No command status comment found for ${options.repo}#${options.itemNumber}.`);
return;
}
const body = mergeCommandProgressSection(comment.body, options);
if (body === comment.body) return;
const payload = writePayload(repoRoot(), `command-status-progress-${comment.id}`, { body });
ghText([
"api",
`repos/${options.repo}/issues/comments/${comment.id}`,
"--method",
"PATCH",
"--input",
payload,
]);
}
async function findCommandStatusComment(options: Options): Promise<LooseRecord | null> {
const deadline = Date.now() + Math.max(0, options.waitMs);
let shouldContinue = true;
while (shouldContinue) {
const comments = ghPagedWithRetry<LooseRecord>(
`repos/${options.repo}/issues/${options.itemNumber}/comments?per_page=100`,
{ attempts: 3 },
);
const match = comments
.filter(
(comment) => typeof comment.body === "string" && comment.body.includes(options.marker),
)
.at(-1);
if (match) return match;
shouldContinue = Date.now() < deadline;
if (!shouldContinue) break;
await sleep(5000);
}
return null;
}
export function mergeCommandProgressSection(
body: string,
options: Pick<Options, "state" | "detail" | "runUrl">,
) {
const section = renderCommandProgressSection(options);
const start = body.indexOf(PROGRESS_START);
const end = body.indexOf(PROGRESS_END);
if (start >= 0 && end > start) {
return `${body.slice(0, start).trimEnd()}\n\n${section}\n${body.slice(end + PROGRESS_END.length).trimStart()}`;
}
return `${body.trimEnd()}\n\n${section}`;
}
function renderCommandProgressSection(options: Pick<Options, "state" | "detail" | "runUrl">) {
const lines = [
PROGRESS_START,
"Re-review progress:",
`- State: ${options.state}`,
`- Detail: ${options.detail}`,
];
if (options.runUrl) lines.push(`- Run: ${options.runUrl}`);
lines.push(`- Updated: ${new Date().toISOString()}`, PROGRESS_END);
return lines.join("\n");
}
function parseOptions(argv: string[]): Options {
const args: Record<string, string> = {};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index] ?? "";
if (!arg.startsWith("--")) continue;
const key = arg.slice(2);
const next = argv[index + 1];
if (!next || next.startsWith("--")) {
args[key] = "true";
continue;
}
args[key] = next;
index += 1;
}
return {
repo: args.repo ?? process.env.TARGET_REPO ?? "",
itemNumber: args["item-number"] ?? process.env.ITEM_NUMBER ?? "",
marker: args.marker ?? process.env.COMMAND_STATUS_MARKER ?? "",
state: args.state ?? process.env.COMMAND_STATUS_STATE ?? "",
detail: args.detail ?? process.env.COMMAND_STATUS_DETAIL ?? "",
runUrl: args["run-url"] ?? process.env.RUN_URL ?? "",
waitMs: Number.parseInt(args["wait-ms"] ?? process.env.COMMAND_STATUS_WAIT_MS ?? "0", 10) || 0,
};
}
function validateRepo(repo: string) {
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) {
throw new Error(`invalid repo: ${repo}`);
}
}
function validateItemNumber(itemNumber: JsonValue) {
if (!/^[0-9]+$/.test(String(itemNumber ?? ""))) {
throw new Error(`invalid item number: ${itemNumber}`);
}
}

View File

@ -5,6 +5,7 @@ import path from "node:path";
import { pathToFileURL } from "node:url";
import { parseArgs } from "./lib.js";
import { isJsonObject } from "./json-types.js";
import { AUTOMATION_LIMITS, WORKER_CONFIG, workerLimit, type WorkerLane } from "./limits.js";
type ApplyAction = {
action: string;
@ -49,6 +50,22 @@ function runCli(): void {
case "count-requeue-required":
console.log(countRequeueRequired(requiredString("dir")));
break;
case "limit":
process.stdout.write(String(automationLimit(optionalString("path") || positionalString(1))));
break;
case "worker-limit":
process.stdout.write(
String(
workerLimit(requiredWorkerLane(optionalString("lane") || positionalString(1)), {
activeCritical: numberArg("active-critical", 0),
activeBackground: numberArg("active-background", 0),
}),
),
);
break;
case "worker-config":
process.stdout.write(JSON.stringify(WORKER_CONFIG, null, 2));
break;
case "proposed-item-numbers":
process.stdout.write(proposedItemNumbers(proposedItemOptions()).join(","));
break;
@ -60,10 +77,42 @@ function runCli(): void {
}
}
function requiredWorkerLane(value: string): WorkerLane {
const allowed = new Set<WorkerLane>([
"normal_review",
"hot_intake",
"commit_review",
"repair",
"automerge_repair",
"issue_implementation",
"exact_item",
]);
if (allowed.has(value as WorkerLane)) return value as WorkerLane;
throw new Error(`unknown worker lane: ${value}`);
}
export function automationLimit(limitPath: string): number {
let cursor: unknown = AUTOMATION_LIMITS;
for (const segment of limitPath.split(".")) {
if (!segment) throw new Error(`invalid automation limit path: ${limitPath}`);
if (!isJsonObject(cursor) || !(segment in cursor)) {
throw new Error(`unknown automation limit: ${limitPath}`);
}
cursor = cursor[segment];
}
if (typeof cursor !== "number" || !Number.isInteger(cursor) || cursor < 1) {
throw new Error(`automation limit ${limitPath} must resolve to a positive integer`);
}
return cursor;
}
function printPlanOutput(): void {
const plan = readJsonObject(requiredString("plan"));
const batchSize = positiveNumber(optionalString("batch-size"), 5);
const shardCount = positiveNumber(optionalString("shard-count"), 100);
const shardCount = positiveNumber(
optionalString("shard-count"),
AUTOMATION_LIMITS.review_shards.normal_default,
);
printOutput(planOutputFields(plan, { batchSize, shardCount }));
}
@ -310,6 +359,11 @@ function optionalString(name: string): string {
return typeof value === "string" ? value : "";
}
function positionalString(index: number): string {
const value = args._[index];
return typeof value === "string" ? value : "";
}
function numberArg(name: string, fallback: number): number {
const value = optionalString(name);
if (!value) return fallback;

View File

@ -1,3 +1,7 @@
import { existsSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
export type RepositoryItemKind = "issue" | "pull_request";
export type RepositoryCloseReason =
| "implemented_on_main"
@ -20,6 +24,30 @@ export interface RepositoryProfile {
applyCloseRules: Partial<Record<RepositoryItemKind, readonly RepositoryCloseReason[]>>;
}
interface TargetRepositoryConfig {
schemaVersion: 1;
repositories: readonly ConfiguredRepositoryProfile[];
openclawFallback?: OpenClawFallbackConfig;
}
interface ConfiguredRepositoryProfile {
targetRepo: string;
displayName: string;
checkoutDir: string;
docsUrl?: string;
communityUrl?: string;
promptNote: string;
applyCloseRules: Partial<Record<RepositoryItemKind, readonly RepositoryCloseReason[]>>;
}
interface OpenClawFallbackConfig {
owner: string;
denyRepositories: readonly string[];
allowRepoNamePattern: RegExp;
promptNote: string;
applyCloseRules: Partial<Record<RepositoryItemKind, readonly RepositoryCloseReason[]>>;
}
const OPENCLAW_CLOSE_REASONS: readonly RepositoryCloseReason[] = [
"implemented_on_main",
"cannot_reproduce",
@ -30,48 +58,32 @@ const OPENCLAW_CLOSE_REASONS: readonly RepositoryCloseReason[] = [
"stale_insufficient_info",
];
const ALL_CLOSE_REASONS: readonly RepositoryCloseReason[] = [...OPENCLAW_CLOSE_REASONS, "none"];
const CLOSE_REASON_SET = new Set<RepositoryCloseReason>(ALL_CLOSE_REASONS);
const ITEM_KIND_SET = new Set<RepositoryItemKind>(["issue", "pull_request"]);
export const DEFAULT_TARGET_REPO = "openclaw/openclaw";
export const REPOSITORY_PROFILES: readonly RepositoryProfile[] = [
{
targetRepo: DEFAULT_TARGET_REPO,
slug: "openclaw-openclaw",
displayName: "OpenClaw",
checkoutDir: "openclaw",
docsUrl: "https://docs.openclaw.ai",
communityUrl: "https://clawhub.ai/",
promptNote:
"Use the OpenClaw source tree, docs, changelog, and current main branch. Close proposals may use the normal OpenClaw stale/duplicate/not-in-repo/implemented-on-main policy when evidence is strong.",
applyCloseRules: {
issue: OPENCLAW_CLOSE_REASONS,
pull_request: OPENCLAW_CLOSE_REASONS.filter((reason) => reason !== "stale_insufficient_info"),
},
},
{
targetRepo: "openclaw/clawhub",
slug: "openclaw-clawhub",
displayName: "ClawHub",
checkoutDir: "clawhub",
communityUrl: "https://clawhub.ai/",
promptNote:
"Use the ClawHub source tree and current main branch. Review every issue and PR with the same evidence standard, but only propose auto-close for pull requests that are certainly implemented on main. Keep everything else open.",
applyCloseRules: {
issue: [],
pull_request: ["implemented_on_main"],
},
},
{
targetRepo: "openclaw/clawsweeper",
slug: "openclaw-clawsweeper",
displayName: "ClawSweeper",
checkoutDir: "clawsweeper",
promptNote:
"Use the ClawSweeper source tree and current main branch. Review bot automation, workflow, and documentation changes conservatively. Only propose auto-close for pull requests that are certainly implemented on main; keep issues open for maintainer triage.",
applyCloseRules: {
issue: [],
pull_request: ["implemented_on_main"],
},
const CORE_OPENCLAW_PROFILE: RepositoryProfile = {
targetRepo: DEFAULT_TARGET_REPO,
slug: "openclaw-openclaw",
displayName: "OpenClaw",
checkoutDir: "openclaw",
docsUrl: "https://docs.openclaw.ai",
communityUrl: "https://clawhub.ai/",
promptNote:
"Use the OpenClaw source tree, docs, changelog, and current main branch. Close proposals may use the normal OpenClaw stale/duplicate/not-in-repo/implemented-on-main policy when evidence is strong.",
applyCloseRules: {
issue: OPENCLAW_CLOSE_REASONS,
pull_request: OPENCLAW_CLOSE_REASONS.filter((reason) => reason !== "stale_insufficient_info"),
},
};
const TARGET_REPOSITORY_CONFIG = readTargetRepositoryConfig();
export const REPOSITORY_PROFILES: RepositoryProfile[] = [
CORE_OPENCLAW_PROFILE,
...TARGET_REPOSITORY_CONFIG.repositories.map(configuredRepositoryProfile),
];
export function repositoryProfileFor(targetRepo: string): RepositoryProfile {
@ -79,12 +91,14 @@ export function repositoryProfileFor(targetRepo: string): RepositoryProfile {
const profile = REPOSITORY_PROFILES.find(
(candidate) => normalizeRepo(candidate.targetRepo) === normalized,
);
if (!profile) {
throw new Error(
`Unsupported target repo: ${targetRepo}. Known repos: ${REPOSITORY_PROFILES.map((candidate) => candidate.targetRepo).join(", ")}`,
);
}
return profile;
if (profile) return profile;
const fallback = fallbackRepositoryProfile(normalized);
if (fallback) return fallback;
throw new Error(
`Unsupported target repo: ${targetRepo}. Known repos: ${REPOSITORY_PROFILES.map((candidate) => candidate.targetRepo).join(", ")}. Generic fallback: ${fallbackDescription()}`,
);
}
export function repositoryProfileForSlug(slug: string): RepositoryProfile | undefined {
@ -102,3 +116,181 @@ export function isAutoCloseAllowed(
): boolean {
return Boolean(profile.applyCloseRules[kind]?.includes(reason));
}
function configuredRepositoryProfile(profile: ConfiguredRepositoryProfile): RepositoryProfile {
const targetRepo = normalizeRepo(profile.targetRepo);
const result: RepositoryProfile = {
targetRepo,
slug: slugForRepo(targetRepo),
displayName: profile.displayName,
checkoutDir: profile.checkoutDir,
promptNote: profile.promptNote,
applyCloseRules: profile.applyCloseRules,
};
if (profile.docsUrl) result.docsUrl = profile.docsUrl;
if (profile.communityUrl) result.communityUrl = profile.communityUrl;
return result;
}
function fallbackRepositoryProfile(normalizedTargetRepo: string): RepositoryProfile | undefined {
const fallback = TARGET_REPOSITORY_CONFIG.openclawFallback;
if (!fallback) return undefined;
const [owner, repoName] = normalizedTargetRepo.split("/");
if (!owner || !repoName || owner !== fallback.owner) return undefined;
if (fallback.denyRepositories.includes(normalizedTargetRepo)) return undefined;
if (!fallback.allowRepoNamePattern.test(repoName)) return undefined;
return {
targetRepo: normalizedTargetRepo,
slug: slugForRepo(normalizedTargetRepo),
displayName: repoName,
checkoutDir: repoName,
promptNote: fallback.promptNote
.replaceAll("{target_repo}", normalizedTargetRepo)
.replaceAll("{repo_name}", repoName),
applyCloseRules: fallback.applyCloseRules,
};
}
function fallbackDescription(): string {
const fallback = TARGET_REPOSITORY_CONFIG.openclawFallback;
if (!fallback) return "disabled";
const denied =
fallback.denyRepositories.length === 0 ? "" : ` except ${fallback.denyRepositories.join(", ")}`;
return `${fallback.owner}/*${denied}`;
}
function slugForRepo(targetRepo: string): string {
return targetRepo.replace(/[^A-Za-z0-9_.-]+/g, "-");
}
function readTargetRepositoryConfig(
filePath = join(repoRoot(), "config", "target-repositories.json"),
): TargetRepositoryConfig {
if (!existsSync(filePath)) return { schemaVersion: 1, repositories: [] };
const parsed = JSON.parse(readFileSync(filePath, "utf8")) as unknown;
return validateTargetRepositoryConfig(parsed);
}
function validateTargetRepositoryConfig(value: unknown): TargetRepositoryConfig {
const config = record(value, "target repository config");
const schemaVersion = numberValue(config.schema_version, "schema_version");
if (schemaVersion !== 1)
throw new Error(`Unsupported target repository config schema: ${schemaVersion}`);
const repositories = arrayValue(config.repositories, "repositories").map((entry, index) =>
validateConfiguredRepositoryProfile(entry, `repositories[${index}]`),
);
const result: TargetRepositoryConfig = { schemaVersion: 1, repositories };
if (config.openclaw_fallback !== undefined) {
result.openclawFallback = validateOpenClawFallbackConfig(config.openclaw_fallback);
}
return result;
}
function validateConfiguredRepositoryProfile(
value: unknown,
label: string,
): ConfiguredRepositoryProfile {
const profile = record(value, label);
const result: ConfiguredRepositoryProfile = {
targetRepo: repoValue(profile.target_repo, `${label}.target_repo`),
displayName: stringValue(profile.display_name, `${label}.display_name`),
checkoutDir: pathSegmentValue(profile.checkout_dir, `${label}.checkout_dir`),
promptNote: stringValue(profile.prompt_note, `${label}.prompt_note`),
applyCloseRules: closeRulesValue(profile.apply_close_rules, `${label}.apply_close_rules`),
};
if (profile.docs_url !== undefined) {
result.docsUrl = stringValue(profile.docs_url, `${label}.docs_url`);
}
if (profile.community_url !== undefined) {
result.communityUrl = stringValue(profile.community_url, `${label}.community_url`);
}
return result;
}
function validateOpenClawFallbackConfig(value: unknown): OpenClawFallbackConfig {
const fallback = record(value, "openclaw_fallback");
const pattern = stringValue(
fallback.allow_repo_name_pattern,
"openclaw_fallback.allow_repo_name_pattern",
);
return {
owner: stringValue(fallback.owner, "openclaw_fallback.owner").toLowerCase(),
denyRepositories: arrayValue(
fallback.deny_repositories,
"openclaw_fallback.deny_repositories",
).map((entry, index) =>
normalizeRepo(repoValue(entry, `openclaw_fallback.deny_repositories[${index}]`)),
),
allowRepoNamePattern: new RegExp(pattern),
promptNote: stringValue(fallback.prompt_note, "openclaw_fallback.prompt_note"),
applyCloseRules: closeRulesValue(
fallback.apply_close_rules,
"openclaw_fallback.apply_close_rules",
),
};
}
function closeRulesValue(
value: unknown,
label: string,
): Partial<Record<RepositoryItemKind, readonly RepositoryCloseReason[]>> {
const rules = record(value, label);
const result: Partial<Record<RepositoryItemKind, RepositoryCloseReason[]>> = {};
for (const [kind, reasons] of Object.entries(rules)) {
if (!ITEM_KIND_SET.has(kind as RepositoryItemKind)) {
throw new Error(`${label}.${kind} has unsupported item kind`);
}
result[kind as RepositoryItemKind] = arrayValue(reasons, `${label}.${kind}`).map(
(reason, index) => closeReasonValue(reason, `${label}.${kind}[${index}]`),
);
}
return result;
}
function closeReasonValue(value: unknown, label: string): RepositoryCloseReason {
const reason = stringValue(value, label) as RepositoryCloseReason;
if (!CLOSE_REASON_SET.has(reason))
throw new Error(`${label} has unsupported close reason: ${reason}`);
return reason;
}
function repoValue(value: unknown, label: string): string {
const repo = normalizeRepo(stringValue(value, label));
if (!/^[a-z0-9_.-]+\/[a-z0-9_.-]+$/.test(repo)) throw new Error(`${label} must be owner/repo`);
return repo;
}
function pathSegmentValue(value: unknown, label: string): string {
const segment = stringValue(value, label);
if (!/^[A-Za-z0-9_.-]+$/.test(segment)) throw new Error(`${label} must be a safe path segment`);
return segment;
}
function stringValue(value: unknown, label: string): string {
if (typeof value !== "string" || value.trim() === "")
throw new Error(`${label} must be a string`);
return value;
}
function numberValue(value: unknown, label: string): number {
if (typeof value !== "number") throw new Error(`${label} must be a number`);
return value;
}
function arrayValue(value: unknown, label: string): unknown[] {
if (!Array.isArray(value)) throw new Error(`${label} must be an array`);
return value;
}
function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${label} must be an object`);
}
return value as Record<string, unknown>;
}
function repoRoot(): string {
return dirname(dirname(fileURLToPath(import.meta.url)));
}

View File

@ -1,7 +1,12 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
const tmpPrefix = join(tmpdir(), "clawsweeper-test-");
import {
applyDecisionPriority,
auditFromSnapshot,
@ -11,9 +16,12 @@ import {
closeReasonApplyAgeSkipReason,
closeReasonsArg,
closingPullRequestReferenceTarget,
compactMappedSlice,
codexEnv,
dashboardClosedAt,
fixedPullRequestFromCommitPullsForTest,
formatRecentClosedRows,
githubPaginatedPath,
ghRetryKind,
hotIntakeRecencyMs,
isCodexReviewCommentBody,
@ -28,6 +36,7 @@ import {
parseGhJsonLines,
parseDecision,
protectedLabels,
realBehaviorProofSufficientLabelsForTest,
relatedTitleSearchTerms,
renderReviewStartStatusComment,
reviewArtifactDestination,
@ -35,6 +44,10 @@ import {
reviewActionForDecision,
reviewPriority,
renderReviewCommentFromReport,
renderWorkPlanFromReport,
reviewDecisionSchemaText,
reviewPromptTelemetryForTest,
reviewPromptTemplate,
runtimeBudgetExceeded,
safeOutputTail,
sameAuthorCounterpartApplyReason,
@ -144,6 +157,12 @@ function closeDecision(overrides = {}) {
summary: "No patch security review is needed for this issue cleanup decision.",
concerns: [],
},
realBehaviorProof: {
status: "not_applicable",
summary: "Real behavior proof is not required for non-PR issue triage.",
evidenceKind: "not_applicable",
needsContributorAction: false,
},
overallCorrectness: "not a patch",
overallConfidenceScore: 0.75,
fixedRelease: null,
@ -162,6 +181,55 @@ function closeDecision(overrides = {}) {
};
}
test("githubPaginatedPath requests maximum REST page size by default", () => {
assert.equal(
githubPaginatedPath("repos/openclaw/openclaw/issues/123/comments"),
"repos/openclaw/openclaw/issues/123/comments?per_page=100",
);
assert.equal(
githubPaginatedPath("repos/openclaw/openclaw/issues?state=open&sort=created"),
"repos/openclaw/openclaw/issues?state=open&sort=created&per_page=100",
);
assert.equal(
githubPaginatedPath("repos/openclaw/openclaw/issues?per_page=50&state=open"),
"repos/openclaw/openclaw/issues?per_page=50&state=open",
);
});
test("compactMappedSlice maps only retained prompt entries", () => {
const mapped: number[] = [];
const result = compactMappedSlice([1, 2, 3, 4, 5, 6], 4, (value) => {
mapped.push(value);
return value * 10;
});
assert.deepEqual(result, [
10,
20,
{ omitted: 2, note: "middle entries omitted from prompt context" },
50,
60,
]);
assert.deepEqual(mapped, [1, 2, 5, 6]);
});
test("compactMappedSlice maps every entry when no compaction is needed", () => {
const mapped: number[] = [];
const result = compactMappedSlice([1, 2, 3], 3, (value) => {
mapped.push(value);
return value * 10;
});
assert.deepEqual(result, [10, 20, 30]);
assert.deepEqual(mapped, [1, 2, 3]);
});
test("review prompt assets match tracked files", () => {
assert.equal(reviewPromptTemplate(), readFileSync("prompts/review-item.md", "utf8"));
assert.deepEqual(
JSON.parse(reviewDecisionSchemaText()),
JSON.parse(readFileSync("schema/clawsweeper-decision.schema.json", "utf8")),
);
});
test("main CLI args ignore package-manager double dash separators", () => {
assert.deepEqual(parseClawsweeperArgs(["apply-decisions", "--", "--dry-run"]), {
_: ["apply-decisions"],
@ -197,6 +265,27 @@ ${Object.entries(values)
`;
}
function realBehaviorProofReportSection(overrides = {}) {
const values = {
status: "sufficient",
evidenceKind: "terminal",
needsContributorAction: false,
summary:
"The PR includes a terminal transcript from a real OpenClaw setup showing the fixed behavior after the patch.",
...overrides,
};
return `## Real Behavior Proof
Status: ${values.status}
Evidence kind: ${values.evidenceKind}
Needs contributor action: ${values.needsContributorAction}
Summary: ${values.summary}
`;
}
function auditRecord(number, overrides = {}) {
return {
repo: "openclaw/openclaw",
@ -215,6 +304,28 @@ function auditRecord(number, overrides = {}) {
};
}
test("review prompt telemetry records durable cost proxies", () => {
const context = {
issue: { number: 123, title: "Sample item" },
comments: [{ author: "contributor", body: "This still reproduces." }],
timeline: [],
counts: { comments: 1, timeline: 0 },
};
const telemetry = reviewPromptTelemetryForTest(
item({ title: "Telemetry regression" }),
context,
git,
"keep extra instructions visible",
);
assert.ok(telemetry.staticPromptChars > 1000);
assert.ok(telemetry.schemaChars > 1000);
assert.ok(telemetry.contextChars >= JSON.stringify(context, null, 2).length);
assert.ok(telemetry.promptChars > telemetry.staticPromptChars + telemetry.contextChars);
assert.equal(telemetry.additionalPromptChars, "keep extra instructions visible".length);
});
test("protected labels are normalized and excluded from normal planning", () => {
assert.deepEqual(protectedLabels(["Security", "bug", "maintainer", "SECURITY"]), [
"security",
@ -371,6 +482,136 @@ test("review actions only propose valid closes and never apply directly", () =>
assert.match(action.closeComment, /Codex review notes: model gpt-5\.5, reasoning high;/);
});
test("close comments reference high-confidence merged fixing PRs", () => {
const action = reviewActionForDecision({
item: item(),
decision: closeDecision({
fixedPullRequest: {
repo: "openclaw/openclaw",
number: 456,
url: "https://github.com/openclaw/openclaw/pull/456",
title: "fix: wire the shell check",
mergedAt: "2026-04-28T12:00:00Z",
sha: "fedcba9876543210",
confidence: "high",
source: "GitHub closing PR reference",
},
}),
git,
runtime: { model: "gpt-5.5", reasoningEffort: "high" },
});
assert.equal(action.actionTaken, "proposed_close");
assert.match(
action.closeComment,
/merged PR that appears to have closed this: \[#456: fix: wire the shell check\]\(https:\/\/github\.com\/openclaw\/openclaw\/pull\/456\)/,
);
assert.match(
action.closeComment,
/fix evidence: merged PR \[#456\]\(https:\/\/github\.com\/openclaw\/openclaw\/pull\/456\), commit/,
);
});
test("commit PR lookup selects the newest merged pull request", () => {
const fixedPullRequest = fixedPullRequestFromCommitPullsForTest([
{
number: 455,
html_url: "https://github.com/openclaw/openclaw/pull/455",
title: "fix: older candidate",
merged: true,
merged_at: "2026-04-27T12:00:00Z",
merge_commit_sha: "1111111111111111",
},
{
number: 456,
html_url: "https://github.com/openclaw/openclaw/pull/456",
title: "fix: wire the shell check",
merged_at: "2026-04-28T12:00:00Z",
merge_commit_sha: "fedcba9876543210",
},
{
number: 457,
html_url: "https://github.com/openclaw/openclaw/pull/457",
title: "open follow-up",
merged: false,
},
]);
assert.deepEqual(fixedPullRequest, {
repo: "openclaw/openclaw",
number: 456,
url: "https://github.com/openclaw/openclaw/pull/456",
title: "fix: wire the shell check",
mergedAt: "2026-04-28T12:00:00Z",
sha: "fedcba9876543210",
confidence: "high",
source: "GitHub commit PR lookup",
});
});
test("report-rendered close comments keep merged fixing PR provenance", () => {
const comment = renderReviewCommentFromReport(
`${reportFrontMatter({
type: "issue",
number: "123",
title: JSON.stringify("Sample item"),
decision: "close",
close_reason: "implemented_on_main",
action_taken: "proposed_close",
fixed_pr_url: "https://github.com/openclaw/openclaw/pull/456",
fixed_pr_number: "456",
fixed_pr_title: JSON.stringify("fix: wire the shell check"),
fixed_pr_merged_at: "2026-04-28T12:00:00Z",
fixed_pr_sha: "fedcba9876543210",
fixed_pr_confidence: "high",
fixed_pr_source: JSON.stringify("GitHub closing PR reference"),
fixed_sha: "abcdef1234567890",
fixed_at: "2026-04-28T12:00:00Z",
main_sha: "abcdef1234567890",
review_model: "gpt-5.5",
review_reasoning_effort: "high",
})}
## Summary
Current main already implements this.
## Best Possible Solution
Keep the implementation as-is.
## Reproduction Assessment
Yes. Current main can be checked by inspecting source and history.
## Solution Assessment
Yes. Keeping the implementation as-is is the narrowest maintainable outcome.
## Evidence
- **implementation:** The feature is present in source.
- file: [src/example.ts:12](https://github.com/openclaw/openclaw/blob/abcdef1234567890/src/example.ts#L12)
- sha: [abcdef1234567890](https://github.com/openclaw/openclaw/commit/abcdef1234567890)
## Likely Owners
- **@alice:** introduced behavior
- reason: git blame points at the fix.
- confidence: high
- commits: abcdef1234567890
- files: src/example.ts
`,
"implemented_on_main",
);
assert.match(
comment,
/merged PR that appears to have closed this: \[#456: fix: wire the shell check\]\(https:\/\/github\.com\/openclaw\/openclaw\/pull\/456\)/,
);
assert.match(comment, /fix evidence: merged PR \[#456\]/);
});
test("close comments suppress duplicate best solution text", () => {
const action = reviewActionForDecision({
item: item(),
@ -385,6 +626,49 @@ test("close comments suppress duplicate best solution text", () => {
assert.doesNotMatch(action.closeComment, /Best possible solution:/);
});
test("skill-only OpenClaw PRs can close through ClawHub with upload guidance", () => {
const decision = closeDecision({
closeReason: "clawhub",
summary:
"The branch adds an optional bundled skill and does not change required core behavior.",
changeSummary: "Adds bundled Higgsfield skill files under skills/higgsfield.",
bestSolution:
"Publish the skill through ClawHub so it stays installable outside OpenClaw core.",
itemCategory: "skill",
reproductionStatus: "not_applicable",
reproductionConfidence: "high",
securityReview: {
status: "cleared",
summary:
"The PR is a skill-only content addition and should move to the community skill path.",
concerns: [],
},
realBehaviorProof: {
status: "not_applicable",
summary: "Real behavior proof is not needed for a scope-fit close.",
evidenceKind: "not_applicable",
needsContributorAction: false,
},
});
const pr = item({
kind: "pull_request",
url: "https://github.com/openclaw/openclaw/pull/78018",
});
assert.equal(validateCloseDecision(pr, decision).ok, true);
const action = reviewActionForDecision({
item: pr,
decision,
git,
});
assert.equal(action.actionTaken, "proposed_close");
assert.match(action.closeComment, /ClawHub\.com/);
assert.match(action.closeComment, /upload or publish/i);
assert.match(action.closeComment, /installable community skill/);
});
test("ClawHub policy only allows implemented-on-main PR close proposals", () => {
const implementedPr = validateCloseDecision(
item({
@ -1551,6 +1835,217 @@ Full review comments:
assert.doesNotMatch(comment, /clawsweeper-verdict:needs-human/);
});
test("sufficient real behavior proof allows automerge pass markers", () => {
const report = `${reportFrontMatter({
type: "pull_request",
number: "74459",
decision: "keep_open",
close_reason: "none",
review_status: "complete",
confidence: "high",
author: "contributor",
author_association: "CONTRIBUTOR",
labels: JSON.stringify(["clawsweeper:automerge"]),
work_candidate: "none",
pull_head_sha: "abc123def456",
})}
## Summary
Keep this focused PR open for automerge.
## What This Changes
Fixes the gateway status output.
## Best Possible Solution
Merge after required checks are green.
${realBehaviorProofReportSection()}
## Review Findings
Overall correctness: patch is correct
Overall confidence: 0.9
Full review comments:
- none
`;
const comment = renderReviewCommentFromReport(report, "none");
const markers = reviewAutomationMarkersFromReport(report);
assert.match(comment, /\*\*Real behavior proof\*\*\nSufficient \(terminal\):/);
assert.match(markers, /clawsweeper-verdict:pass/);
assert.doesNotMatch(markers, /clawsweeper-verdict:needs-human/);
});
test("screenshot-only browser runtime proof blocks pass markers", () => {
const report = `${reportFrontMatter({
type: "pull_request",
number: "74460",
decision: "keep_open",
close_reason: "none",
review_status: "complete",
confidence: "high",
author: "contributor",
author_association: "CONTRIBUTOR",
labels: JSON.stringify(["clawsweeper:automerge"]),
work_candidate: "none",
pull_head_sha: "abc123def456",
})}
## Summary
Keep this focused PR open for automerge.
## What This Changes
Adds tweakcn.com to the Control UI connect-src directive.
## Best Possible Solution
Ask the contributor to add browser runtime proof from their real setup.
${realBehaviorProofReportSection({
status: "sufficient",
evidenceKind: "screenshot",
needsContributorAction: false,
summary:
"The inspected screenshot shows an after-fix Control UI import success state for a tweakcn theme, with no visible console CSP violation.",
})}
## Review Findings
Overall correctness: patch is correct
Overall confidence: 0.9
Full review comments:
- none
`;
const comment = renderReviewCommentFromReport(report, "none");
const markers = reviewAutomationMarkersFromReport(report);
assert.match(comment, /Codex review: needs real behavior proof before merge\./);
assert.match(comment, /Needs stronger real behavior proof before merge:/);
assert.match(comment, /not enough for browser runtime or security behavior/);
assert.match(comment, /console, network, terminal, live output, or logs/);
assert.match(markers, /clawsweeper-verdict:needs-human/);
assert.doesNotMatch(markers, /clawsweeper-verdict:pass/);
assert.doesNotMatch(markers, /proof: sufficient/);
});
test("missing real behavior proof blocks pass and repair markers", () => {
const report = `${reportFrontMatter({
type: "pull_request",
number: "74460",
decision: "keep_open",
close_reason: "none",
review_status: "complete",
confidence: "high",
author: "contributor",
author_association: "CONTRIBUTOR",
labels: JSON.stringify(["clawsweeper:automerge"]),
work_candidate: "queue_fix_pr",
pull_head_sha: "abc123def456",
})}
## Summary
Keep this PR open until the contributor proves the fix in a real setup.
## What This Changes
Fixes the gateway status output.
## Best Possible Solution
Ask the contributor to add after-fix proof from their real setup.
${realBehaviorProofReportSection({
status: "missing",
evidenceKind: "none",
needsContributorAction: true,
summary:
"The PR body does not include after-fix evidence from a real setup; terminal screenshots, console output, copied live output, linked artifacts, recordings, and redacted logs count.",
})}
## Review Findings
Overall correctness: patch is correct
Overall confidence: 0.9
Full review comments:
- none
`;
const comment = renderReviewCommentFromReport(report, "none");
const markers = reviewAutomationMarkersFromReport(report);
assert.match(comment, /Codex review: needs real behavior proof before merge\./);
assert.match(comment, /\*\*Real behavior proof\*\*/);
assert.match(comment, /terminal screenshots, console output, copied live output/);
assert.match(comment, /update the PR body; ClawSweeper should re-review automatically/);
assert.match(comment, /@clawsweeper re-review/);
assert.match(markers, /clawsweeper-verdict:needs-human/);
assert.doesNotMatch(markers, /clawsweeper-verdict:pass/);
assert.doesNotMatch(markers, /clawsweeper-action:fix-required/);
});
test("mock-only real behavior proof blocks repair markers", () => {
const report = `${reportFrontMatter({
type: "pull_request",
number: "74461",
decision: "keep_open",
close_reason: "none",
confidence: "high",
author: "contributor",
author_association: "CONTRIBUTOR",
labels: JSON.stringify(["clawsweeper:autofix"]),
work_candidate: "queue_fix_pr",
pull_head_sha: "abc123def456",
})}
## Summary
Keep this PR open until proof covers real behavior.
${realBehaviorProofReportSection({
status: "mock_only",
evidenceKind: "none",
needsContributorAction: true,
summary:
"The PR only cites unit tests and CI; the contributor needs a terminal screenshot, console output, copied live output, recording, linked artifact, or redacted runtime log from a real setup.",
})}
## Review Findings
Overall correctness: patch is incorrect
Overall confidence: 0.9
Full review comments:
- **[P3] Add a changelog entry:** \`CHANGELOG.md:12\`
- body: The PR changes user-visible behavior and needs a changelog entry.
- confidence: 0.8
`;
const markers = reviewAutomationMarkersFromReport(report);
assert.match(markers, /clawsweeper-verdict:needs-human/);
assert.doesNotMatch(markers, /clawsweeper-action:fix-required/);
assert.doesNotMatch(markers, /clawsweeper-verdict:needs-changes/);
});
test("pull request automerge pass is not blocked by generic protected labels", () => {
const comment = renderReviewCommentFromReport(
`${reportFrontMatter({
@ -1722,6 +2217,206 @@ Full review comments:
assert.doesNotMatch(markers, /clawsweeper-verdict:needs-human/);
});
function workPlanCandidateReport(overrides = {}) {
const frontmatter = {
number: 321,
repository: "openclaw/clawsweeper",
type: "issue",
title: "Render work plans",
reviewed_at: new Date().toISOString(),
review_status: "complete",
local_checkout_access: "verified",
decision: "keep_open",
action_taken: "kept_open",
work_candidate: "queue_fix_pr",
work_status: "candidate",
work_priority: "medium",
work_confidence: "high",
work_likely_files: JSON.stringify(["src/clawsweeper.ts", "test/clawsweeper.test.ts"]),
work_validation: JSON.stringify(["pnpm run check"]),
work_cluster_refs: JSON.stringify(["openclaw/clawsweeper#26"]),
...overrides,
};
return `---
${Object.entries(frontmatter)
.map(([key, value]) => `${key}: ${value}`)
.join("\n")}
---
# #321: Render work plans
## Summary
The dashboard has queue_fix_pr candidates but no generated coding plan.
## Repair Work Prompt
Render generated plan markdown from existing report fields.
`;
}
test("renderWorkPlanFromReport renders dashboard plan artifacts for fresh queue_fix_pr candidates", () => {
const plan = renderWorkPlanFromReport(workPlanCandidateReport(), {
reportPath: "records/openclaw-clawsweeper/items/321.md",
});
assert.ok(plan);
assert.match(plan, /# Coding Plan for openclaw\/clawsweeper#321: Render work plans/);
assert.match(plan, /Render generated plan markdown from existing report fields\./);
assert.match(plan, /- `src\/clawsweeper\.ts`/);
assert.match(plan, /- `pnpm run check`/);
assert.match(plan, /openclaw\/clawsweeper#26/);
});
test("renderWorkPlanFromReport returns null for stale, reclassified, or non-candidate reports", () => {
assert.equal(renderWorkPlanFromReport(workPlanCandidateReport({ work_candidate: "none" })), null);
assert.equal(
renderWorkPlanFromReport(workPlanCandidateReport({ work_status: "manual_review" })),
null,
);
assert.equal(renderWorkPlanFromReport(workPlanCandidateReport({ action_taken: "closed" })), null);
assert.equal(
renderWorkPlanFromReport(workPlanCandidateReport({ reviewed_at: "2026-01-01T00:00:00.000Z" })),
null,
);
});
test("apply-artifacts writes and removes generated work plans", () => {
const root = mkdtempSync(tmpPrefix);
try {
const artifactDir = join(root, "artifacts");
const itemsDir = join(root, "items");
const closedDir = join(root, "closed");
const plansDir = join(root, "plans");
mkdirSync(artifactDir, { recursive: true });
writeFileSync(join(artifactDir, "321.md"), workPlanCandidateReport(), "utf8");
execFileSync(process.execPath, [
"dist/clawsweeper.js",
"apply-artifacts",
"--target-repo",
"openclaw/clawsweeper",
"--artifact-dir",
artifactDir,
"--items-dir",
itemsDir,
"--closed-dir",
closedDir,
"--plans-dir",
plansDir,
"--replay-closed-artifacts",
"--skip-reconcile",
]);
const planPath = join(plansDir, "321.md");
assert.ok(existsSync(planPath));
assert.match(readFileSync(planPath, "utf8"), /## Plan\n\nRender generated plan markdown/);
writeFileSync(
join(artifactDir, "321.md"),
workPlanCandidateReport({ work_candidate: "none", work_status: "none" }),
"utf8",
);
execFileSync(process.execPath, [
"dist/clawsweeper.js",
"apply-artifacts",
"--target-repo",
"openclaw/clawsweeper",
"--artifact-dir",
artifactDir,
"--items-dir",
itemsDir,
"--closed-dir",
closedDir,
"--plans-dir",
plansDir,
"--replay-closed-artifacts",
"--skip-reconcile",
]);
assert.equal(existsSync(planPath), false);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("apply-decisions removes archived work plans from the scoped plans directory", () => {
const root = mkdtempSync(tmpPrefix);
const originalPath = process.env.PATH;
const defaultPlanDir = join(process.cwd(), "records", "openclaw-clawsweeper", "plans");
const defaultPlanPath = join(defaultPlanDir, "321.md");
try {
const binDir = join(root, "bin");
const itemsDir = join(root, "items");
const closedDir = join(root, "closed");
const plansDir = join(root, "plans");
mkdirSync(binDir, { recursive: true });
mkdirSync(itemsDir, { recursive: true });
mkdirSync(plansDir, { recursive: true });
mkdirSync(defaultPlanDir, { recursive: true });
writeFileSync(
join(binDir, "gh"),
`#!/usr/bin/env node
const args = process.argv.slice(2).join(" ");
if (args.includes("/comments")) {
console.log(JSON.stringify([[]]));
} else {
console.log(JSON.stringify({
number: 321,
title: "Render work plans",
html_url: "https://github.com/openclaw/clawsweeper/issues/321",
created_at: "2026-05-01T00:00:00Z",
updated_at: "2026-05-01T00:00:00Z",
closed_at: "2026-05-02T00:00:00Z",
state: "closed",
locked: false,
active_lock_reason: null,
author_association: "CONTRIBUTOR",
user: { login: "reporter" },
labels: [],
pull_request: null
}));
}
`,
{ mode: 0o755 },
);
writeFileSync(
join(itemsDir, "321.md"),
workPlanCandidateReport({
item_snapshot_hash: "reviewed-snapshot",
item_updated_at: "2026-05-01T00:00:00Z",
}),
"utf8",
);
writeFileSync(join(plansDir, "321.md"), "scoped generated plan\n", "utf8");
writeFileSync(defaultPlanPath, "default generated plan\n", "utf8");
process.env.PATH = `${binDir}:${originalPath ?? ""}`;
execFileSync(process.execPath, [
"dist/clawsweeper.js",
"apply-decisions",
"--target-repo",
"openclaw/clawsweeper",
"--items-dir",
itemsDir,
"--closed-dir",
closedDir,
"--plans-dir",
plansDir,
"--limit",
"1",
"--processed-limit",
"1",
"--close-delay-ms",
"0",
]);
assert.equal(existsSync(join(plansDir, "321.md")), false);
assert.ok(existsSync(defaultPlanPath));
assert.ok(existsSync(join(closedDir, "321.md")));
} finally {
process.env.PATH = originalPath;
rmSync(root, { recursive: true, force: true });
rmSync(defaultPlanPath, { force: true });
}
});
test("security-needs-attention reports block unopted repair and automerge pass markers", () => {
const securitySection = `
## Security Review
@ -2014,6 +2709,7 @@ test("runtime budget only trips after a positive elapsed limit", () => {
test("decision parser enforces required schema-shaped evidence", () => {
assert.equal(parseDecision(closeDecision()).decision, "close");
assert.equal(parseDecision(closeDecision({ itemCategory: "skill" })).itemCategory, "skill");
assert.throws(
() =>
parseDecision({
@ -2067,6 +2763,11 @@ test("decision parser enforces required schema-shaped evidence", () => {
delete decision.securityReview;
return parseDecision(decision);
}, /decision\.securityReview/);
assert.throws(() => {
const decision = closeDecision();
delete decision.realBehaviorProof;
return parseDecision(decision);
}, /decision\.realBehaviorProof/);
const workCandidate = parseDecision(
closeDecision({
decision: "keep_open",
@ -2085,6 +2786,7 @@ test("decision parser enforces required schema-shaped evidence", () => {
assert.equal(workCandidate.workCandidate, "queue_fix_pr");
assert.equal(workCandidate.itemCategory, "bug");
assert.equal(workCandidate.reproductionStatus, "reproduced");
assert.equal(workCandidate.realBehaviorProof.status, "not_applicable");
assert.deepEqual(workCandidate.workClusterRefs, ["#123", "#456"]);
});
@ -2108,6 +2810,73 @@ test("review prompt requires a dedicated securityReview section", () => {
assert.match(prompt, /status: "needs_attention"/);
});
test("review prompt requires real behavior proof for PR reviews", () => {
const prompt = readFileSync("prompts/review-item.md", "utf8");
assert.match(prompt, /realBehaviorProof/);
assert.match(prompt, /Terminal screenshots|terminal screenshots/);
assert.match(prompt, /download\/open GitHub attachment links/);
assert.match(prompt, /generate stills or contact sheets from videos/);
assert.match(prompt, /compare the proof against the PR diff/);
assert.match(prompt, /Prefer asking for screenshots or videos/);
assert.match(prompt, /redact private information like IP addresses, API keys/);
assert.match(prompt, /screenshot-only proof sufficient/);
assert.match(prompt, /no visible console violation/);
assert.match(prompt, /scratch directory/);
assert.match(prompt, /@clawsweeper re-review/);
assert.match(
prompt,
/Unit tests, mocks, snapshots, lint, typechecks, and CI are supplemental only/,
);
assert.match(prompt, /do not request ClawSweeper repair markers/);
});
test("ClawSweeper proof judgement controls the sufficient proof label", () => {
assert.deepEqual(realBehaviorProofSufficientLabelsForTest(["proof: supplied"], "sufficient"), [
"proof: supplied",
"proof: sufficient",
]);
assert.deepEqual(
realBehaviorProofSufficientLabelsForTest(
["proof: supplied", "proof: sufficient"],
"insufficient",
),
["proof: supplied"],
);
assert.deepEqual(realBehaviorProofSufficientLabelsForTest(["proof: sufficient"], "missing"), []);
});
test("review workflow gives Codex a read-only inspection token", () => {
const workflow = readFileSync(".github/workflows/sweep.yml", "utf8");
assert.match(workflow, /id: codex-inspection-token/);
assert.match(workflow, /permission-issues: read/);
assert.match(workflow, /CLAWSWEEPER_PROOF_INSPECTION_TOKEN/);
});
test("manual exact-item review dispatches avoid broad review concurrency", () => {
const workflow = readFileSync(".github/workflows/sweep.yml", "utf8");
assert.match(
workflow,
/github\.event_name == 'workflow_dispatch' && \(github\.event\.inputs\.item_number != '' \|\| github\.event\.inputs\.item_numbers != ''\)\) && format\('clawsweeper-intake-exact-\{0\}'/,
);
assert.doesNotMatch(
workflow,
/github\.event_name == 'workflow_dispatch' && github\.event\.inputs\.hot_intake == 'true' && \(github\.event\.inputs\.item_number != '' \|\| github\.event\.inputs\.item_numbers != ''\)\) && format\('clawsweeper-intake-exact-\{0\}'/,
);
});
test("sweep workflow publishes target-scoped state paths", () => {
const workflow = readFileSync(".github/workflows/sweep.yml", "utf8");
assert.match(workflow, /target_slug="\$TARGET_REPO"/);
assert.match(workflow, /--path "records\/\$\{target_slug\}"/);
assert.match(workflow, /--path "results\/sweep-status\/\$\{target_slug\}\.json"/);
assert.doesNotMatch(workflow, /--path records\s*\\/);
assert.doesNotMatch(workflow, /--path results\/sweep-status\s*\\/);
});
test("review prompt asks for concise public review fields", () => {
const prompt = readFileSync("prompts/review-item.md", "utf8");
@ -2137,8 +2906,13 @@ test("review prompts require reproduction and solution assessment details", () =
assert.match(itemPrompt, /Always fill `reproductionAssessment`/);
assert.match(itemPrompt, /itemCategory: "bug"/);
assert.match(itemPrompt, /itemCategory: "skill"/);
assert.match(itemPrompt, /skills\/<vendor>/);
assert.match(itemPrompt, /upload or publish it through ClawHub\.com/);
assert.match(itemPrompt, /requiresNewConfigOption/);
assert.match(itemPrompt, /automatic\s+bug-fix PR creation/);
assert.match(itemPrompt, /For every other issue or PR reference,\s+use the full GitHub URL/);
assert.doesNotMatch(itemPrompt, /normal `#123` links/);
assert.match(itemPrompt, /Always fill `solutionAssessment`/);
assert.match(itemPrompt, /Do we have a high-confidence way to reproduce the\s+issue\?/);
assert.match(itemPrompt, /Is this the best way to solve the issue\?/);
@ -2186,6 +2960,17 @@ test("sweep review recovery uses explicit failed shard artifacts", () => {
);
});
test("sweep dashboard status writes are scoped to the target repository", () => {
const workflow = readFileSync(".github/workflows/sweep.yml", "utf8");
const statusCalls = [...workflow.matchAll(new RegExp("pnpm run status -- \\\\", "g"))];
assert.ok(statusCalls.length > 0);
for (const match of statusCalls) {
const block = workflow.slice(match.index, match.index + 220);
assert.match(block, /--target-repo /);
}
});
test("review parser strips environment access caveats from risks", () => {
const parsed = parseDecision(
closeDecision({
@ -2204,6 +2989,7 @@ test("codex subprocess env strips GitHub and App credentials", () => {
process.env.GH_TOKEN = "gh";
process.env.GITHUB_TOKEN = "github";
process.env.COMMIT_SWEEPER_TARGET_GH_TOKEN = "target";
process.env.CLAWSWEEPER_PROOF_INSPECTION_TOKEN = "codex-target";
process.env.CLAWSWEEPER_APP_ID = "123";
process.env.CLAWSWEEPER_APP_PRIVATE_KEY = "private";
process.env.OPENAI_API_KEY = "openai";
@ -2214,6 +3000,7 @@ test("codex subprocess env strips GitHub and App credentials", () => {
assert.equal(env.GH_TOKEN, undefined);
assert.equal(env.GITHUB_TOKEN, undefined);
assert.equal(env.COMMIT_SWEEPER_TARGET_GH_TOKEN, undefined);
assert.equal(env.CLAWSWEEPER_PROOF_INSPECTION_TOKEN, undefined);
assert.equal(env.CLAWSWEEPER_APP_ID, undefined);
assert.equal(env.CLAWSWEEPER_APP_PRIVATE_KEY, undefined);
assert.equal(env.OPENAI_API_KEY, undefined);
@ -2230,12 +3017,14 @@ test("codex subprocess env can expose an explicit read-only GitHub token", () =>
process.env.GH_TOKEN = "ambient";
process.env.GITHUB_TOKEN = "github";
process.env.COMMIT_SWEEPER_TARGET_GH_TOKEN = "hidden";
process.env.CLAWSWEEPER_PROOF_INSPECTION_TOKEN = "hidden-codex";
const env = codexEnv({ ghToken: "target-read" });
assert.equal(env.GH_TOKEN, "target-read");
assert.equal(env.GITHUB_TOKEN, undefined);
assert.equal(env.COMMIT_SWEEPER_TARGET_GH_TOKEN, undefined);
assert.equal(env.CLAWSWEEPER_PROOF_INSPECTION_TOKEN, undefined);
assert.equal(env.GIT_OPTIONAL_LOCKS, "0");
} finally {
process.env = originalEnv;

View File

@ -1,6 +1,9 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isRetryableCodexTransportError } from "../../dist/repair/codex-transient.js";
import {
isCodexContextLimitError,
isRetryableCodexTransportError,
} from "../../dist/repair/codex-transient.js";
test("Codex closed-stdin tool transport errors are retryable", () => {
assert.equal(
@ -18,3 +21,19 @@ test("ordinary Codex failures are not classified as transient transport", () =>
false,
);
});
test("Codex TPM rate-limit errors are retryable transport failures", () => {
const message =
"stream disconnected before completion: Rate limit reached for gpt-5.5 on tokens per min (TPM): Limit 40000000, Used 40000000, Requested 126092. Please try again in 189ms.";
assert.equal(isRetryableCodexTransportError(message), true);
assert.equal(isCodexContextLimitError(message), false);
});
test("Codex context-limit errors are blocked automation outcomes", () => {
assert.equal(
isCodexContextLimitError("Error: Requested 142470. Please try again with a smaller input."),
true,
);
assert.equal(isCodexContextLimitError("maximum context length exceeded"), true);
assert.equal(isCodexContextLimitError("validation command failed: pnpm check:changed"), false);
});

View File

@ -21,6 +21,7 @@ import {
commandResponseMarker,
commandResponseMarkerPrefix,
commandStatusMarkerPrefix,
createCachedLabelNumberLookup,
existingCommandStatusBlocksReplay,
existingModeStatusBlocksReplay,
hasCommandResponseMarker,
@ -29,6 +30,7 @@ import {
issueImplementationJobPath,
isMaintainerCommandAllowed,
parseCommand,
pausedModeStatusBlocksReplay,
parseTrustedAutomation,
repairableCheckBlockers,
repairLoopStopPauseReason,
@ -186,6 +188,23 @@ test("parseCommand recognizes maintainer slash commands", () => {
});
});
test("cached label number lookup fetches each label once and returns stable copies", () => {
const calls: string[] = [];
const lookup = createCachedLabelNumberLookup((label) => {
calls.push(label);
return label === "clawsweeper:autofix" ? ["10", 10, 0, "bad", 11, 10] : [20];
});
const first = lookup("clawsweeper:autofix");
first.push(99);
assert.deepEqual(first, [10, 11, 99]);
assert.deepEqual(lookup("clawsweeper:autofix"), [10, 11]);
assert.deepEqual(lookup("clawsweeper:automerge"), [20]);
assert.deepEqual(lookup("clawsweeper:autofix"), [10, 11]);
assert.deepEqual(calls, ["clawsweeper:autofix", "clawsweeper:automerge"]);
});
test("autoclose reason parser preserves maintainer wording", () => {
assert.equal(
autocloseReasonFromCommand("autoclose We don't want this feature"),
@ -229,6 +248,22 @@ test("force reprocess bypasses existing command status guards", () => {
};
assert.equal(existingModeStatusBlocksReplay({ ...existingEnabled, forceReprocess: false }), true);
assert.equal(existingModeStatusBlocksReplay({ ...existingEnabled, forceReprocess: true }), false);
assert.equal(
pausedModeStatusBlocksReplay({
hasPauseLabels: true,
hasExistingModeStatusResponse: true,
forceReprocess: false,
}),
true,
);
assert.equal(
pausedModeStatusBlocksReplay({
hasPauseLabels: true,
hasExistingModeStatusResponse: true,
forceReprocess: true,
}),
false,
);
});
test("automerge status marker prefix is stable across head changes", () => {
@ -668,6 +703,38 @@ test("parseTrustedAutomation treats trusted ClawSweeper needs-human as a pause",
assert.match(parsed.repair_reason, /needs-human/);
});
test("parseTrustedAutomation explains security-sensitive human-review pauses", () => {
const trustedAuthors = new Set(["clawsweeper[bot]"]);
const parsed = parseTrustedAutomation(
{
user: { login: "clawsweeper[bot]" },
body: [
"Codex review: found issues before merge.",
"",
"**Next step before merge**",
"Automerge should pause for maintainer/security handling of the remaining sudo -k carrier bypass.",
"",
"**Security**",
"Needs attention: Needs attention: the PR still leaves a sudo reset-timestamp carrier form unwrapped.",
"",
"**Review findings**",
"- [P1] Treat sudo -k as a command-carrying option — `src/infra/command-carriers.ts:74-83`",
"",
"<!-- clawsweeper-security:security-sensitive item=76672 sha=abc123 confidence=high -->",
"<!-- clawsweeper-verdict:needs-human item=76672 sha=abc123 confidence=high -->",
].join("\n"),
},
{ trustedAuthors },
);
assert.equal(parsed.intent, "clawsweeper_needs_human");
assert.equal(parsed.expected_head_sha, "abc123");
assert.match(parsed.repair_reason, /sudo -k carrier bypass/);
assert.match(parsed.repair_reason, /sudo reset-timestamp carrier form/);
assert.match(parsed.repair_reason, /\[P1\] Treat sudo -k/);
assert.match(parsed.repair_reason, /sha=abc123/);
});
test("parseTrustedAutomation repairs needs-human verdicts with concrete P findings", () => {
const trustedAuthors = new Set(["clawsweeper[bot]"]);
const parsed = parseTrustedAutomation(
@ -1122,6 +1189,8 @@ test("renderResponse reports maintainer re-review dispatches", () => {
assert.match(body, /re-review requested/);
assert.match(body, /review this item again/);
assert.match(body, /Action: item re-review queued/);
assert.match(body, /existing ClawSweeper review comment will be edited in place/);
assert.match(body, /clawsweeper-command-status:74107:re_review:def461/);
assert.doesNotMatch(body, /repair worker/);
});

View File

@ -0,0 +1,19 @@
import assert from "node:assert/strict";
import test from "node:test";
import { githubPaginatedPath } from "../../dist/repair/github-cli.js";
test("githubPaginatedPath requests maximum REST page size by default", () => {
assert.equal(
githubPaginatedPath("repos/openclaw/openclaw/issues/123/comments"),
"repos/openclaw/openclaw/issues/123/comments?per_page=100",
);
assert.equal(
githubPaginatedPath("repos/openclaw/openclaw/issues?state=open&sort=created"),
"repos/openclaw/openclaw/issues?state=open&sort=created&per_page=100",
);
assert.equal(
githubPaginatedPath("repos/openclaw/openclaw/issues?per_page=50&state=open"),
"repos/openclaw/openclaw/issues?per_page=50&state=open",
);
});

View File

@ -95,3 +95,18 @@ test("issue implementation PR executor applies autogenerated label", () => {
assert.match(source, /AUTOGENERATED_LABEL/);
assert.match(source, /job\.frontmatter\.source === "issue_implementation"/);
});
test("repair executor uses retryable blobless target checkout", () => {
const source = readFileSync("src/repair/execute-fix-artifact.ts", "utf8");
assert.match(source, /cloneTargetCheckout/);
assert.match(source, /--filter=blob:none/);
assert.match(source, /CLAWSWEEPER_CHECKOUT_CLONE_ATTEMPTS/);
assert.match(source, /CLAWSWEEPER_CHECKOUT_CLONE_TIMEOUT_MS/);
});
test("comment router default allows one same-head infrastructure retry", () => {
const source = readFileSync("src/repair/config.ts", "utf8");
assert.match(source, /CLAWSWEEPER_MAX_REPAIRS_PER_HEAD \?\? 2/);
});

View File

@ -3,6 +3,8 @@ import test from "node:test";
import {
MAX_LIVE_WORKERS,
listActiveWorkflowRuns,
normalizeWorkflowRun,
readMaxLiveWorkers,
repairRunNameForJob,
repairRunNamePrefixForJob,
@ -46,3 +48,68 @@ test("repair run names match workflow dispatch titles", () => {
"automerge repair jobs/openclaw/inbox/automerge-openclaw-openclaw-75363.md",
);
});
test("workflow run normalization prefers the human Actions URL", () => {
const run = normalizeWorkflowRun(
{
id: 123,
status: "queued",
url: "https://api.github.com/repos/openclaw/clawsweeper/actions/runs/123",
html_url: "https://github.com/openclaw/clawsweeper/actions/runs/123",
display_title: "automerge repair jobs/openclaw/inbox/a.md",
},
"queued",
);
assert.equal(run.url, "https://github.com/openclaw/clawsweeper/actions/runs/123");
});
test("active workflow runs are filtered from one recent-runs fetch", () => {
const calls = [];
const runs = listActiveWorkflowRuns({
repo: "openclaw/clawsweeper",
workflow: "repair-cluster.yml",
runNamePrefix: "repair cluster ",
excludeRunNamePrefix: "repair cluster skip",
fetchWorkflowRuns: ({ repo, workflow }) => {
calls.push({ repo, workflow });
return [
{
id: 1,
status: "completed",
display_title: "repair cluster completed",
created_at: "2026-05-05T00:04:00.000Z",
},
{
id: 2,
status: "queued",
display_title: "repair cluster older.md",
created_at: "2026-05-05T00:01:00.000Z",
},
{
id: 3,
status: "in_progress",
display_title: "repair cluster newer.md",
created_at: "2026-05-05T00:03:00.000Z",
},
{
id: 4,
status: "waiting",
display_title: "repair cluster skip this.md",
created_at: "2026-05-05T00:05:00.000Z",
},
{
id: 5,
status: "requested",
display_title: "automerge repair jobs/openclaw/inbox/pr.md",
created_at: "2026-05-05T00:02:00.000Z",
},
];
},
});
assert.deepEqual(calls, [{ repo: "openclaw/clawsweeper", workflow: "repair-cluster.yml" }]);
assert.deepEqual(
runs.map((run) => run.databaseId),
[3, 2],
);
});

View File

@ -2,7 +2,9 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
isRepairBranchPushBlocked,
isRepairBranchPushRace,
repairBranchPushBlockedReason,
repairBranchPushRaceReason,
} from "../../dist/repair/repair-branch-push-errors.js";
@ -23,3 +25,17 @@ test("does not classify unrelated validation failures as push races", () => {
assert.equal(isRepairBranchPushRace(error), false);
assert.equal(repairBranchPushRaceReason(error), null);
});
test("detects GitHub App workflow permission push denials", () => {
const error = new Error(
"To https://github.com/openclaw/openclaw.git\n" +
" ! [remote rejected] HEAD -> clawsweeper/automerge-openclaw-openclaw-74905 " +
"(refusing to allow a GitHub App to create or update workflow " +
"`.github/workflows/openclaw-live-and-e2e-checks-reusable.yml` without `workflows` permission)\n" +
"error: failed to push some refs to 'https://github.com/openclaw/openclaw.git'",
);
assert.equal(isRepairBranchPushBlocked(error), true);
assert.match(repairBranchPushBlockedReason(error) ?? "", /workflows permission/);
assert.equal(isRepairBranchPushRace(error), false);
});

View File

@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
@ -6,6 +7,7 @@ import test from "node:test";
import {
artifactItemNumbers,
automationLimit,
countActions,
countCommandActions,
countRequeueRequired,
@ -14,6 +16,30 @@ import {
plannedItemNumberCsv,
proposedItemNumbers,
} from "../../dist/repair/workflow-utils.js";
import { workerLimit } from "../../dist/repair/limits.js";
test("workflow utilities expose automation limits", () => {
assert.equal(automationLimit("review_shards.normal_default"), 70);
assert.equal(automationLimit("repair_live_runs.default"), 40);
assert.throws(() => automationLimit("missing.default"), /unknown automation limit/);
});
test("workflow utilities accept positional automation limit CLI paths", () => {
const output = execFileSync(
process.execPath,
["dist/repair/workflow-utils.js", "limit", "review_shards.normal_default"],
{ cwd: process.cwd(), encoding: "utf8" },
);
assert.equal(output, "70");
});
test("worker scheduler lets background lanes yield to active work", () => {
assert.equal(workerLimit("normal_review"), 70);
assert.equal(workerLimit("normal_review", { activeCritical: 30, activeBackground: 20 }), 40);
assert.equal(workerLimit("commit_review"), 5);
assert.equal(workerLimit("commit_review", { activeCritical: 90 }), 1);
assert.equal(workerLimit("repair"), 40);
});
test("workflow utilities derive artifact item numbers and action counts", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawsweeper-workflow-"));

View File

@ -10,6 +10,42 @@ test("repositoryProfileFor matches mixed-case input against canonical profiles",
assert.equal(profile.slug, "openclaw-clawhub");
});
test("repositoryProfileFor supports fs-safe event reviews", () => {
const profile = repositoryProfileFor("OpenClaw/fs-safe");
assert.equal(profile.targetRepo, "openclaw/fs-safe");
assert.equal(profile.slug, "openclaw-fs-safe");
assert.equal(profile.checkoutDir, "fs-safe");
assert.deepEqual(profile.applyCloseRules.issue, []);
assert.deepEqual(profile.applyCloseRules.pull_request, ["implemented_on_main"]);
});
test("generic OpenClaw fallback supports conservative event-only onboarding", () => {
const profile = repositoryProfileFor("OpenClaw/example-tool");
assert.equal(profile.targetRepo, "openclaw/example-tool");
assert.equal(profile.slug, "openclaw-example-tool");
assert.equal(profile.displayName, "example-tool");
assert.equal(profile.checkoutDir, "example-tool");
assert.match(profile.promptNote, /generic OpenClaw onboarding profile/);
assert.deepEqual(profile.applyCloseRules.issue, []);
assert.deepEqual(profile.applyCloseRules.pull_request, ["implemented_on_main"]);
});
test("generic OpenClaw fallback keeps denied repositories unsupported", () => {
assert.throws(
() => repositoryProfileFor("openclaw/clawsweeper-state"),
/Unsupported target repo: openclaw\/clawsweeper-state/,
);
});
test("generic fallback does not support repositories outside OpenClaw", () => {
assert.throws(
() => repositoryProfileFor("other-org/example-tool"),
/Unsupported target repo: other-org\/example-tool/,
);
});
test("profile lookup normalizes candidate target repos as well as input", () => {
const mixedCaseProfile = {
...REPOSITORY_PROFILES[0],