Compare commits
94 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9a420e71b | ||
|
|
fdd213207f | ||
|
|
a7170a575a | ||
|
|
290a3f749c | ||
|
|
c44bbe7e2d | ||
|
|
1003195db4 | ||
|
|
1f9fcbc508 | ||
|
|
f925ae5ae7 | ||
|
|
dca9feb029 | ||
|
|
12314945e8 | ||
|
|
fb9c2f32fd | ||
|
|
e82a802a67 | ||
|
|
369c634b3c | ||
|
|
0fefca282d | ||
|
|
37cbd19c19 | ||
|
|
90dd661e0d | ||
|
|
412df40759 | ||
|
|
4a3be5222d | ||
|
|
ec1ea902a3 | ||
|
|
2477968992 | ||
|
|
ee25a76eee | ||
|
|
2ba139fcd6 | ||
|
|
00d7037915 | ||
|
|
f512051d79 | ||
|
|
eb0407f59a | ||
|
|
20ed692452 | ||
|
|
2a2546b71f | ||
|
|
6e9773534c | ||
|
|
eae122c38f | ||
|
|
b033b73435 | ||
|
|
e923eac212 | ||
|
|
5d54e8660a | ||
|
|
1a45767d1b | ||
|
|
16cced2111 | ||
|
|
42f414194b | ||
|
|
d821944133 | ||
|
|
ae3cd4988b | ||
|
|
e2942b1739 | ||
|
|
925728aee9 | ||
|
|
86376d8f43 | ||
|
|
be899d2fe3 | ||
|
|
3002e45562 | ||
|
|
2955367141 | ||
|
|
0761da3e82 | ||
|
|
ed8c25371c | ||
|
|
451b27a189 | ||
|
|
c941f8a27a | ||
|
|
636bee7d49 | ||
|
|
abd0febb24 | ||
|
|
dd15176f9f | ||
|
|
09e9daf950 | ||
|
|
f638537aa3 | ||
|
|
f72071e2ec | ||
|
|
682658e7d8 | ||
|
|
9c2cf8c3b8 | ||
|
|
faf5042244 | ||
|
|
460bb0dd35 | ||
|
|
3ef87d364a | ||
|
|
063360a8cc | ||
|
|
5a9f2d09a0 | ||
|
|
f9a6708575 | ||
|
|
3171d7d329 | ||
|
|
c67b2d83cb | ||
|
|
d1c11f4a1e | ||
|
|
a48a724573 | ||
|
|
4bc090e812 | ||
|
|
2da3c49074 | ||
|
|
3b95ab5d5a | ||
|
|
ebccd34fc9 | ||
|
|
5c4e4dcbe2 | ||
|
|
34c4ade1b3 | ||
|
|
bec6f20fc2 | ||
|
|
20adef834b | ||
|
|
bbd932acd6 | ||
|
|
05074fca93 | ||
|
|
8ddc438085 | ||
|
|
0fbf1e65bf | ||
|
|
3dc3e028a7 | ||
|
|
fd21f53216 | ||
|
|
0dc8a96fbd | ||
|
|
b0b53c1219 | ||
|
|
f7c2fc8dbb | ||
|
|
1f2a515e50 | ||
|
|
e7e93e03e4 | ||
|
|
c277b493fc | ||
|
|
90d2bfdd9e | ||
|
|
5c15b3cdff | ||
|
|
3fb1ddd7ca | ||
|
|
372a599a4a | ||
|
|
7e8a7d686e | ||
|
|
34154292b7 | ||
|
|
38473cef23 | ||
|
|
ae5641acf6 | ||
|
|
ca230659df |
71
.github/actions/setup-codex/action.yml
vendored
71
.github/actions/setup-codex/action.yml
vendored
@ -4,6 +4,12 @@ inputs:
|
||||
version:
|
||||
description: Codex CLI npm package version.
|
||||
default: "0.128.0"
|
||||
auth-mode:
|
||||
description: Authentication mode. Use proxy to keep OPENAI_API_KEY out of Codex subprocesses, or login for legacy codex login.
|
||||
default: "proxy"
|
||||
codex-home:
|
||||
description: CODEX_HOME directory. Defaults to an isolated per-run ClawSweeper directory.
|
||||
default: ""
|
||||
cache-suffix:
|
||||
description: Extra cache key suffix for target-tool downloads.
|
||||
default: ""
|
||||
@ -20,7 +26,7 @@ runs:
|
||||
~/.npm
|
||||
~/.cache/node/corepack
|
||||
~/.clawsweeper-repair/codex
|
||||
key: ${{ runner.os }}-node24-codex-${{ inputs.version }}-${{ inputs['cache-suffix'] || 'default' }}-v1
|
||||
key: ${{ runner.os }}-node24-codex-${{ inputs.version }}-${{ inputs['cache-suffix'] || 'default' }}-v2
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node24-codex-${{ inputs.version }}-
|
||||
${{ runner.os }}-node24-codex-
|
||||
@ -37,14 +43,75 @@ runs:
|
||||
if ! command -v codex >/dev/null 2>&1 || ! codex --version | grep -Fq "${{ inputs.version }}"; then
|
||||
npm install -g "@openai/codex@${{ inputs.version }}" --prefer-offline --no-audit --no-fund
|
||||
fi
|
||||
if [[ "${{ inputs['auth-mode'] }}" == "proxy" ]] && ! npm list -g --depth=0 "@openai/codex-responses-api-proxy@${{ inputs.version }}" >/dev/null 2>&1; then
|
||||
npm install -g "@openai/codex-responses-api-proxy@${{ inputs.version }}" --prefer-offline --no-audit --no-fund
|
||||
fi
|
||||
codex --version
|
||||
|
||||
- name: Authenticate Codex
|
||||
- name: Resolve Codex home
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -n "${{ inputs['codex-home'] }}" ]]; then
|
||||
codex_home="${{ inputs['codex-home'] }}"
|
||||
else
|
||||
codex_home="$HOME/.clawsweeper-repair/codex-home/${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}"
|
||||
fi
|
||||
mkdir -p "$codex_home"
|
||||
chmod 700 "$codex_home"
|
||||
echo "CODEX_HOME=$codex_home" >> "$GITHUB_ENV"
|
||||
export CODEX_HOME="$codex_home"
|
||||
echo "Using isolated CODEX_HOME=$CODEX_HOME"
|
||||
|
||||
- name: Authenticate Codex through Responses proxy
|
||||
if: ${{ inputs['auth-mode'] == 'proxy' }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export CODEX_HOME="${CODEX_HOME:?}"
|
||||
test -n "${OPENAI_API_KEY:-}"
|
||||
server_info="$CODEX_HOME/responses-proxy.json"
|
||||
if [[ ! -s "$server_info" ]]; then
|
||||
(
|
||||
printenv OPENAI_API_KEY | env -u OPENAI_API_KEY -u CODEX_API_KEY -u PROXY_API_KEY \
|
||||
codex-responses-api-proxy --http-shutdown --server-info "$server_info"
|
||||
) &
|
||||
fi
|
||||
for _ in {1..100}; do
|
||||
[[ -s "$server_info" ]] && break
|
||||
sleep 0.1
|
||||
done
|
||||
if [[ ! -s "$server_info" ]]; then
|
||||
echo "codex-responses-api-proxy did not write server info" >&2
|
||||
exit 1
|
||||
fi
|
||||
port="$(node -e 'const fs=require("fs"); const p=process.argv[1]; const data=JSON.parse(fs.readFileSync(p,"utf8")); if (!Number.isInteger(data.port)) process.exit(2); process.stdout.write(String(data.port));' "$server_info")"
|
||||
cat > "$CODEX_HOME/config.toml" <<EOF
|
||||
model_provider = "clawsweeper-responses-proxy"
|
||||
|
||||
[model_providers.clawsweeper-responses-proxy]
|
||||
name = "ClawSweeper Responses Proxy"
|
||||
base_url = "http://127.0.0.1:${port}/v1"
|
||||
wire_api = "responses"
|
||||
EOF
|
||||
chmod 600 "$CODEX_HOME/config.toml"
|
||||
echo "Configured Codex Responses proxy on localhost."
|
||||
|
||||
- name: Authenticate Codex with login
|
||||
if: ${{ inputs['auth-mode'] == 'login' }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export CODEX_HOME="${CODEX_HOME:?}"
|
||||
test -n "${OPENAI_API_KEY:-}"
|
||||
printenv OPENAI_API_KEY | codex login --with-api-key >/dev/null
|
||||
if [[ "${{ inputs['login-status'] }}" == "true" ]]; then
|
||||
codex login status
|
||||
fi
|
||||
|
||||
- name: Reject unknown Codex auth mode
|
||||
if: ${{ inputs['auth-mode'] != 'proxy' && inputs['auth-mode'] != 'login' }}
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Unsupported setup-codex auth-mode: ${{ inputs['auth-mode'] }}" >&2
|
||||
exit 1
|
||||
|
||||
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@ -30,6 +30,7 @@ jobs:
|
||||
with:
|
||||
sparse-checkout: |
|
||||
.github
|
||||
config
|
||||
docs
|
||||
instructions
|
||||
prompts
|
||||
|
||||
47
.github/workflows/commit-review.yml
vendored
47
.github/workflows/commit-review.yml
vendored
@ -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")"
|
||||
@ -240,7 +276,7 @@ jobs:
|
||||
echo "Selected $selected_count/$total_count commits at offset $COMMIT_OFFSET"
|
||||
printf 'Selected commits:\n%s\n' "$selected_commits"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: skipped-commit-reports
|
||||
@ -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
|
||||
@ -376,7 +411,7 @@ jobs:
|
||||
--report-relative-path "$relative_path" \
|
||||
--report-repo "${{ github.repository }}"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: commit-review-${{ matrix.sha }}
|
||||
@ -407,14 +442,14 @@ jobs:
|
||||
with:
|
||||
build-script: build:all
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v8
|
||||
if: ${{ needs.plan.outputs.planned_count != '0' }}
|
||||
with:
|
||||
pattern: commit-review-*
|
||||
path: commit-artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v8
|
||||
if: ${{ needs.plan.outputs.skipped_count != '0' }}
|
||||
with:
|
||||
name: skipped-commit-reports
|
||||
|
||||
34
.github/workflows/github-activity.yml
vendored
34
.github/workflows/github-activity.yml
vendored
@ -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,9 +76,31 @@ jobs:
|
||||
filter: blob:none
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/setup-pnpm
|
||||
with:
|
||||
build-script: build:repair
|
||||
- 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
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
exit 1
|
||||
fi
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
for attempt in 1 2 3; do
|
||||
if pnpm install --frozen-lockfile; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
exit 1
|
||||
fi
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
pnpm run build:repair
|
||||
|
||||
- name: Feed activity to OpenClaw
|
||||
env:
|
||||
|
||||
163
.github/workflows/repair-cluster-worker.yml
vendored
163
.github/workflows/repair-cluster-worker.yml
vendored
@ -100,86 +100,7 @@ jobs:
|
||||
id: check_job
|
||||
env:
|
||||
JOB_PATH: ${{ inputs.job }}
|
||||
run: |
|
||||
restore_automerge_job() {
|
||||
case "$JOB_PATH" in
|
||||
jobs/*/inbox/automerge-*.md) ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
filename="${JOB_PATH##*/}"
|
||||
stem="${filename%.md}"
|
||||
rest="${stem#automerge-}"
|
||||
number="${rest##*-}"
|
||||
repo_slug="${rest%-${number}}"
|
||||
owner="${JOB_PATH#jobs/}"
|
||||
owner="${owner%%/*}"
|
||||
repo_name="${repo_slug#${owner}-}"
|
||||
if [ -z "$number" ] || [ "$number" = "$rest" ] || [ -z "$repo_name" ] || [ "$repo_name" = "$repo_slug" ]; then
|
||||
return 1
|
||||
fi
|
||||
repo="$owner/$repo_name"
|
||||
ref="#$number"
|
||||
branch="clawsweeper/automerge-$repo_slug-$number"
|
||||
mkdir -p "$(dirname "$JOB_PATH")"
|
||||
cat > "$JOB_PATH" <<EOF
|
||||
---
|
||||
repo: $repo
|
||||
cluster_id: automerge-$repo_slug-$number
|
||||
mode: autonomous
|
||||
allowed_actions:
|
||||
- comment
|
||||
- label
|
||||
- fix
|
||||
- raise_pr
|
||||
blocked_actions:
|
||||
- close
|
||||
- merge
|
||||
require_human_for:
|
||||
- close
|
||||
- merge
|
||||
canonical:
|
||||
- $ref
|
||||
candidates:
|
||||
- $ref
|
||||
cluster_refs:
|
||||
- $ref
|
||||
allow_instant_close: false
|
||||
allow_fix_pr: true
|
||||
allow_merge: false
|
||||
allow_unmerged_fix_close: false
|
||||
allow_post_merge_close: false
|
||||
require_fix_before_close: true
|
||||
security_policy: central_security_only
|
||||
security_sensitive: false
|
||||
target_branch: $branch
|
||||
source: pr_automerge
|
||||
---
|
||||
|
||||
# ClawSweeper adopted PR repair candidate
|
||||
|
||||
Maintainer opted $ref into ClawSweeper automerge.
|
||||
|
||||
Source PR: https://github.com/$repo/pull/$number
|
||||
Title: PR $ref
|
||||
|
||||
ClawSweeper should use this job only for the bounded ClawSweeper review/fix loop:
|
||||
|
||||
- If ClawSweeper emits an explicit repair marker, requests changes, or finds failing checks/rebase work, and the PR branch is safe to update, emit a fix artifact with \`repair_strategy: "repair_contributor_branch"\` and \`source_prs: ["https://github.com/$repo/pull/$number"]\`.
|
||||
- If the PR branch cannot be safely updated, emit a narrow credited replacement only when the artifact can preserve the original contributor credit; otherwise return \`needs_human\`.
|
||||
- Do not merge, close, or bypass review gates from the worker. The comment router owns final merge only after a passing ClawSweeper verdict for the exact current head.
|
||||
- Keep repair scope limited to actionable ClawSweeper findings, failing relevant checks, and required review feedback on this PR.
|
||||
EOF
|
||||
}
|
||||
|
||||
if [ -f "$JOB_PATH" ]; then
|
||||
echo "job_exists=1" >> "$GITHUB_OUTPUT"
|
||||
elif restore_automerge_job; then
|
||||
echo "job_exists=1" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice title=Restored automerge repair job::Job file '$JOB_PATH' was missing from the state checkout; reconstructed it from the workflow input."
|
||||
else
|
||||
echo "job_exists=0" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice title=Stale repair dispatch::Job file '$JOB_PATH' no longer exists on the current state checkout; skipping this worker."
|
||||
fi
|
||||
run: scripts/restore-repair-job.sh "$JOB_PATH" "this worker"
|
||||
|
||||
- name: Capture execution gates
|
||||
id: capture_gates
|
||||
@ -322,6 +243,7 @@ jobs:
|
||||
permission-contents: write
|
||||
permission-issues: write
|
||||
permission-pull-requests: write
|
||||
permission-workflows: write
|
||||
|
||||
- uses: ./.github/actions/setup-state
|
||||
with:
|
||||
@ -331,86 +253,7 @@ jobs:
|
||||
id: check_job
|
||||
env:
|
||||
JOB_PATH: ${{ inputs.job }}
|
||||
run: |
|
||||
restore_automerge_job() {
|
||||
case "$JOB_PATH" in
|
||||
jobs/*/inbox/automerge-*.md) ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
filename="${JOB_PATH##*/}"
|
||||
stem="${filename%.md}"
|
||||
rest="${stem#automerge-}"
|
||||
number="${rest##*-}"
|
||||
repo_slug="${rest%-${number}}"
|
||||
owner="${JOB_PATH#jobs/}"
|
||||
owner="${owner%%/*}"
|
||||
repo_name="${repo_slug#${owner}-}"
|
||||
if [ -z "$number" ] || [ "$number" = "$rest" ] || [ -z "$repo_name" ] || [ "$repo_name" = "$repo_slug" ]; then
|
||||
return 1
|
||||
fi
|
||||
repo="$owner/$repo_name"
|
||||
ref="#$number"
|
||||
branch="clawsweeper/automerge-$repo_slug-$number"
|
||||
mkdir -p "$(dirname "$JOB_PATH")"
|
||||
cat > "$JOB_PATH" <<EOF
|
||||
---
|
||||
repo: $repo
|
||||
cluster_id: automerge-$repo_slug-$number
|
||||
mode: autonomous
|
||||
allowed_actions:
|
||||
- comment
|
||||
- label
|
||||
- fix
|
||||
- raise_pr
|
||||
blocked_actions:
|
||||
- close
|
||||
- merge
|
||||
require_human_for:
|
||||
- close
|
||||
- merge
|
||||
canonical:
|
||||
- $ref
|
||||
candidates:
|
||||
- $ref
|
||||
cluster_refs:
|
||||
- $ref
|
||||
allow_instant_close: false
|
||||
allow_fix_pr: true
|
||||
allow_merge: false
|
||||
allow_unmerged_fix_close: false
|
||||
allow_post_merge_close: false
|
||||
require_fix_before_close: true
|
||||
security_policy: central_security_only
|
||||
security_sensitive: false
|
||||
target_branch: $branch
|
||||
source: pr_automerge
|
||||
---
|
||||
|
||||
# ClawSweeper adopted PR repair candidate
|
||||
|
||||
Maintainer opted $ref into ClawSweeper automerge.
|
||||
|
||||
Source PR: https://github.com/$repo/pull/$number
|
||||
Title: PR $ref
|
||||
|
||||
ClawSweeper should use this job only for the bounded ClawSweeper review/fix loop:
|
||||
|
||||
- If ClawSweeper emits an explicit repair marker, requests changes, or finds failing checks/rebase work, and the PR branch is safe to update, emit a fix artifact with \`repair_strategy: "repair_contributor_branch"\` and \`source_prs: ["https://github.com/$repo/pull/$number"]\`.
|
||||
- If the PR branch cannot be safely updated, emit a narrow credited replacement only when the artifact can preserve the original contributor credit; otherwise return \`needs_human\`.
|
||||
- Do not merge, close, or bypass review gates from the worker. The comment router owns final merge only after a passing ClawSweeper verdict for the exact current head.
|
||||
- Keep repair scope limited to actionable ClawSweeper findings, failing relevant checks, and required review feedback on this PR.
|
||||
EOF
|
||||
}
|
||||
|
||||
if [ -f "$JOB_PATH" ]; then
|
||||
echo "job_exists=1" >> "$GITHUB_OUTPUT"
|
||||
elif restore_automerge_job; then
|
||||
echo "job_exists=1" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice title=Restored automerge repair job::Job file '$JOB_PATH' was missing from the state checkout; reconstructed it from the workflow input."
|
||||
else
|
||||
echo "job_exists=0" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice title=Stale repair dispatch::Job file '$JOB_PATH' no longer exists on the current state checkout; skipping execute."
|
||||
fi
|
||||
run: scripts/restore-repair-job.sh "$JOB_PATH" "execute"
|
||||
|
||||
- uses: ./.github/actions/setup-pnpm
|
||||
if: ${{ steps.check_job.outputs.job_exists == '1' }}
|
||||
|
||||
1
.github/workflows/repair-comment-router.yml
vendored
1
.github/workflows/repair-comment-router.yml
vendored
@ -79,6 +79,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
sparse-checkout: |
|
||||
.github
|
||||
config
|
||||
jobs
|
||||
results
|
||||
scripts/hydrate-state.ts
|
||||
|
||||
@ -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 \
|
||||
|
||||
274
.github/workflows/sweep.yml
vendored
274
.github/workflows/sweep.yml
vendored
@ -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:
|
||||
@ -224,14 +224,6 @@ jobs:
|
||||
permission-issues: write
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Use state token
|
||||
id: state-token
|
||||
run: echo "token=${{ secrets.CLAWSWEEPER_STATE_REPOSITORY_TOKEN }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: ./.github/actions/setup-state
|
||||
with:
|
||||
token: ${{ steps.state-token.outputs.token }}
|
||||
|
||||
- name: React to target item review start
|
||||
continue-on-error: true
|
||||
env:
|
||||
@ -265,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 }}
|
||||
@ -301,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 }}
|
||||
@ -321,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 \
|
||||
@ -329,7 +339,16 @@ jobs:
|
||||
--shard-count 1 \
|
||||
"${additional_prompt_arg[@]}"
|
||||
|
||||
- name: Use state token
|
||||
id: state-token
|
||||
run: echo "token=${{ secrets.CLAWSWEEPER_STATE_REPOSITORY_TOKEN }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: ./.github/actions/setup-state
|
||||
with:
|
||||
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: |
|
||||
@ -450,6 +497,8 @@ jobs:
|
||||
hot_intake: ${{ steps.mode.outputs.hot_intake }}
|
||||
matrix: ${{ steps.select.outputs.matrix }}
|
||||
max_pages: ${{ steps.mode.outputs.max_pages }}
|
||||
min_active_shards: ${{ steps.mode.outputs.min_active_shards }}
|
||||
min_backfill_review_age_minutes: ${{ steps.mode.outputs.min_backfill_review_age_minutes }}
|
||||
planned_count: ${{ steps.select.outputs.planned_count }}
|
||||
planned_capacity: ${{ steps.select.outputs.planned_capacity }}
|
||||
planned_item_numbers: ${{ steps.select.outputs.planned_item_numbers }}
|
||||
@ -517,13 +566,14 @@ jobs:
|
||||
with:
|
||||
token: ${{ steps.state-token.outputs.token }}
|
||||
worktree-path: clawsweeper
|
||||
fetch-depth: 1
|
||||
|
||||
- uses: ./clawsweeper/.github/actions/setup-pnpm
|
||||
with:
|
||||
working-directory: clawsweeper
|
||||
build-script: build:all
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: clawsweeper-runtime-dist
|
||||
path: clawsweeper/dist
|
||||
@ -535,47 +585,102 @@ 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"
|
||||
else
|
||||
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||
batch_size="1"
|
||||
else
|
||||
batch_size="${{ github.event.inputs.batch_size || '3' }}"
|
||||
fi
|
||||
shard_count="${{ github.event.inputs.shard_count || '100' }}"
|
||||
if [ "$target_repo" = "openclaw/openclaw" ]; then
|
||||
min_active_shards="$normal_active_floor"
|
||||
else
|
||||
min_active_shards="0"
|
||||
fi
|
||||
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
|
||||
if [ "$hot_intake" = "true" ] && [ -n "$exact_item" ]; then
|
||||
min_active_shards="0"
|
||||
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"
|
||||
echo "codex_timeout_ms=${{ github.event.inputs.codex_timeout_ms || '600000' }}"
|
||||
echo "hot_intake=$hot_intake"
|
||||
echo "max_pages=$max_pages"
|
||||
echo "min_active_shards=$min_active_shards"
|
||||
echo "min_backfill_review_age_minutes=$min_backfill_review_age_minutes"
|
||||
echo "shard_count=$shard_count"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
@ -588,6 +693,8 @@ jobs:
|
||||
ITEM_NUMBER: ${{ github.event.inputs.item_number || '' }}
|
||||
ITEM_NUMBERS: ${{ github.event.inputs.item_numbers || github.event.client_payload.item_number || '' }}
|
||||
MAX_PAGES: ${{ steps.mode.outputs.max_pages }}
|
||||
MIN_ACTIVE_SHARDS: ${{ steps.mode.outputs.min_active_shards }}
|
||||
MIN_BACKFILL_REVIEW_AGE_MINUTES: ${{ steps.mode.outputs.min_backfill_review_age_minutes }}
|
||||
TARGET_REPO: ${{ steps.target.outputs.target_repo }}
|
||||
GH_TOKEN: ${{ steps.target-read-token.outputs.token }}
|
||||
run: |
|
||||
@ -611,7 +718,8 @@ 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[@]}" \
|
||||
"${item_arg[@]}" > plan.json
|
||||
pnpm run --silent workflow -- plan-output \
|
||||
@ -628,8 +736,10 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TARGET_REPO: ${{ steps.target.outputs.target_repo }}
|
||||
run: |
|
||||
pnpm run reconcile -- --target-repo "$TARGET_REPO" --skip-closed-at
|
||||
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 }}" \
|
||||
@ -640,11 +750,10 @@ jobs:
|
||||
--due-backlog "${{ steps.select.outputs.due_backlog }}" \
|
||||
--oldest-unreviewed-at "${{ steps.select.outputs.oldest_unreviewed_at }}" \
|
||||
--capacity-reason "${{ steps.select.outputs.capacity_reason }}"
|
||||
pnpm run repair:publish-main -- \
|
||||
timeout 20s pnpm run repair:publish-main -- \
|
||||
--message "chore: mark sweep review in progress" \
|
||||
--path records \
|
||||
--path results/sweep-status \
|
||||
--rebase-strategy theirs
|
||||
--path "results/sweep-status/${target_slug}.json" \
|
||||
--rebase-strategy theirs || echo "::warning::Skipped slow in-progress dashboard publish so review shards can start."
|
||||
|
||||
review:
|
||||
name: Review shard ${{ matrix.shard }}
|
||||
@ -680,21 +789,23 @@ jobs:
|
||||
permission-issues: write
|
||||
permission-pull-requests: read
|
||||
|
||||
- name: Use state token
|
||||
id: state-token
|
||||
run: echo "token=${{ secrets.CLAWSWEEPER_STATE_REPOSITORY_TOKEN }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: ./clawsweeper/.github/actions/setup-state
|
||||
- name: Create target Codex inspection token
|
||||
id: codex-inspection-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
token: ${{ steps.state-token.outputs.token }}
|
||||
worktree-path: clawsweeper
|
||||
fetch-depth: 1
|
||||
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
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: clawsweeper-runtime-dist
|
||||
path: clawsweeper/dist
|
||||
@ -743,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=()
|
||||
@ -753,7 +865,16 @@ jobs:
|
||||
if [ -n "$ADDITIONAL_PROMPT" ]; then
|
||||
additional_prompt_arg=(--additional-prompt "$ADDITIONAL_PROMPT")
|
||||
fi
|
||||
node dist/clawsweeper.js review \
|
||||
codex_timeout_seconds=$(((${{ needs.plan.outputs.codex_timeout_ms }} + 999) / 1000))
|
||||
review_timeout_seconds=$(((codex_timeout_seconds + 180) * ${{ needs.plan.outputs.batch_size }}))
|
||||
if [ "$review_timeout_seconds" -lt 300 ]; then
|
||||
review_timeout_seconds=300
|
||||
fi
|
||||
if [ "$review_timeout_seconds" -gt 4200 ]; then
|
||||
review_timeout_seconds=4200
|
||||
fi
|
||||
echo "::notice::Review shard timeout is ${review_timeout_seconds}s for batch size ${{ needs.plan.outputs.batch_size }} and per-item Codex timeout ${codex_timeout_seconds}s."
|
||||
timeout --kill-after=30s "${review_timeout_seconds}s" node dist/clawsweeper.js review \
|
||||
--target-repo "${{ needs.plan.outputs.target_repo }}" \
|
||||
--target-dir "../${{ needs.plan.outputs.target_checkout_dir }}" \
|
||||
--artifact-dir ../review-artifacts/shard-${{ matrix.shard }} \
|
||||
@ -762,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[@]}" \
|
||||
@ -808,7 +928,7 @@ jobs:
|
||||
}
|
||||
JSON
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: review-shard-${{ matrix.shard }}
|
||||
@ -816,14 +936,14 @@ jobs:
|
||||
review-artifacts/shard-${{ matrix.shard }}/*.md
|
||||
if-no-files-found: ignore
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: review-failed-shard-${{ matrix.shard }}
|
||||
path: review-artifacts/failed-shards/shard-${{ matrix.shard }}.json
|
||||
if-no-files-found: ignore
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: review-metrics-${{ matrix.shard }}
|
||||
@ -866,13 +986,13 @@ jobs:
|
||||
with:
|
||||
build-script: build:all
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: review-shard-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v8
|
||||
continue-on-error: true
|
||||
with:
|
||||
pattern: review-metrics-*
|
||||
@ -903,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 }}" \
|
||||
@ -921,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
|
||||
@ -928,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
|
||||
@ -938,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" \
|
||||
@ -957,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,
|
||||
@ -981,8 +1107,8 @@ jobs:
|
||||
-f report_url="$report_url"
|
||||
done < /tmp/issue-implementation-candidates.tsv
|
||||
|
||||
- name: Dispatch scheduled review comment sync
|
||||
if: ${{ success() && github.event_name == 'schedule' && needs.plan.outputs.hot_intake != 'true' }}
|
||||
- name: Dispatch background review comment sync
|
||||
if: ${{ success() && needs.plan.outputs.hot_intake != 'true' && github.event_name != 'repository_dispatch' && (github.event_name != 'workflow_dispatch' || (github.event.inputs.item_number == '' && github.event.inputs.item_numbers == '')) }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TARGET_REPO: ${{ needs.plan.outputs.target_repo }}
|
||||
@ -1003,15 +1129,15 @@ jobs:
|
||||
-f apply_limit=0 \
|
||||
-f apply_comment_sync_min_age_days=7 \
|
||||
-f apply_progress_every=25; then
|
||||
echo "Dispatched scheduled comment sync for item numbers: $item_numbers"
|
||||
echo "Dispatched background comment sync for item numbers: $item_numbers"
|
||||
exit 0
|
||||
fi
|
||||
sleep "$((attempt * 10))"
|
||||
done
|
||||
echo "::warning::Unable to dispatch scheduled comment sync after three attempts; apply/comment-sync backstops can pick it up later."
|
||||
echo "::warning::Unable to dispatch background comment sync after three attempts; apply/comment-sync backstops can pick it up later."
|
||||
|
||||
- name: Sync selected review comments
|
||||
if: ${{ success() && (github.event_name != 'schedule' || needs.plan.outputs.hot_intake == 'true') && (needs.plan.outputs.hot_intake != 'true' || github.event.inputs.item_number != '' || github.event.inputs.item_numbers != '') }}
|
||||
if: ${{ success() && (github.event_name == 'repository_dispatch' || github.event.inputs.item_number != '' || github.event.inputs.item_numbers != '') }}
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.target-write-token.outputs.token }}
|
||||
@ -1019,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."
|
||||
@ -1038,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 }}" \
|
||||
@ -1050,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
|
||||
@ -1064,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."
|
||||
@ -1088,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 }}" \
|
||||
@ -1100,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
|
||||
@ -1135,7 +1267,7 @@ jobs:
|
||||
actions: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@v8
|
||||
id: failed-shards
|
||||
continue-on-error: true
|
||||
with:
|
||||
@ -1308,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
|
||||
@ -1479,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 }}"
|
||||
@ -1512,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 }}"
|
||||
@ -1553,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 }}"
|
||||
@ -1568,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 }}"
|
||||
@ -1595,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
|
||||
@ -1658,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 || "[]");
|
||||
@ -1666,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 || ""));
|
||||
@ -1699,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
|
||||
|
||||
@ -1711,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
|
||||
|
||||
159
CHANGELOG.md
159
CHANGELOG.md
@ -5,6 +5,165 @@ 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.
|
||||
|
||||
### 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.
|
||||
- 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.
|
||||
|
||||
## 0.1.0 - 2026-05-03
|
||||
|
||||
### Added
|
||||
|
||||
265
README.md
265
README.md
@ -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
|
||||
|
||||
@ -100,7 +128,8 @@ Maintainers can steer ClawSweeper from target-repo issue and PR comments. The
|
||||
preferred form is `@clawsweeper ...`. The router also accepts
|
||||
`@clawsweeper[bot] ...`, `@openclaw-clawsweeper ...`,
|
||||
`@openclaw-clawsweeper[bot] ...`, and legacy slash aliases such as
|
||||
`/clawsweeper ...`, `/review`, `/automerge`, and `/autoclose <reason>`.
|
||||
`/clawsweeper ...`, `/review`, `/automerge`, `/auto merge`, and
|
||||
`/autoclose <reason>`.
|
||||
|
||||
Common commands:
|
||||
|
||||
@ -123,8 +152,9 @@ Common commands:
|
||||
- `review` and `re-review` dispatch a fresh ClawSweeper issue/PR review without
|
||||
starting repair.
|
||||
- Command status replies are marker-backed and edited in place per
|
||||
issue/PR, intent, and head SHA, so repeated review nudges do not leave a
|
||||
trail of duplicate lobster notes.
|
||||
issue/PR, intent, and head SHA. The visible badge is one lobster plus the
|
||||
current state: `👀` for acknowledgement, `🧹` for review, `🔧` for repair, and
|
||||
`✅` for completed/paused work.
|
||||
- Freeform `@clawsweeper ...` mentions dispatch a read-only assist review that
|
||||
answers the maintainer request in the next ClawSweeper comment. Action-looking
|
||||
prose still maps through existing safe markers and deterministic gates.
|
||||
@ -143,8 +173,10 @@ Common commands:
|
||||
exact-head review is clean.
|
||||
- `approve` lets a maintainer clear a ClawSweeper human-review pause and merge
|
||||
only after the normal exact-head, checks, mergeability, and gate checks pass.
|
||||
- `stop` adds `clawsweeper:human-review`; `/autoclose <reason>` closes the
|
||||
item and bounded linked same-repo targets with an explicit maintainer reason.
|
||||
- `stop` removes repair-loop labels, adds `clawsweeper:human-review`, and makes
|
||||
older automerge/autofix comments ineligible to continue. `/autoclose <reason>`
|
||||
closes the item and bounded linked same-repo targets with an explicit
|
||||
maintainer reason.
|
||||
|
||||
Only maintainers are accepted. The router checks repository collaborator
|
||||
permission (`admin`, `maintain`, or `write`) and falls back to trusted
|
||||
@ -159,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
|
||||
|
||||
@ -194,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;
|
||||
@ -247,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
|
||||
@ -257,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.
|
||||
@ -339,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
|
||||
@ -396,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).
|
||||
@ -433,7 +509,9 @@ full compiled-repo coverage ratchet.
|
||||
|
||||
Required secrets:
|
||||
|
||||
- `OPENAI_API_KEY`: OpenAI API key used to log Codex in before review shards run.
|
||||
- `OPENAI_API_KEY`: OpenAI API key used by the per-job local Codex Responses
|
||||
proxy. Codex subprocesses inherit only the proxy-backed `CODEX_HOME`, not the
|
||||
raw API key.
|
||||
- `CLAWSWEEPER_APP_CLIENT_ID`: public GitHub App client ID for `clawsweeper`.
|
||||
Currently `Iv23liOECG0slfuhz093`.
|
||||
- `CLAWSWEEPER_APP_PRIVATE_KEY`: private key for `clawsweeper`; plan/review
|
||||
@ -447,8 +525,9 @@ Required secrets:
|
||||
|
||||
Token flow:
|
||||
|
||||
- Review shards log Codex in with `OPENAI_API_KEY`, then run without OpenAI or
|
||||
Codex token environment variables.
|
||||
- Review and repair jobs create an isolated per-run `CODEX_HOME`, start a local
|
||||
Responses proxy from `OPENAI_API_KEY`, write proxy-only Codex config there,
|
||||
and run Codex without OpenAI or Codex token environment variables.
|
||||
- ClawSweeper uses the `clawsweeper` GitHub App token for read-heavy target
|
||||
context.
|
||||
- Apply mode uses the same app token for review comments and closes, so GitHub
|
||||
@ -467,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
|
||||
@ -490,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`
|
||||
|
||||
7
config/automation-limits.json
Normal file
7
config/automation-limits.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"workers": {
|
||||
"max": 100,
|
||||
"reserve_for_interactive": 10,
|
||||
"minimum_background": 10
|
||||
}
|
||||
}
|
||||
46
config/target-repositories.json
Normal file
46
config/target-repositories.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
122
docs/limits.md
Normal 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`.
|
||||
@ -186,11 +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 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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -98,12 +98,18 @@ 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.
|
||||
|
||||
If Codex itself fails an edit pass with a transient tool-transport error, such
|
||||
as a closed stdin session from the Codex tool router, the executor consumes an
|
||||
edit retry and keeps the branch recoverable instead of failing the whole repair
|
||||
worker immediately. Timeouts and validation failures still use their dedicated
|
||||
timeout, validation-fix, and review-fix paths.
|
||||
|
||||
Full worker prompts, Codex transcripts, and raw artifacts stay in GitHub Actions. The committed ledger keeps only the cluster summary, run URL, action counts, apply outcomes, closed targets, and human-review entries.
|
||||
|
||||
## Modes
|
||||
@ -114,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
|
||||
|
||||
@ -149,12 +155,14 @@ Supported commands:
|
||||
/clawsweeper rebase
|
||||
/clawsweeper autofix
|
||||
/clawsweeper automerge
|
||||
/clawsweeper auto merge
|
||||
/clawsweeper approve
|
||||
/clawsweeper explain
|
||||
/clawsweeper stop
|
||||
@clawsweeper re-review
|
||||
@clawsweeper review
|
||||
@clawsweeper implement
|
||||
@clawsweeper fix
|
||||
@clawsweeper build
|
||||
@clawsweeper create pr
|
||||
@clawsweeper fix issue
|
||||
@ -166,12 +174,12 @@ Supported commands:
|
||||
dispatch ClawSweeper review again for an open issue or PR. `fix ci`, `address review`,
|
||||
and `rebase` dispatch the normal `repair-cluster-worker.yml` repair path, but only for
|
||||
existing ClawSweeper PRs identified by the `clawsweeper/*` branch.
|
||||
`implement`, `build`, `create pr`, and `fix issue` work only on open issues. The router
|
||||
creates or reuses one durable `issue-<repo>-<number>` job and dispatches the
|
||||
normal repair worker to verify the issue on latest `main` and open or update one
|
||||
narrow implementation PR. This lane never merges or closes the issue; broad,
|
||||
underspecified, security-sensitive, or already-fixed issues become a blocked
|
||||
repair result instead of a public PR.
|
||||
`implement`, `fix`, `build`, `create pr`, and `fix issue` work only on open issues.
|
||||
The router creates or reuses one durable `issue-<repo>-<number>` job and
|
||||
dispatches the normal repair worker to verify the issue on latest `main` and
|
||||
open or update one narrow implementation PR. This lane never merges or closes
|
||||
the issue; broad, underspecified, security-sensitive, or already-fixed issues
|
||||
become a blocked repair result instead of a public PR.
|
||||
Freeform maintainer mentions such as `@clawsweeper why did automerge stop here?`
|
||||
dispatch a read-only assist review. The answer lands in the next ClawSweeper
|
||||
comment; action-looking prose can only become existing structured
|
||||
@ -182,6 +190,8 @@ PRs stay fix-only until GitHub marks them ready for review. `approve` is
|
||||
maintainer-only exact-head approval after a human-review pause; it clears pause
|
||||
labels and merges only when the normal automerge readiness checks and merge
|
||||
gates pass. `stop` labels the item for human review.
|
||||
It also removes repair-loop labels, so older automerge/autofix commands and
|
||||
trusted pass markers cannot continue the loop after the stop.
|
||||
|
||||
The router writes an idempotency marker into each reply and records processed
|
||||
comments in `results/comment-router.json`. The scheduled workflow is dry by
|
||||
@ -220,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
|
||||
@ -284,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
|
||||
```
|
||||
@ -313,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`,
|
||||
@ -323,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
|
||||
@ -333,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
|
||||
|
||||
@ -102,6 +102,7 @@ Maintainers can opt any open PR into the bounded merge loop with:
|
||||
|
||||
```text
|
||||
/clawsweeper automerge
|
||||
/clawsweeper auto merge
|
||||
```
|
||||
|
||||
The command adds `clawsweeper:automerge`, asks ClawSweeper to review the current
|
||||
@ -123,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`
|
||||
@ -199,10 +204,11 @@ Accepted repair verdicts:
|
||||
`clawsweeper:autofix` or `clawsweeper:automerge`, a pass verdict for the exact
|
||||
current head ends the current repair round. Autofix never merges. Automerge can
|
||||
merge only after required checks, mergeability, review state, non-draft status,
|
||||
and both merge gates are green. `needs-human`, `human-review`, and
|
||||
`/clawsweeper stop` pause the loop by adding `clawsweeper:human-review`. If
|
||||
ClawSweeper wants the bounded repair/rebase loop to continue, it must emit an
|
||||
accepted repair verdict or action marker.
|
||||
and both merge gates are green. `needs-human` and `human-review` pause the loop
|
||||
by adding `clawsweeper:human-review`; `/clawsweeper stop` is stronger and also
|
||||
removes repair-loop labels so older automerge/autofix comments cannot resume the
|
||||
loop. If ClawSweeper wants the bounded repair/rebase loop to continue, it must
|
||||
emit an accepted repair verdict or action marker.
|
||||
|
||||
After a `needs-human` pause, `/clawsweeper approve` is a maintainer-only exact-head
|
||||
approval. It clears pause labels and uses the same merge readiness checks and
|
||||
@ -222,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.
|
||||
@ -339,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`.
|
||||
|
||||
@ -78,8 +78,9 @@ Artifact names:
|
||||
|
||||
The repair workflow snapshots recent Codex session JSONL files, Codex log files,
|
||||
and ClawSweeper-captured `codex exec --json` outputs after both the planning job
|
||||
and the fix execution job. Session/log files come from `~/.codex/sessions` and
|
||||
`~/.codex/log`; captured repair outputs come from `.clawsweeper-repair/runs`.
|
||||
and the fix execution job. Session/log files come from the job's isolated
|
||||
`CODEX_HOME` when set, otherwise `~/.codex`; captured repair outputs come from
|
||||
`.clawsweeper-repair/runs`.
|
||||
The collector deliberately excludes Codex auth and config files, redacts common
|
||||
OpenAI and GitHub token shapes, and writes a `manifest.json` with
|
||||
source-relative paths, byte counts, mtimes, and SHA-256 hashes. These debug
|
||||
@ -145,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`
|
||||
@ -259,8 +261,8 @@ close actions remain blocked.
|
||||
## Issue Implementation Commands
|
||||
|
||||
Maintainer comments can turn an open issue into one ClawSweeper implementation
|
||||
PR with `/clawsweeper implement`, `/clawsweeper build`, `@clawsweeper create pr`, or
|
||||
`@clawsweeper fix issue`.
|
||||
PR with `/clawsweeper implement`, `@clawsweeper fix`, `/clawsweeper build`,
|
||||
`@clawsweeper create pr`, or `@clawsweeper fix issue`.
|
||||
|
||||
The comment router creates or reuses
|
||||
`jobs/<owner>/inbox/issue-<owner>-<repo>-<number>.md` with
|
||||
|
||||
@ -200,7 +200,11 @@ Use Blacksmith labels only when you intentionally want a non-parity hosted runne
|
||||
pnpm run repair:dispatch -- jobs/openclaw/cluster-*.md --mode plan --runner blacksmith-4vcpu-ubuntu-2404
|
||||
```
|
||||
|
||||
The workflow uses Node 24 and logs Codex in with `OPENAI_API_KEY`, while also passing `CODEX_API_KEY` to `codex exec`. Set `CODEX_API_KEY` to the same value unless you intentionally separate CI auth.
|
||||
The workflow uses Node 24 and starts a local Codex Responses proxy from
|
||||
`OPENAI_API_KEY` inside an isolated per-run `CODEX_HOME`. Codex subprocesses use
|
||||
that proxy config and run without raw OpenAI or Codex API key environment
|
||||
variables. The legacy `codex login` path remains available only through the
|
||||
local `setup-codex` action's `auth-mode: login` input.
|
||||
|
||||
Codex runs in a read-only sandbox for classification and receives no GitHub token. GitHub read access is scoped to deterministic preflight scripts. For reviewed fix artifacts, `execute-fix-artifact` gives Codex a temporary target checkout without GitHub credentials, then the deterministic executor commits, pushes, opens the replacement PR, and closes uneditable source PRs only after the replacement exists. When a replacement carries contributor work forward, non-bot source PR authors are added as `Co-authored-by` trailers and named in the replacement PR body and source close comment. Remaining write access is scoped to `apply-result`.
|
||||
|
||||
@ -210,6 +214,12 @@ heartbeat. If a model call is slow, Actions logs should show
|
||||
`[clawsweeper repair] ... still running` about once a minute instead of ending
|
||||
with a silent no-output timeout.
|
||||
|
||||
Automerge repair execution also updates the existing mutable automerge status
|
||||
comment at coarse milestones: validation plan, write preflight, Codex edit
|
||||
passes, validation/review loops, final base sync, and the post-repair automerge
|
||||
wait. These updates append or replace rows in the single progress timeline
|
||||
instead of adding new comments.
|
||||
|
||||
Network calls in fix execution are also bounded. Contributor-branch clone,
|
||||
fetch, push, status-comment, and review-thread calls should time out before the
|
||||
GitHub Actions step limit, leaving the final repair report and debug artifacts
|
||||
@ -219,8 +229,8 @@ For deep debugging, download the `clawsweeper-codex-debug-cluster-*` and
|
||||
`clawsweeper-codex-debug-execute-*` artifacts from the repair worker run. They
|
||||
contain recent Codex session/log files, ClawSweeper-captured `codex exec --json`
|
||||
outputs from `.clawsweeper-repair/runs`, and a manifest. The collector skips
|
||||
Codex auth/config files and redacts common token shapes before upload; retention
|
||||
is seven days by default.
|
||||
Codex auth/config files, honors the isolated `CODEX_HOME`, and redacts common
|
||||
token shapes before upload; retention is seven days by default.
|
||||
|
||||
The final repair artifact keeps only capped tail copies of executor debug files
|
||||
under `fix-executor-debug/` so failed runs do not spend minutes uploading huge
|
||||
@ -245,6 +255,9 @@ Target repositories can also forward matching `issue_comment` events as
|
||||
`clawsweeper_comment` repository dispatches with the exact comment id. Those
|
||||
comments get an immediate `eyes` reaction from the ClawSweeper app, and the
|
||||
scheduled sweep remains a five-minute fallback.
|
||||
The status comment itself uses one compact badge: `🦞👀` for acknowledgement,
|
||||
`🦞🧹` for review, `🦞🔧` for repair/build/fix work, and `🦞✅` for completed or
|
||||
paused work.
|
||||
It accepts only maintainer-authored commands, gated by GitHub
|
||||
`author_association` values `OWNER`, `MEMBER`, or `COLLABORATOR` by default.
|
||||
Contributor comments are ignored without a reply.
|
||||
@ -262,12 +275,14 @@ Supported triggers:
|
||||
/clawsweeper rebase
|
||||
/clawsweeper autofix
|
||||
/clawsweeper automerge
|
||||
/clawsweeper auto merge
|
||||
/clawsweeper approve
|
||||
/clawsweeper explain
|
||||
/clawsweeper stop
|
||||
@clawsweeper re-review
|
||||
@clawsweeper review
|
||||
@clawsweeper implement
|
||||
@clawsweeper fix
|
||||
@clawsweeper build
|
||||
@clawsweeper create pr
|
||||
@clawsweeper fix issue
|
||||
@ -275,11 +290,16 @@ Supported triggers:
|
||||
```
|
||||
|
||||
`review` and `re-review` dispatch ClawSweeper review again for an open issue or PR.
|
||||
Issue implementation commands (`implement`, `build`, `create pr`, `fix issue`) dispatch
|
||||
the repair worker for one open issue and ask it to create or update a single
|
||||
ClawSweeper implementation PR. The generated job uses
|
||||
Issue implementation commands (`implement`, `fix`, `build`, `create pr`, `fix issue`)
|
||||
dispatch the repair worker for one open issue and ask it to create or update a
|
||||
single ClawSweeper implementation PR. The generated job uses
|
||||
`source: issue_implementation`, `repair_strategy: new_fix_pr`, blocks merge and
|
||||
close actions, and reuses `clawsweeper/issue-<repo>-<number>` on reruns.
|
||||
Workers can reconstruct this minimal job from the requested `jobs/.../issue-*.md`
|
||||
path when a dispatch races ahead of state propagation, so the request does not
|
||||
silently skip as stale.
|
||||
After opening the PR, the worker updates the existing ClawSweeper command status
|
||||
comment with the generated PR link.
|
||||
When `CLAWSWEEPER_AUTO_IMPLEMENT_REPRO_BUGS=1`, review publish can also dispatch
|
||||
the same lane automatically for strict bug reports only: `item_category: bug`,
|
||||
`reproduction_status: reproduced`, `reproduction_confidence: high`, high
|
||||
@ -341,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
|
||||
|
||||
@ -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,8 +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 due records moving with up to 100 concurrent
|
||||
Codex review shards when backlog exists.
|
||||
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
|
||||
|
||||
@ -23,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
|
||||
@ -34,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
|
||||
|
||||
@ -58,9 +71,30 @@ 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;
|
||||
generated state is checked out afterward, just before publishing the review
|
||||
record, safe close result, and command-router ledger.
|
||||
|
||||
## Automerge Fast Path
|
||||
|
||||
@ -92,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
|
||||
@ -116,15 +157,28 @@ 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
|
||||
- manual normal backfill: defaults to 100 shards, batch size 3, scans up to 250
|
||||
GitHub pages unless overridden
|
||||
- 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 70 shards, batch size 3, scans up to 250
|
||||
GitHub pages unless overridden, and stops early once scanned due candidates
|
||||
fill planned capacity
|
||||
|
||||
The hard planner cap is 100 shards. The workflow clamps invalid or larger
|
||||
`shard_count` inputs to 100.
|
||||
@ -133,20 +187,53 @@ 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.
|
||||
|
||||
Read-only review shards use shallow ClawSweeper and generated-state checkouts.
|
||||
Publish and apply jobs keep full state history because they may rebase and push
|
||||
generated records.
|
||||
Each review shard also wraps the review command in a shell timeout derived from
|
||||
the per-item Codex timeout and the shard batch size, with a 70-minute ceiling so
|
||||
the job still has time to upload metrics and failed-shard artifacts. A hung
|
||||
review command therefore records a failed shard for the recovery lane instead
|
||||
of blocking the publish job until the 75-minute GitHub job timeout.
|
||||
|
||||
Read-only review shards use shallow ClawSweeper checkouts and skip generated
|
||||
state checkout entirely. The planner passes exact item numbers to each shard, so
|
||||
shards can fetch current GitHub item state and write review artifacts without
|
||||
hydrating historical records. Publish and apply jobs keep full state history
|
||||
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 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`. 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
|
||||
backlog found during the scan, not a full-repository count. This keeps
|
||||
continuation runs from spending minutes on extra GitHub page reads before the
|
||||
review shard matrix can start.
|
||||
|
||||
The optional in-progress dashboard publish in the plan job is capped at 20
|
||||
seconds. It is useful telemetry, but it must not delay the review shard matrix;
|
||||
the publish job writes the final dashboard state after review artifacts land.
|
||||
|
||||
## Cadence
|
||||
|
||||
The planner considers only open issues and PRs that pass `shouldPlanItem`.
|
||||
@ -187,7 +274,8 @@ 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"
|
||||
```
|
||||
|
||||
`pnpm run plan` returns:
|
||||
@ -195,10 +283,13 @@ pnpm run --silent plan -- \
|
||||
- `candidates`: selected open items
|
||||
- `shards`: selected item numbers distributed across shard jobs
|
||||
- `capacity`: `batch_size * clamped_shard_count`
|
||||
- `dueBacklog`: due candidates found during the scan
|
||||
- `dueBacklog`: due candidates found during the scan; on saturated queues this
|
||||
can be a lower bound because planning stops once capacity is full
|
||||
- `activeCodexTarget`: nonempty shard count
|
||||
- `oldestUnreviewedAt`: oldest scanned due candidate with no existing review
|
||||
- `capacityReason`: why the selected count did or did not fill capacity
|
||||
- `floorBackfill`: selected stale current-review candidates used to fill the
|
||||
active floor
|
||||
- `matrix`: GitHub Actions matrix entries
|
||||
|
||||
`pnpm run workflow -- plan-output` maps that JSON to GitHub Actions outputs:
|
||||
@ -223,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
|
||||
@ -238,6 +331,26 @@ 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
|
||||
`results/sweep-status/`; publish, apply, and audit still reconcile records before
|
||||
their state mutations where folder placement matters.
|
||||
|
||||
Read-only plan jobs hydrate generated state from a shallow `fetch-depth: 1`
|
||||
checkout. Review shard jobs skip generated-state hydration because the plan
|
||||
matrix already contains exact item numbers. Generated-state publish, apply, and
|
||||
audit jobs keep a full checkout because they may need to rebase and push state
|
||||
updates.
|
||||
|
||||
## Apply
|
||||
|
||||
Review is proposal-only. Apply is the only issue/PR scheduler path that mutates
|
||||
@ -249,11 +362,12 @@ association, paired issue/PR state, snapshot drift, and repository profile
|
||||
rules. It closes only unchanged high-confidence proposals and otherwise updates
|
||||
or syncs the durable ClawSweeper review comment.
|
||||
|
||||
Scheduled normal review publishes records first, then dispatches durable review
|
||||
comment sync into the separate apply/comment-sync lane. This keeps slow GitHub
|
||||
comment writes from holding the normal review concurrency group and delaying the
|
||||
next 100-shard backfill wave. Exact and manual targeted review runs still sync
|
||||
their selected comments inline before finishing.
|
||||
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 70-shard backfill
|
||||
wave. Exact issue/PR reviews and repository-dispatch item runs still sync their
|
||||
selected comments inline before finishing.
|
||||
|
||||
Long apply runs commit checkpoints and can dispatch continuation runs when they
|
||||
reach the configured close limit.
|
||||
@ -277,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.
|
||||
@ -319,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'
|
||||
|
||||
@ -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
|
||||
|
||||
64
docs/target-repositories.md
Normal file
64
docs/target-repositories.md
Normal 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`.
|
||||
@ -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
|
||||
|
||||
@ -45,9 +54,16 @@ have an open PR reference or existing ClawSweeper implementation PR, writes the
|
||||
normal `source: issue_implementation` job, commits the ledger, then dispatches
|
||||
`repair-cluster-worker.yml` in autonomous mode.
|
||||
|
||||
Comment-triggered issue implementation uses the same durable job format. If a
|
||||
worker starts before the new state commit is visible in its checkout, the worker
|
||||
reconstructs the minimal `source: issue_implementation` job from the job path
|
||||
and continues instead of treating the dispatch as stale.
|
||||
|
||||
PRs created from this path are labeled `clawsweeper` and
|
||||
`clawsweeper:autogenerated`. The lane is PR-only: it does not merge or close the
|
||||
source issue.
|
||||
source issue. When a worker opens the PR from a maintainer command, it edits the
|
||||
existing ClawSweeper command status comment with the generated PR link so the
|
||||
same comment moves from queued to opened.
|
||||
|
||||
Promote a candidate from this checkout:
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
16
package.json
16
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/clawsweeper",
|
||||
"version": "0.1.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,21 +58,22 @@
|
||||
"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",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260423.1",
|
||||
"oxfmt": "^0.46.0",
|
||||
"oxlint": "^1.61.0",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260503.1",
|
||||
"oxfmt": "^0.47.0",
|
||||
"oxlint": "^1.62.0",
|
||||
"oxlint-tsgolint": "0.22.1"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
390
pnpm-lock.yaml
generated
390
pnpm-lock.yaml
generated
@ -12,138 +12,138 @@ importers:
|
||||
specifier: ^25.6.0
|
||||
version: 25.6.0
|
||||
'@typescript/native-preview':
|
||||
specifier: 7.0.0-dev.20260423.1
|
||||
version: 7.0.0-dev.20260423.1
|
||||
specifier: 7.0.0-dev.20260503.1
|
||||
version: 7.0.0-dev.20260503.1
|
||||
oxfmt:
|
||||
specifier: ^0.46.0
|
||||
version: 0.46.0
|
||||
specifier: ^0.47.0
|
||||
version: 0.47.0
|
||||
oxlint:
|
||||
specifier: ^1.61.0
|
||||
version: 1.61.0(oxlint-tsgolint@0.22.1)
|
||||
specifier: ^1.62.0
|
||||
version: 1.62.0(oxlint-tsgolint@0.22.1)
|
||||
oxlint-tsgolint:
|
||||
specifier: 0.22.1
|
||||
version: 0.22.1
|
||||
|
||||
packages:
|
||||
|
||||
'@oxfmt/binding-android-arm-eabi@0.46.0':
|
||||
resolution: {integrity: sha512-b1doV4WRcJU+BESSlCvCjV+5CEr/T6h0frArAdV26Nir+gGNFNaylvDiiMPfF1pxeV0txZEs38ojzJaxBYg+ng==}
|
||||
'@oxfmt/binding-android-arm-eabi@0.47.0':
|
||||
resolution: {integrity: sha512-KrMQRdMi/upr81qT4ijK6X6BNp6jqpMY7FwILQnwIy9QLc3qpnhUx5rsCLGzn4ewsCQ0CNAspN2ogmP1GXLyLw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@oxfmt/binding-android-arm64@0.46.0':
|
||||
resolution: {integrity: sha512-v6+HhjsoV3GO0u2u9jLSAZrvWfTraDxKofUIQ7/ktS7tzS+epVsxdHmeM+XxuNcAY/nWxxU1Sg4JcGTNRXraBA==}
|
||||
'@oxfmt/binding-android-arm64@0.47.0':
|
||||
resolution: {integrity: sha512-r4ixS/PeUpAFKgrpDoZ5pSkthjZzVzKd95525Aazj+aOv9H4ulK5zYHGb7wFY5n5kZxHK8TbOJUZgoEb1ohddQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@oxfmt/binding-darwin-arm64@0.46.0':
|
||||
resolution: {integrity: sha512-3eeooJGrqGIlI5MyryDZsAcKXSmKIgAD4yYtfRrRJzXZ0UTFZtiSveIur56YPrGMYZwT4XyVhHsMqrNwr1XeFA==}
|
||||
'@oxfmt/binding-darwin-arm64@0.47.0':
|
||||
resolution: {integrity: sha512-CLWxiKpMl+195cm09CuaWEhJK0CirRkoMa07aR9+9AFPat2LfIKtwx1JqxZM0MTvcMe6+adlJNdVL6jdInvq3g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxfmt/binding-darwin-x64@0.46.0':
|
||||
resolution: {integrity: sha512-QG8BDM0CXWbu84k2SKmCqfEddPQPFiBicwtYnLqHRWZZl57HbtOLRMac/KTq2NO4AEc4ICCBpFxJIV9zcqYfkQ==}
|
||||
'@oxfmt/binding-darwin-x64@0.47.0':
|
||||
resolution: {integrity: sha512-Xq5fjTYDC50faUeLSm0rZdBqoTgleXEdD7NpJdARtQIczkCJn3xNjMUSQQkUmh4CtxkKTNL68lytcOK3e/osgg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxfmt/binding-freebsd-x64@0.46.0':
|
||||
resolution: {integrity: sha512-9DdCqS/n2ncu/Chazvt3cpgAjAmIGQDz7hFKSrNItMApyV/Ja9mz3hD4JakIE3nS8PW9smEbPWnb389QLBY4nw==}
|
||||
'@oxfmt/binding-freebsd-x64@0.47.0':
|
||||
resolution: {integrity: sha512-QOU9ZIJ52p5askcEC0QJvvr8trHAWoonul8bgISo6gYUL3s50zkqafBYcNAr9LJZQbsZtPfIWHk9+5+nUp1qJQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@oxfmt/binding-linux-arm-gnueabihf@0.46.0':
|
||||
resolution: {integrity: sha512-Dgs7VeE2jT0LHMhw6tPEt0xQYe54kBqHEovmWsv4FVQlegCOvlIJNx0S8n4vj8WUtpT+Z6BD2HhKJPLglLxvZg==}
|
||||
'@oxfmt/binding-linux-arm-gnueabihf@0.47.0':
|
||||
resolution: {integrity: sha512-oJxDM1aBhPvz9gmElBv8UpxyiqhwfjcbrSxT5F0xtuUzY6dQI27/AQPIt3eu3Z5Yvn0kQl5R7MA3Z+MbnRvCBw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxfmt/binding-linux-arm-musleabihf@0.46.0':
|
||||
resolution: {integrity: sha512-Zxn3adhTH13JKnU4xXJj8FeEfF680XjXh3gSShKl57HCMBRde2tUJTgogV/1MSHA80PJEVrDa7r66TLVq3Ia7Q==}
|
||||
'@oxfmt/binding-linux-arm-musleabihf@0.47.0':
|
||||
resolution: {integrity: sha512-g8Lh50VS4ibGz2q6v7r9UZY4D0dM16SdrFYOMzhqIoCwGcai8VMIRUAcqn1/jlCsOOzUXJ741+kCeJt0cofakQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxfmt/binding-linux-arm64-gnu@0.46.0':
|
||||
resolution: {integrity: sha512-+TWipjrgVM8D7aIdDD0tlr3teLTTvQTn7QTE5BpT10H1Fj82gfdn9X6nn2sDgx/MepuSCfSnzFNJq2paLL0OiA==}
|
||||
'@oxfmt/binding-linux-arm64-gnu@0.47.0':
|
||||
resolution: {integrity: sha512-YrNT1vQ0asaXoRbrvYENPqmBfOQ9Xr8enPNOULeYfg44VjCcrUowFy5QZr+WawE0zyP8cH9e9Gxxg0fDEFzhcg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-arm64-musl@0.46.0':
|
||||
resolution: {integrity: sha512-aAUPBWJ1lGwwnxZUEDLJ94+Iy6MuwJwPxUgO4sCA5mEEyDk7b+cDQ+JpX1VR150Zoyd+D49gsrUzpUK5h587Eg==}
|
||||
'@oxfmt/binding-linux-arm64-musl@0.47.0':
|
||||
resolution: {integrity: sha512-IxtQC/sbBi4ubbY+MdwdanRWrG9InQJVZqyMsBa5IUaQcnSg86gQme574HxXMC1p4bo4YhV99zQ+wNnGCvEgzw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-linux-ppc64-gnu@0.46.0':
|
||||
resolution: {integrity: sha512-ufBCJukyFX/UDrokP/r6BGDoTInnsDs7bxyzKAgMiZlt2Qu8GPJSJ6Zm6whIiJzKk0naxA8ilwmbO1LMw6Htxw==}
|
||||
'@oxfmt/binding-linux-ppc64-gnu@0.47.0':
|
||||
resolution: {integrity: sha512-EWXEhOMbWO0q6eJSbu0QLkU8cKi0ljlYLngeDs2Ocu/pm1rrLwyQiYzlFbdnMRURI4w9ndr1sI9rSbhlJ5o23Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-gnu@0.46.0':
|
||||
resolution: {integrity: sha512-eqtlC2YmPqjun76R1gVfGLuKWx7NuEnLEAudZ7n6ipSKbCZTqIKSs1b5Y8K/JHZsRpLkeSmAAjig5HOIg8fQzQ==}
|
||||
'@oxfmt/binding-linux-riscv64-gnu@0.47.0':
|
||||
resolution: {integrity: sha512-tZrjS11TUiDuEpRaqdk8K9F9xETRyKXfuZKmdeW+Gj7coBnm7+8sBEfyt033EAFEQSlkniAXvBLh+Qja2ioGBQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-musl@0.46.0':
|
||||
resolution: {integrity: sha512-yccVOO2nMXkQLGgy0He3EQEwKD7NF0zEk+/OWmroznkqXyJdN6bfK0LtNnr6/14Bh3FjpYq7bP33l/VloCnxpA==}
|
||||
'@oxfmt/binding-linux-riscv64-musl@0.47.0':
|
||||
resolution: {integrity: sha512-KBFy+2CFKUCZzYwX2ZOPQKck1vjQbz+hextuc19G4r0WRJwadfAeuQMQRQvB+Ivc8brlbOVg7et8K7E467440g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-linux-s390x-gnu@0.46.0':
|
||||
resolution: {integrity: sha512-aAf7fG23OQCey6VRPj9IeCraoYtpgtx0ZyJ1CXkPyT1wjzBE7c3xtuxHe/AdHaJfVVb/SXpSk8Gl1LzyQupSqw==}
|
||||
'@oxfmt/binding-linux-s390x-gnu@0.47.0':
|
||||
resolution: {integrity: sha512-REUPFKVGSiK99B+9eaPhluEVglzaoj/SMykNC5SUiV2RSsBfV5lWN7Y0iCIc251Wz3GaeAGZsJ/zj3gjarxdFg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-x64-gnu@0.46.0':
|
||||
resolution: {integrity: sha512-q0JPsTMyJNjYrBvYFDz4WbVsafNZaPCZv4RnFypRotLqpKROtBZcEaXQW4eb9YmvLU3NckVemLJnzkSZSdmOxw==}
|
||||
'@oxfmt/binding-linux-x64-gnu@0.47.0':
|
||||
resolution: {integrity: sha512-KVftVSVEDeIfRW3TIeLe3aNI/iY4m1fu5mDwHcisKMZSCMKLkrhFsjowC7o9RoqNPxbbglm2+/6KAKBIts2t0Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-x64-musl@0.46.0':
|
||||
resolution: {integrity: sha512-7LsLY9Cw57GPkhSR+duI3mt9baRczK/DtHYSldQ4BEU92da9igBQNl4z7Vq5U9NNPsh1FmpKvv1q9WDtiUQR1A==}
|
||||
'@oxfmt/binding-linux-x64-musl@0.47.0':
|
||||
resolution: {integrity: sha512-DTsmGEaA2860Aq5VUyDO8/MT9NFxwVL93RnRYmpMwK6DsSkThmvEpqoUDDljziEpAedMRG19SCogrNbINSbLUQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-openharmony-arm64@0.46.0':
|
||||
resolution: {integrity: sha512-lHiBOz8Duaku7JtRNLlps3j++eOaICPZSd8FCVmTDM4DFOPT71Bjn7g6iar1z7StXlKRweUKxWUs4sA+zWGDXg==}
|
||||
'@oxfmt/binding-openharmony-arm64@0.47.0':
|
||||
resolution: {integrity: sha512-8r5BDro7fLOBoq1JXHLVSs55OlrxQhEso4HVo0TcY7OXJUPYfjPoOaYL5us+yIwqyP9rQwN+rxuiNFSmaxSuOQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@oxfmt/binding-win32-arm64-msvc@0.46.0':
|
||||
resolution: {integrity: sha512-/5ktYUliP89RhgC37DBH1x20U5zPSZMy3cMEcO0j3793rbHP9MWsknBwQB6eozRzWmYrh0IFM/p20EbPvDlYlg==}
|
||||
'@oxfmt/binding-win32-arm64-msvc@0.47.0':
|
||||
resolution: {integrity: sha512-qtz/gzm8IjSPUlseZ0ofW8zyHLoZsuP5HTfcGGkWkUblB89JT8GNYH3ICqjbDsqsGqXum0/ZndXTFplSdXFIcg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@oxfmt/binding-win32-ia32-msvc@0.46.0':
|
||||
resolution: {integrity: sha512-3WTnoiuIr8XvV0DIY7SN+1uJSwKf4sPpcbHfobcRT9JutGcLaef/miyBB87jxd3aqH+mS0+G5lsgHuXLUwjjpQ==}
|
||||
'@oxfmt/binding-win32-ia32-msvc@0.47.0':
|
||||
resolution: {integrity: sha512-5vIcdcIDE7nCx+MXN6sm8kbC4zajDB31E86rez4i45iHNH/2NjdKlJ720xcHTr3eeiMcttCGPHPhE1TjtBDGZw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@oxfmt/binding-win32-x64-msvc@0.46.0':
|
||||
resolution: {integrity: sha512-IXxiQpkYnOwNfP23vzwSfhdpxJzyiPTY7eTn6dn3DsriKddESzM8i6kfq9R7CD/PUJwCvQT22NgtygBeug3KoA==}
|
||||
'@oxfmt/binding-win32-x64-msvc@0.47.0':
|
||||
resolution: {integrity: sha512-Sr59Y5ms54ONBjxFeWhVlGyQcHXxcl9DxC23f6yXlRkcos7LXBLoO+KDfxexjHIOZh7cWqrWduzvUjJ+pHp8cQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@ -178,124 +178,124 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint/binding-android-arm-eabi@1.61.0':
|
||||
resolution: {integrity: sha512-6eZBPgiigK5txqoVgRqxbaxiom4lM8AP8CyKPPvpzKnQ3iFRFOIDc+0AapF+qsUSwjOzr5SGk4SxQDpQhkSJMQ==}
|
||||
'@oxlint/binding-android-arm-eabi@1.62.0':
|
||||
resolution: {integrity: sha512-pKsthNECyvJh8lPTICz6VcwVy2jOqdhhsp1rlxCkhgZR47aKvXPmaRWQDv+zlXpRae4qm1MaaTnutkaOk5aofg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@oxlint/binding-android-arm64@1.61.0':
|
||||
resolution: {integrity: sha512-CkwLR69MUnyv5wjzebvbbtTSUwqLxM35CXE79bHqDIK+NtKmPEUpStTcLQRZMCo4MP0qRT6TXIQVpK0ZVScnMA==}
|
||||
'@oxlint/binding-android-arm64@1.62.0':
|
||||
resolution: {integrity: sha512-b1AUNViByvgmR2xJDubvLIr+dSuu3uraG7bsAoKo+xrpspPvu6RIn6Fhr2JUhobfep3jwUTy18Huco6GkwdvGQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@oxlint/binding-darwin-arm64@1.61.0':
|
||||
resolution: {integrity: sha512-8JbefTkbmvqkqWjmQrHke+MdpgT2UghhD/ktM4FOQSpGeCgbMToJEKdl9zwhr/YWTl92i4QI1KiTwVExpcUN8A==}
|
||||
'@oxlint/binding-darwin-arm64@1.62.0':
|
||||
resolution: {integrity: sha512-iG+Tvf70UJ6otfwFYIHk36Sjq9cpPP5YLxkoggANNRtzgi3Tj3g8q6Ybqi6AtkU3+yg9QwF7bDCkCS6bbL4PCg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxlint/binding-darwin-x64@1.61.0':
|
||||
resolution: {integrity: sha512-uWpoxDT47hTnDLcdEh5jVbso8rlTTu5o0zuqa9J8E0JAKmIWn7kGFEIB03Pycn2hd2vKxybPGLhjURy/9We5FQ==}
|
||||
'@oxlint/binding-darwin-x64@1.62.0':
|
||||
resolution: {integrity: sha512-oOWI6YPPr5AJUx+yIDlxmuUbQjS5gZX3OH3QisawYvsZgLiQVvZtR0rPBcJTxLWqt2ClrWg0DlSrlUiG5SQNHg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxlint/binding-freebsd-x64@1.61.0':
|
||||
resolution: {integrity: sha512-K/o4hEyW7flfMel0iBVznmMBt7VIMHGdjADocHKpK1DUF9erpWnJ+BSSWd2W0c8K3mPtpph+CuHzRU6CI3l9jQ==}
|
||||
'@oxlint/binding-freebsd-x64@1.62.0':
|
||||
resolution: {integrity: sha512-dLP33T7VLCmLVv4cvjkVX+rmkcwNk2UfxmsZPNur/7BQHoQR60zJ7XLiRvNUawlzn0u8ngCa3itjEG73MAMa/w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@oxlint/binding-linux-arm-gnueabihf@1.61.0':
|
||||
resolution: {integrity: sha512-P6040ZkcyweJ0Po9yEFqJCdvZnf3VNCGs1SIHgXDf8AAQNC6ID/heXQs9iSgo2FH7gKaKq32VWc59XZwL34C5Q==}
|
||||
'@oxlint/binding-linux-arm-gnueabihf@1.62.0':
|
||||
resolution: {integrity: sha512-fl//LWNks6qo9chNY60UDYyIwtp7a5cEx4Y/rHPjaarhuwqx6jtbzEpD5V5AqmdL4a6Y5D8zeXg5HF2Cr0QmSQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint/binding-linux-arm-musleabihf@1.61.0':
|
||||
resolution: {integrity: sha512-bwxrGCzTZkuB+THv2TQ1aTkVEfv5oz8sl+0XZZCpoYzErJD8OhPQOTA0ENPd1zJz8QsVdSzSrS2umKtPq4/JXg==}
|
||||
'@oxlint/binding-linux-arm-musleabihf@1.62.0':
|
||||
resolution: {integrity: sha512-i5vkAuxvueTODV3J2dL61/TXewDHhMFKvtD156cIsk7GsdfiAu7zW7kY0NJXhKeFHeiMZIh7eFNjkPYH6J47HQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint/binding-linux-arm64-gnu@1.61.0':
|
||||
resolution: {integrity: sha512-vkhb9/wKguMkLlrm3FoJW/Xmdv31GgYAE+x8lxxQ+7HeOxXUySI0q36a3NTVIuQUdLzxCI1zzMGsk1o37FOe3w==}
|
||||
'@oxlint/binding-linux-arm64-gnu@1.62.0':
|
||||
resolution: {integrity: sha512-QwN19LLuIGuOjEflSeJkZmOTfBdBMlTmW8xbMf8TZhjd//cxVNYQPq75q7oKZBJc6hRx3gY7sX0Egc8cEIFZYg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-arm64-musl@1.61.0':
|
||||
resolution: {integrity: sha512-bl1dQh8LnVqsj6oOQAcxwbuOmNJkwc4p6o//HTBZhNTzJy21TLDwAviMqUFNUxDHkPGpmdKTSN4tWTjLryP8xg==}
|
||||
'@oxlint/binding-linux-arm64-musl@1.62.0':
|
||||
resolution: {integrity: sha512-8eCy3FCDuWUM5hWujAv6heMvfZPbcCOU3SdQUAkixZLu5bSzOkNfirJiLGoQFO943xceOKkiQRMQNzH++jM3WA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-linux-ppc64-gnu@1.61.0':
|
||||
resolution: {integrity: sha512-QoOX6KB2IiEpyOj/HKqaxi+NQHPnOgNgnr22n9N4ANJCzXkUlj1UmeAbFb4PpqdlHIzvGDM5xZ0OKtcLq9RhiQ==}
|
||||
'@oxlint/binding-linux-ppc64-gnu@1.62.0':
|
||||
resolution: {integrity: sha512-NjQ7K7tpTPDe9J+yq8p/s/J0E7lRCkK2uDBDqvT4XIT6f4Z0tlnr59OBg/WcrmVHER1AbrcfyxhGTXgcG8ytWg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-gnu@1.61.0':
|
||||
resolution: {integrity: sha512-1TGcTerjY6p152wCof3oKElccq3xHljS/Mucp04gV/4ATpP6nO7YNnp7opEg6SHkv2a57/b4b8Ndm9znJ1/qAw==}
|
||||
'@oxlint/binding-linux-riscv64-gnu@1.62.0':
|
||||
resolution: {integrity: sha512-oKZed9gmSwze29dEt3/Wnsv6l/Ygw/FUst+8Kfpv2SGeS/glEoTGZAMQw37SVyzFV76UTHJN2snGgxK2t2+8ow==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-musl@1.61.0':
|
||||
resolution: {integrity: sha512-65wXEmZIrX2ADwC8i/qFL4EWLSbeuBpAm3suuX1vu4IQkKd+wLT/HU/BOl84kp91u2SxPkPDyQgu4yrqp8vwVA==}
|
||||
'@oxlint/binding-linux-riscv64-musl@1.62.0':
|
||||
resolution: {integrity: sha512-gBjBxQ+9lGpAYq+ELqw0w8QXsBnkZclFc7GRX2r0LnEVn3ZTEqeIKpKcGjucmp76Q53bvJD0i4qBWBhcfhSfGA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-linux-s390x-gnu@1.61.0':
|
||||
resolution: {integrity: sha512-TVvhgMvor7Qa6COeXxCJ7ENOM+lcAOGsQ0iUdPSCv2hxb9qSHLQ4XF1h50S6RE1gBOJ0WV3rNukg4JJJP1LWRA==}
|
||||
'@oxlint/binding-linux-s390x-gnu@1.62.0':
|
||||
resolution: {integrity: sha512-Ew2Kxs9EQ9/mbAIJ2hvocMC0wsOu6YKzStI2eFBDt+Td5O8seVC/oxgRIHqCcl5sf5ratA1nozQBAuv7tphkHg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-x64-gnu@1.61.0':
|
||||
resolution: {integrity: sha512-SjpS5uYuFoDnDdZPwZE59ndF95AsY47R5MliuneTWR1pDm2CxGJaYXbKULI71t5TVfLQUWmrHEGRL9xvuq6dnA==}
|
||||
'@oxlint/binding-linux-x64-gnu@1.62.0':
|
||||
resolution: {integrity: sha512-5z25jcAA0gfKyVwz71A0VXgaPlocPoTAxhlv/hgoK6tlCrfoNuw7haWbDHvGMfjXhdic4EqVXGRv5XsTqFnbRQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-x64-musl@1.61.0':
|
||||
resolution: {integrity: sha512-gGfAeGD4sNJGILZbc/yKcIimO9wQnPMoYp9swAaKeEtwsSQAbU+rsdQze5SBtIP6j0QDzeYd4XSSUCRCF+LIeQ==}
|
||||
'@oxlint/binding-linux-x64-musl@1.62.0':
|
||||
resolution: {integrity: sha512-IWpHmMB6ZDllPvqWDkG6AmXrN7JF5e/c4g/0PuURsmlK+vHoYZPB70rr4u1bn3I4LsKCSpqqfveyx6UCOC8wdg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-openharmony-arm64@1.61.0':
|
||||
resolution: {integrity: sha512-OlVT0LrG/ct33EVtWRyR+B/othwmDWeRxfi13wUdPeb3lAT5TgTcFDcfLfarZtzB4W1nWF/zICMgYdkggX2WmQ==}
|
||||
'@oxlint/binding-openharmony-arm64@1.62.0':
|
||||
resolution: {integrity: sha512-fjlSxxrD5pA594vkyikCS9MnPRjQawW6/BLgyTYkO+73wwPlYjkcZ7LSd974l0Q2zkHQmu4DPvJFLYA7o8xrxQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@oxlint/binding-win32-arm64-msvc@1.61.0':
|
||||
resolution: {integrity: sha512-vI//NZPJk6DToiovPtaiwD4iQ7kO1r5ReWQD0sOOyKRtP3E2f6jxin4uvwi3OvDzHA2EFfd7DcZl5dtkQh7g1w==}
|
||||
'@oxlint/binding-win32-arm64-msvc@1.62.0':
|
||||
resolution: {integrity: sha512-EiFXr8loNS0Ul3Gu80+9nr1T8jRmnKocqmHHg16tj5ZqTgUXyb97l2rrspVHdDluyFn9JfR4PoJFdNzw4paHww==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint/binding-win32-ia32-msvc@1.61.0':
|
||||
resolution: {integrity: sha512-0ySj4/4zd2XjePs3XAQq7IigIstN4LPQZgCyigX5/ERMLjdWAJfnxcTsrtxZxuij8guJW8foXuHmhGxW0H4dDA==}
|
||||
'@oxlint/binding-win32-ia32-msvc@1.62.0':
|
||||
resolution: {integrity: sha512-IgOFvL73li1bFgab+hThXYA0N2Xms2kV2MvZN95cebV+fmrZ9AVui1JSxfeeqRLo3CpPxKZlzhyq4G0cnaAvIw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint/binding-win32-x64-msvc@1.61.0':
|
||||
resolution: {integrity: sha512-0xgSiyeqDLDZxXoe9CVJrOx3TUVsfyoOY7cNi03JbItNcC9WCZqrSNdrAbHONxhSPaVh/lzfnDcON1RqSUMhHw==}
|
||||
'@oxlint/binding-win32-x64-msvc@1.62.0':
|
||||
resolution: {integrity: sha512-6hMpyDWQ2zGA1OXFKBrdYMUveUCO8UJhkO6JdwZPd78xIdHZNhjx+pib+4fC2Cljuhjyl0QwA2F3df/bs4Bp6A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@ -303,55 +303,55 @@ packages:
|
||||
'@types/node@25.6.0':
|
||||
resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
|
||||
|
||||
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260423.1':
|
||||
resolution: {integrity: sha512-wbLr6o5fROaCYt6cOpFhbe92FJAOdhAHwm/s8I/IyN5HbL1ULgel/wHaZiR+ws+27rgruNUiCENzTUg9vSz2bA==}
|
||||
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260503.1':
|
||||
resolution: {integrity: sha512-rUZQVuBcZlxADagx+pnhDHqjX2Ewh+KWave6vtilRWR5vsGyR3FaCzPFqXteiwPg6XRijEMnMaXg60t5itm8EA==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260423.1':
|
||||
resolution: {integrity: sha512-13MpNT+4MgkgrfiW2u03rnER5aB3yz9fA0bWEYh6IH3rIqA2AR3Dntp3QXW4sQrZf0SriXqHe2R7X3HCT5xmqA==}
|
||||
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260503.1':
|
||||
resolution: {integrity: sha512-YtK8ac8RCkRh7jwoP8Wt4LaBhpGK8cOVMi3e0cvF/8Xn5XV1ewJK3/HdNBnlDhCib3j0eVRxK/Pg9GIq/hFUxQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260423.1':
|
||||
resolution: {integrity: sha512-ICIkJDTqmn0R4Vs811+Ht2RYTk1OCrAhHCu0JthmhR216T1Tqgi5DWRoCprp3RL1qU6fLnxxrIpEbNlNN7XFYA==}
|
||||
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260503.1':
|
||||
resolution: {integrity: sha512-c5yM3Ea2WZSLKmscMxUhEDdaR2Ugp7P9CX6osciIPgZIF9c4LDiuKQohjQAyidx9JZKCsSXnfS88QssWH/+ONQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/native-preview-linux-arm@7.0.0-dev.20260423.1':
|
||||
resolution: {integrity: sha512-CxUA15qbPQRvz2nanBpiv1h4tgXTCJJwqOtgKMSdIuPkow8dyYW3ba5oLoH/jZhS4792XislX659hlFrfiU6CQ==}
|
||||
'@typescript/native-preview-linux-arm@7.0.0-dev.20260503.1':
|
||||
resolution: {integrity: sha512-28vAomYeU8hpz1f0dDoWz6PYehz0ffEfkJhFq4tqXzUM/QY1I15u5y03EJQ5UcP4LRwrdJe22J5LdrYpM2Lr3w==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/native-preview-linux-x64@7.0.0-dev.20260423.1':
|
||||
resolution: {integrity: sha512-cWLFS4R8dOU1YuUJ/2VLeGMVIjgI3GGb/f9rRY5MbWHq5l3NNZh8y1QwAOrTh3+g3q6+znArfxVnD2hZHUz8Mw==}
|
||||
'@typescript/native-preview-linux-x64@7.0.0-dev.20260503.1':
|
||||
resolution: {integrity: sha512-M64z7LwpqNfOXYCBKmD/ObwyxYOobUk4tDv0ECNLit7pDER1sswNZjJGjgRYjQsKokmydy6p3FqtJ1uUPUP/sw==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260423.1':
|
||||
resolution: {integrity: sha512-OWaGUI4+dHqYZv+k6sITx9Y27FNy3XzNFk4OrOiYtBkIO/xrb9TPMP4A5XI4n5zwRLIv3xne9g039xgRbaeyoQ==}
|
||||
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260503.1':
|
||||
resolution: {integrity: sha512-QHy3R1VBb8MYszjpz0IyuDjKaW6JUHg8hYEqzhZIxvrydKLF3gyDeKohnmWSFTS/oII0GvUmYM3h83fbanBvcQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@typescript/native-preview-win32-x64@7.0.0-dev.20260423.1':
|
||||
resolution: {integrity: sha512-5MQjO/qdLwXpjW7Dy/1lNv7Vtpvo6bhCkbjan4PoRN5/eeyqEqDWxdf8AGE4btLmHqyIjEHRuYf7kp2tlAr6lQ==}
|
||||
'@typescript/native-preview-win32-x64@7.0.0-dev.20260503.1':
|
||||
resolution: {integrity: sha512-nrapqpVONrg0IZjKp3AzV9M+jxPwKGTQZEOxuKh6WHMhy/UElN8RnOFlfetA8ZMtKvPkmjgjqw0qoAa5QMwhPA==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@typescript/native-preview@7.0.0-dev.20260423.1':
|
||||
resolution: {integrity: sha512-9WD7TJJlGvt9PQqJI/+44dVP4oqGQFIkYrpXt7nlQ0WgNIErN52x/XhxmJ4nWft06qejgPiUbPo4aYRNOmIHXg==}
|
||||
'@typescript/native-preview@7.0.0-dev.20260503.1':
|
||||
resolution: {integrity: sha512-gDro38CPFiBUGbaFGNt+ufOsEd1OrZrfrOPxsLSfBcvvoGaqAxV++ul/BHTOShoEkIYHiFsoDX2az1IPCDV2jQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
hasBin: true
|
||||
|
||||
oxfmt@0.46.0:
|
||||
resolution: {integrity: sha512-CopwJOwPAjZ9p76fCvz+mSOJTw9/NY3cSksZK3VO/bUQ8UoEcketNgUuYS0UB3p+R9XnXe7wGGXUmyFxc7QxJA==}
|
||||
oxfmt@0.47.0:
|
||||
resolution: {integrity: sha512-OFbkbzxKCpooQEnRmpTDnuwTX8KHXzZTQ4Df/hz85fpS67Pl+lxPEFvUtin56HIIS0B1k4X8oIzTXRZPufA2CA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
@ -359,8 +359,8 @@ packages:
|
||||
resolution: {integrity: sha512-YUSGSLUnoolsu8gxISEDio3q1rtsCozwfOzASUn3DT2mR2EeQ93uEEnen7s+6LpF+lyTQFln1pQfqwBh/fsVEg==}
|
||||
hasBin: true
|
||||
|
||||
oxlint@1.61.0:
|
||||
resolution: {integrity: sha512-ZC0ALuhDZ6ivOFG+sy0D0pEDN49EvsId98zVlmYdkcXHsEM14m/qTNUEsUpiFiCVbpIxYtVBmmLE87nsbUHohQ==}
|
||||
oxlint@1.62.0:
|
||||
resolution: {integrity: sha512-1uFkg6HakjsGIpW9wNdeW4/2LOHW9MEkoWjZUTUfQtIHyLIZPYt00w3Sg+H3lH+206FgBPHBbW5dVE5l2ExECQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@ -378,61 +378,61 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@oxfmt/binding-android-arm-eabi@0.46.0':
|
||||
'@oxfmt/binding-android-arm-eabi@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-android-arm64@0.46.0':
|
||||
'@oxfmt/binding-android-arm64@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-darwin-arm64@0.46.0':
|
||||
'@oxfmt/binding-darwin-arm64@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-darwin-x64@0.46.0':
|
||||
'@oxfmt/binding-darwin-x64@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-freebsd-x64@0.46.0':
|
||||
'@oxfmt/binding-freebsd-x64@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-arm-gnueabihf@0.46.0':
|
||||
'@oxfmt/binding-linux-arm-gnueabihf@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-arm-musleabihf@0.46.0':
|
||||
'@oxfmt/binding-linux-arm-musleabihf@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-arm64-gnu@0.46.0':
|
||||
'@oxfmt/binding-linux-arm64-gnu@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-arm64-musl@0.46.0':
|
||||
'@oxfmt/binding-linux-arm64-musl@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-ppc64-gnu@0.46.0':
|
||||
'@oxfmt/binding-linux-ppc64-gnu@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-gnu@0.46.0':
|
||||
'@oxfmt/binding-linux-riscv64-gnu@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-musl@0.46.0':
|
||||
'@oxfmt/binding-linux-riscv64-musl@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-s390x-gnu@0.46.0':
|
||||
'@oxfmt/binding-linux-s390x-gnu@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-x64-gnu@0.46.0':
|
||||
'@oxfmt/binding-linux-x64-gnu@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-linux-x64-musl@0.46.0':
|
||||
'@oxfmt/binding-linux-x64-musl@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-openharmony-arm64@0.46.0':
|
||||
'@oxfmt/binding-openharmony-arm64@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-win32-arm64-msvc@0.46.0':
|
||||
'@oxfmt/binding-win32-arm64-msvc@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-win32-ia32-msvc@0.46.0':
|
||||
'@oxfmt/binding-win32-ia32-msvc@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxfmt/binding-win32-x64-msvc@0.46.0':
|
||||
'@oxfmt/binding-win32-x64-msvc@0.47.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint-tsgolint/darwin-arm64@0.22.1':
|
||||
@ -453,121 +453,121 @@ snapshots:
|
||||
'@oxlint-tsgolint/win32-x64@0.22.1':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-android-arm-eabi@1.61.0':
|
||||
'@oxlint/binding-android-arm-eabi@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-android-arm64@1.61.0':
|
||||
'@oxlint/binding-android-arm64@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-darwin-arm64@1.61.0':
|
||||
'@oxlint/binding-darwin-arm64@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-darwin-x64@1.61.0':
|
||||
'@oxlint/binding-darwin-x64@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-freebsd-x64@1.61.0':
|
||||
'@oxlint/binding-freebsd-x64@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-arm-gnueabihf@1.61.0':
|
||||
'@oxlint/binding-linux-arm-gnueabihf@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-arm-musleabihf@1.61.0':
|
||||
'@oxlint/binding-linux-arm-musleabihf@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-arm64-gnu@1.61.0':
|
||||
'@oxlint/binding-linux-arm64-gnu@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-arm64-musl@1.61.0':
|
||||
'@oxlint/binding-linux-arm64-musl@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-ppc64-gnu@1.61.0':
|
||||
'@oxlint/binding-linux-ppc64-gnu@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-riscv64-gnu@1.61.0':
|
||||
'@oxlint/binding-linux-riscv64-gnu@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-riscv64-musl@1.61.0':
|
||||
'@oxlint/binding-linux-riscv64-musl@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-s390x-gnu@1.61.0':
|
||||
'@oxlint/binding-linux-s390x-gnu@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-x64-gnu@1.61.0':
|
||||
'@oxlint/binding-linux-x64-gnu@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-x64-musl@1.61.0':
|
||||
'@oxlint/binding-linux-x64-musl@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-openharmony-arm64@1.61.0':
|
||||
'@oxlint/binding-openharmony-arm64@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-win32-arm64-msvc@1.61.0':
|
||||
'@oxlint/binding-win32-arm64-msvc@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-win32-ia32-msvc@1.61.0':
|
||||
'@oxlint/binding-win32-ia32-msvc@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-win32-x64-msvc@1.61.0':
|
||||
'@oxlint/binding-win32-x64-msvc@1.62.0':
|
||||
optional: true
|
||||
|
||||
'@types/node@25.6.0':
|
||||
dependencies:
|
||||
undici-types: 7.19.2
|
||||
|
||||
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260423.1':
|
||||
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260503.1':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260423.1':
|
||||
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260503.1':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260423.1':
|
||||
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260503.1':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-linux-arm@7.0.0-dev.20260423.1':
|
||||
'@typescript/native-preview-linux-arm@7.0.0-dev.20260503.1':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-linux-x64@7.0.0-dev.20260423.1':
|
||||
'@typescript/native-preview-linux-x64@7.0.0-dev.20260503.1':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260423.1':
|
||||
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260503.1':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-win32-x64@7.0.0-dev.20260423.1':
|
||||
'@typescript/native-preview-win32-x64@7.0.0-dev.20260503.1':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview@7.0.0-dev.20260423.1':
|
||||
'@typescript/native-preview@7.0.0-dev.20260503.1':
|
||||
optionalDependencies:
|
||||
'@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260423.1
|
||||
'@typescript/native-preview-darwin-x64': 7.0.0-dev.20260423.1
|
||||
'@typescript/native-preview-linux-arm': 7.0.0-dev.20260423.1
|
||||
'@typescript/native-preview-linux-arm64': 7.0.0-dev.20260423.1
|
||||
'@typescript/native-preview-linux-x64': 7.0.0-dev.20260423.1
|
||||
'@typescript/native-preview-win32-arm64': 7.0.0-dev.20260423.1
|
||||
'@typescript/native-preview-win32-x64': 7.0.0-dev.20260423.1
|
||||
'@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260503.1
|
||||
'@typescript/native-preview-darwin-x64': 7.0.0-dev.20260503.1
|
||||
'@typescript/native-preview-linux-arm': 7.0.0-dev.20260503.1
|
||||
'@typescript/native-preview-linux-arm64': 7.0.0-dev.20260503.1
|
||||
'@typescript/native-preview-linux-x64': 7.0.0-dev.20260503.1
|
||||
'@typescript/native-preview-win32-arm64': 7.0.0-dev.20260503.1
|
||||
'@typescript/native-preview-win32-x64': 7.0.0-dev.20260503.1
|
||||
|
||||
oxfmt@0.46.0:
|
||||
oxfmt@0.47.0:
|
||||
dependencies:
|
||||
tinypool: 2.1.0
|
||||
optionalDependencies:
|
||||
'@oxfmt/binding-android-arm-eabi': 0.46.0
|
||||
'@oxfmt/binding-android-arm64': 0.46.0
|
||||
'@oxfmt/binding-darwin-arm64': 0.46.0
|
||||
'@oxfmt/binding-darwin-x64': 0.46.0
|
||||
'@oxfmt/binding-freebsd-x64': 0.46.0
|
||||
'@oxfmt/binding-linux-arm-gnueabihf': 0.46.0
|
||||
'@oxfmt/binding-linux-arm-musleabihf': 0.46.0
|
||||
'@oxfmt/binding-linux-arm64-gnu': 0.46.0
|
||||
'@oxfmt/binding-linux-arm64-musl': 0.46.0
|
||||
'@oxfmt/binding-linux-ppc64-gnu': 0.46.0
|
||||
'@oxfmt/binding-linux-riscv64-gnu': 0.46.0
|
||||
'@oxfmt/binding-linux-riscv64-musl': 0.46.0
|
||||
'@oxfmt/binding-linux-s390x-gnu': 0.46.0
|
||||
'@oxfmt/binding-linux-x64-gnu': 0.46.0
|
||||
'@oxfmt/binding-linux-x64-musl': 0.46.0
|
||||
'@oxfmt/binding-openharmony-arm64': 0.46.0
|
||||
'@oxfmt/binding-win32-arm64-msvc': 0.46.0
|
||||
'@oxfmt/binding-win32-ia32-msvc': 0.46.0
|
||||
'@oxfmt/binding-win32-x64-msvc': 0.46.0
|
||||
'@oxfmt/binding-android-arm-eabi': 0.47.0
|
||||
'@oxfmt/binding-android-arm64': 0.47.0
|
||||
'@oxfmt/binding-darwin-arm64': 0.47.0
|
||||
'@oxfmt/binding-darwin-x64': 0.47.0
|
||||
'@oxfmt/binding-freebsd-x64': 0.47.0
|
||||
'@oxfmt/binding-linux-arm-gnueabihf': 0.47.0
|
||||
'@oxfmt/binding-linux-arm-musleabihf': 0.47.0
|
||||
'@oxfmt/binding-linux-arm64-gnu': 0.47.0
|
||||
'@oxfmt/binding-linux-arm64-musl': 0.47.0
|
||||
'@oxfmt/binding-linux-ppc64-gnu': 0.47.0
|
||||
'@oxfmt/binding-linux-riscv64-gnu': 0.47.0
|
||||
'@oxfmt/binding-linux-riscv64-musl': 0.47.0
|
||||
'@oxfmt/binding-linux-s390x-gnu': 0.47.0
|
||||
'@oxfmt/binding-linux-x64-gnu': 0.47.0
|
||||
'@oxfmt/binding-linux-x64-musl': 0.47.0
|
||||
'@oxfmt/binding-openharmony-arm64': 0.47.0
|
||||
'@oxfmt/binding-win32-arm64-msvc': 0.47.0
|
||||
'@oxfmt/binding-win32-ia32-msvc': 0.47.0
|
||||
'@oxfmt/binding-win32-x64-msvc': 0.47.0
|
||||
|
||||
oxlint-tsgolint@0.22.1:
|
||||
optionalDependencies:
|
||||
@ -578,27 +578,27 @@ snapshots:
|
||||
'@oxlint-tsgolint/win32-arm64': 0.22.1
|
||||
'@oxlint-tsgolint/win32-x64': 0.22.1
|
||||
|
||||
oxlint@1.61.0(oxlint-tsgolint@0.22.1):
|
||||
oxlint@1.62.0(oxlint-tsgolint@0.22.1):
|
||||
optionalDependencies:
|
||||
'@oxlint/binding-android-arm-eabi': 1.61.0
|
||||
'@oxlint/binding-android-arm64': 1.61.0
|
||||
'@oxlint/binding-darwin-arm64': 1.61.0
|
||||
'@oxlint/binding-darwin-x64': 1.61.0
|
||||
'@oxlint/binding-freebsd-x64': 1.61.0
|
||||
'@oxlint/binding-linux-arm-gnueabihf': 1.61.0
|
||||
'@oxlint/binding-linux-arm-musleabihf': 1.61.0
|
||||
'@oxlint/binding-linux-arm64-gnu': 1.61.0
|
||||
'@oxlint/binding-linux-arm64-musl': 1.61.0
|
||||
'@oxlint/binding-linux-ppc64-gnu': 1.61.0
|
||||
'@oxlint/binding-linux-riscv64-gnu': 1.61.0
|
||||
'@oxlint/binding-linux-riscv64-musl': 1.61.0
|
||||
'@oxlint/binding-linux-s390x-gnu': 1.61.0
|
||||
'@oxlint/binding-linux-x64-gnu': 1.61.0
|
||||
'@oxlint/binding-linux-x64-musl': 1.61.0
|
||||
'@oxlint/binding-openharmony-arm64': 1.61.0
|
||||
'@oxlint/binding-win32-arm64-msvc': 1.61.0
|
||||
'@oxlint/binding-win32-ia32-msvc': 1.61.0
|
||||
'@oxlint/binding-win32-x64-msvc': 1.61.0
|
||||
'@oxlint/binding-android-arm-eabi': 1.62.0
|
||||
'@oxlint/binding-android-arm64': 1.62.0
|
||||
'@oxlint/binding-darwin-arm64': 1.62.0
|
||||
'@oxlint/binding-darwin-x64': 1.62.0
|
||||
'@oxlint/binding-freebsd-x64': 1.62.0
|
||||
'@oxlint/binding-linux-arm-gnueabihf': 1.62.0
|
||||
'@oxlint/binding-linux-arm-musleabihf': 1.62.0
|
||||
'@oxlint/binding-linux-arm64-gnu': 1.62.0
|
||||
'@oxlint/binding-linux-arm64-musl': 1.62.0
|
||||
'@oxlint/binding-linux-ppc64-gnu': 1.62.0
|
||||
'@oxlint/binding-linux-riscv64-gnu': 1.62.0
|
||||
'@oxlint/binding-linux-riscv64-musl': 1.62.0
|
||||
'@oxlint/binding-linux-s390x-gnu': 1.62.0
|
||||
'@oxlint/binding-linux-x64-gnu': 1.62.0
|
||||
'@oxlint/binding-linux-x64-musl': 1.62.0
|
||||
'@oxlint/binding-openharmony-arm64': 1.62.0
|
||||
'@oxlint/binding-win32-arm64-msvc': 1.62.0
|
||||
'@oxlint/binding-win32-ia32-msvc': 1.62.0
|
||||
'@oxlint/binding-win32-x64-msvc': 1.62.0
|
||||
oxlint-tsgolint: 0.22.1
|
||||
|
||||
tinypool@2.1.0: {}
|
||||
|
||||
@ -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.
|
||||
@ -191,12 +197,16 @@ maintainer-review expectations. If review findings name a narrow mechanical
|
||||
blocker that an automated worker can fix, choose `queue_fix_pr` even when the
|
||||
finding is process-only or P3. Examples include a missing required changelog
|
||||
entry, docs/diagnostic copy, validation-only warning, focused test coverage, or
|
||||
a failing check with a clear file-level repair. Use `manual_review` for an
|
||||
automerge-opted PR only when the blocker is not safely repairable by automation,
|
||||
such as a security finding, release/beta approval, draft/conflict/stale head,
|
||||
failing required check without a narrow repair, requested changes that require
|
||||
human/product/ownership approval, unclear ownership approval for a specific
|
||||
risky behavior, or an explicit human-review/pause signal.
|
||||
a failing check with a clear file-level repair. Concrete security findings are
|
||||
not automatically human-review blockers after a maintainer opts a PR into
|
||||
`clawsweeper:automerge` or `clawsweeper:autofix`; if the defect has a narrow
|
||||
code/test repair, choose `queue_fix_pr` and let the repair loop try first. Use
|
||||
`manual_review` for an automerge-opted PR only when the blocker is not safely
|
||||
repairable by automation, such as release/beta approval, draft/conflict/stale
|
||||
head, failing required check without a narrow repair, requested changes that
|
||||
require human/product/ownership approval, unclear ownership approval for a
|
||||
specific risky behavior, a security/product decision rather than a concrete
|
||||
code defect, or an explicit human-review/pause signal.
|
||||
|
||||
Keep an issue open when an open PR specifically references it with GitHub closing
|
||||
syntax such as `Fixes #123`, `Closes #123`, or `Resolves #123`. That PR is an
|
||||
@ -207,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
|
||||
@ -312,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
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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/<repo>/items/<n>.md</code>: decision, evidence, proposed comment, snapshot hash. Nothing else.",
|
||||
"Every reviewed issue and PR becomes <code>records/<repo>/items/<n>.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/<repo>/commits/<sha>.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">
|
||||
|
||||
@ -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
175
scripts/check-limits.ts
Normal 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, "\\$&");
|
||||
}
|
||||
185
scripts/restore-repair-job.sh
Executable file
185
scripts/restore-repair-job.sh
Executable file
@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
JOB_PATH="${1:-${JOB_PATH:-}}"
|
||||
SKIP_TARGET="${2:-this worker}"
|
||||
|
||||
if [ -z "$JOB_PATH" ]; then
|
||||
echo "JOB_PATH is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
write_output() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
if [ -n "${GITHUB_OUTPUT:-}" ]; then
|
||||
echo "$key=$value" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "$key=$value"
|
||||
fi
|
||||
}
|
||||
|
||||
restore_automerge_job() {
|
||||
case "$JOB_PATH" in
|
||||
jobs/*/inbox/automerge-*.md) ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
local filename stem rest number repo_slug owner repo_name repo ref branch
|
||||
filename="${JOB_PATH##*/}"
|
||||
stem="${filename%.md}"
|
||||
rest="${stem#automerge-}"
|
||||
number="${rest##*-}"
|
||||
repo_slug="${rest%-${number}}"
|
||||
owner="${JOB_PATH#jobs/}"
|
||||
owner="${owner%%/*}"
|
||||
repo_name="${repo_slug#${owner}-}"
|
||||
if [ -z "$number" ] || [ "$number" = "$rest" ] || [ -z "$repo_name" ] || [ "$repo_name" = "$repo_slug" ]; then
|
||||
return 1
|
||||
fi
|
||||
repo="$owner/$repo_name"
|
||||
ref="#$number"
|
||||
branch="clawsweeper/automerge-$repo_slug-$number"
|
||||
mkdir -p "$(dirname "$JOB_PATH")"
|
||||
cat > "$JOB_PATH" <<EOF
|
||||
---
|
||||
repo: $repo
|
||||
cluster_id: automerge-$repo_slug-$number
|
||||
mode: autonomous
|
||||
allowed_actions:
|
||||
- comment
|
||||
- label
|
||||
- fix
|
||||
- raise_pr
|
||||
blocked_actions:
|
||||
- close
|
||||
- merge
|
||||
require_human_for:
|
||||
- close
|
||||
- merge
|
||||
canonical:
|
||||
- $ref
|
||||
candidates:
|
||||
- $ref
|
||||
cluster_refs:
|
||||
- $ref
|
||||
allow_instant_close: false
|
||||
allow_fix_pr: true
|
||||
allow_merge: false
|
||||
allow_unmerged_fix_close: false
|
||||
allow_post_merge_close: false
|
||||
require_fix_before_close: true
|
||||
security_policy: central_security_only
|
||||
security_sensitive: false
|
||||
target_branch: $branch
|
||||
source: pr_automerge
|
||||
---
|
||||
|
||||
# ClawSweeper adopted PR repair candidate
|
||||
|
||||
Maintainer opted $ref into ClawSweeper automerge.
|
||||
|
||||
Source PR: https://github.com/$repo/pull/$number
|
||||
Title: PR $ref
|
||||
|
||||
ClawSweeper should use this job only for the bounded ClawSweeper review/fix loop:
|
||||
|
||||
- If ClawSweeper emits an explicit repair marker, requests changes, or finds failing checks/rebase work, and the PR branch is safe to update, emit a fix artifact with \`repair_strategy: "repair_contributor_branch"\` and \`source_prs: ["https://github.com/$repo/pull/$number"]\`.
|
||||
- If the PR branch cannot be safely updated, emit a narrow credited replacement only when the artifact can preserve the original contributor credit; otherwise return \`needs_human\`.
|
||||
- Do not merge, close, or bypass review gates from the worker. The comment router owns final merge only after a passing ClawSweeper verdict for the exact current head.
|
||||
- Keep repair scope limited to actionable ClawSweeper findings, failing relevant checks, and required review feedback on this PR.
|
||||
EOF
|
||||
}
|
||||
|
||||
restore_issue_implementation_job() {
|
||||
case "$JOB_PATH" in
|
||||
jobs/*/inbox/issue-*.md) ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
local filename stem rest number repo_slug owner repo_name repo ref branch
|
||||
filename="${JOB_PATH##*/}"
|
||||
stem="${filename%.md}"
|
||||
rest="${stem#issue-}"
|
||||
number="${rest##*-}"
|
||||
repo_slug="${rest%-${number}}"
|
||||
owner="${JOB_PATH#jobs/}"
|
||||
owner="${owner%%/*}"
|
||||
repo_name="${repo_slug#${owner}-}"
|
||||
if [ -z "$number" ] || [ "$number" = "$rest" ] || [ -z "$repo_name" ] || [ "$repo_name" = "$repo_slug" ]; then
|
||||
return 1
|
||||
fi
|
||||
repo="$owner/$repo_name"
|
||||
ref="#$number"
|
||||
branch="clawsweeper/issue-$repo_slug-$number"
|
||||
mkdir -p "$(dirname "$JOB_PATH")"
|
||||
cat > "$JOB_PATH" <<EOF
|
||||
---
|
||||
repo: $repo
|
||||
cluster_id: issue-$repo_slug-$number
|
||||
mode: autonomous
|
||||
allowed_actions:
|
||||
- comment
|
||||
- label
|
||||
- fix
|
||||
- raise_pr
|
||||
blocked_actions:
|
||||
- close
|
||||
- merge
|
||||
require_human_for:
|
||||
- close
|
||||
- merge
|
||||
canonical:
|
||||
- $ref
|
||||
candidates:
|
||||
- $ref
|
||||
cluster_refs:
|
||||
- $ref
|
||||
allow_instant_close: false
|
||||
allow_fix_pr: true
|
||||
allow_merge: false
|
||||
allow_unmerged_fix_close: false
|
||||
allow_post_merge_close: false
|
||||
require_fix_before_close: false
|
||||
security_policy: central_security_only
|
||||
security_sensitive: false
|
||||
target_branch: $branch
|
||||
source: issue_implementation
|
||||
required_pr_labels:
|
||||
- clawsweeper:autogenerated
|
||||
---
|
||||
|
||||
# ClawSweeper issue implementation candidate
|
||||
|
||||
ClawSweeper Repair should create or update one implementation PR from \`$branch\`.
|
||||
|
||||
Source issue: https://github.com/$repo/issues/$number
|
||||
Title: Issue $ref
|
||||
|
||||
## Operator Prompt
|
||||
|
||||
Use the source issue as the product request or bug report. Verify the request is still valid on latest \`$repo@main\`, inspect nearby code, and make the narrowest implementation that directly satisfies the issue. If the issue is too broad, underspecified, security-sensitive, already fixed, or not safely implementable by automation, do not change code; report the exact blocker.
|
||||
|
||||
When code changes are appropriate, emit a fix artifact with \`repair_strategy: "new_fix_pr"\`, \`source_prs: []\`, this issue in \`linked_refs\`, and validation commands for the touched surface.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Do not merge.
|
||||
- Do not close the issue from this lane.
|
||||
- Keep one PR for this issue; reuse \`$branch\` if it already exists.
|
||||
- Keep the diff narrow and avoid unrelated refactors.
|
||||
- Preserve issue context and link https://github.com/$repo/issues/$number in the PR body.
|
||||
- Add a changelog entry when the target repo expects one.
|
||||
EOF
|
||||
}
|
||||
|
||||
if [ -f "$JOB_PATH" ]; then
|
||||
write_output job_exists 1
|
||||
elif restore_automerge_job; then
|
||||
write_output job_exists 1
|
||||
echo "::notice title=Restored automerge repair job::Job file '$JOB_PATH' was missing from the state checkout; reconstructed it from the workflow input."
|
||||
elif restore_issue_implementation_job; then
|
||||
write_output job_exists 1
|
||||
echo "::notice title=Restored issue implementation job::Job file '$JOB_PATH' was missing from the state checkout; reconstructed it from the workflow input."
|
||||
else
|
||||
write_output job_exists 0
|
||||
echo "::notice title=Stale repair dispatch::Job file '$JOB_PATH' no longer exists on the current state checkout; skipping $SKIP_TARGET."
|
||||
fi
|
||||
293
scripts/social-card.mjs
Normal file
293
scripts/social-card.mjs
Normal 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)));
|
||||
}
|
||||
1077
src/clawsweeper.ts
1077
src/clawsweeper.ts
File diff suppressed because it is too large
Load Diff
@ -8,9 +8,11 @@ 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;
|
||||
delete env.CODEX_API_KEY;
|
||||
if (ghToken) env.GH_TOKEN = ghToken;
|
||||
env.GIT_OPTIONAL_LOCKS = "0";
|
||||
return env;
|
||||
|
||||
@ -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
185
src/limits.ts
Normal 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)), "..");
|
||||
}
|
||||
@ -33,11 +33,22 @@ function existingTimelineRows(value: JsonValue): string[] {
|
||||
new RegExp(`${escapeRegExp(TIMELINE_START)}([\\s\\S]*?)${escapeRegExp(TIMELINE_END)}`),
|
||||
);
|
||||
if (!match) return [];
|
||||
return match[1]!
|
||||
const lines = match[1]!
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line) => line.includes(EVENT_PREFIX))
|
||||
.map(sanitizeTimelineRow);
|
||||
.filter((line) => line.trim() && line.trim() !== "Automerge progress:");
|
||||
const rows: string[] = [];
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
const line = lines[index]!;
|
||||
if (!line.includes(EVENT_PREFIX)) continue;
|
||||
const parts = [line];
|
||||
while (index + 1 < lines.length && !lines[index + 1]!.includes(EVENT_PREFIX)) {
|
||||
parts.push(lines[++index]!);
|
||||
}
|
||||
const row = sanitizeTimelineRow(parts.join("\n"));
|
||||
if (row) rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function renderTimelineEvent(event: LooseRecord): string {
|
||||
@ -50,8 +61,9 @@ function renderTimelineEvent(event: LooseRecord): string {
|
||||
const duration = formatDuration(event.durationMs ?? event.duration_ms);
|
||||
const runUrl = safeTimelineRunUrl(event.runUrl ?? event.run_url);
|
||||
const details = compact(event.details ?? event.detail ?? event.reason, 160);
|
||||
return [
|
||||
`- ${EVENT_PREFIX}${id} -->`,
|
||||
const eventMarker = `${EVENT_PREFIX}${id} -->`;
|
||||
const row = [
|
||||
"-",
|
||||
at,
|
||||
label,
|
||||
head,
|
||||
@ -62,15 +74,24 @@ function renderTimelineEvent(event: LooseRecord): string {
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return `${eventMarker}\n${row}`;
|
||||
}
|
||||
|
||||
function sanitizeTimelineRow(row: string): string {
|
||||
return row
|
||||
const id = timelineEventId(row);
|
||||
if (!id) return "";
|
||||
const body = row
|
||||
.replace(/\r?\n/g, " ")
|
||||
.replace(/^\s*-\s*/, "")
|
||||
.replace(new RegExp(escapeRegExp(`${EVENT_PREFIX}${id} -->`), "g"), "")
|
||||
.replace(
|
||||
/\s+Run:\s+https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/(?:pull|issues)\/\d+#issuecomment-\d+\b/gi,
|
||||
"",
|
||||
)
|
||||
.trimEnd();
|
||||
.replace(/\]\s+\(/g, "](")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
return `${EVENT_PREFIX}${id} -->${body ? `\n${body.startsWith("- ") ? body : `- ${body}`}` : ""}`;
|
||||
}
|
||||
|
||||
function safeTimelineRunUrl(value: JsonValue): string {
|
||||
|
||||
13
src/repair/codex-transient.ts
Normal file
13
src/repair/codex-transient.ts
Normal file
@ -0,0 +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|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,
|
||||
);
|
||||
}
|
||||
@ -107,7 +107,8 @@ export function redactSecrets(text: string) {
|
||||
}
|
||||
|
||||
function codexDebugRoots(options: CollectOptions) {
|
||||
const codexHome = options.codexHome || path.join(options.homeDir, ".codex");
|
||||
const codexHome =
|
||||
options.codexHome || process.env.CODEX_HOME?.trim() || path.join(options.homeDir, ".codex");
|
||||
const repairRunsDir =
|
||||
options.repairRunsDir || path.join(process.cwd(), ".clawsweeper-repair", "runs");
|
||||
return [
|
||||
@ -173,7 +174,8 @@ function isMain() {
|
||||
if (isMain()) {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const outDir = stringArg(args.out, ".clawsweeper-repair/codex-debug");
|
||||
const codexHome = typeof args["codex-home"] === "string" ? args["codex-home"] : undefined;
|
||||
const codexHome =
|
||||
typeof args["codex-home"] === "string" ? args["codex-home"] : process.env.CODEX_HOME;
|
||||
const repairRunsDir =
|
||||
typeof args["repair-runs-dir"] === "string" ? args["repair-runs-dir"] : undefined;
|
||||
const result = collectCodexDebug({
|
||||
|
||||
@ -17,7 +17,12 @@ export const HUMAN_REVIEW_LABEL = "clawsweeper:human-review";
|
||||
export const MERGE_READY_LABEL = "clawsweeper:merge-ready";
|
||||
export const AUTOGENERATED_LABEL = "clawsweeper:autogenerated";
|
||||
export const DEFAULT_ALLOWED_REPOSITORY_PERMISSIONS = ["admin", "maintain", "write"];
|
||||
const CLAWSWEEPER_REPLY_BADGE = "🦞🦞";
|
||||
const CLAWSWEEPER_REPLY_BADGES = {
|
||||
default: "🦞👀",
|
||||
repair: "🦞🔧",
|
||||
sweep: "🦞🧹",
|
||||
done: "🦞✅",
|
||||
};
|
||||
const AUTOMERGE_STATUS_INTENTS = new Set([
|
||||
"automerge",
|
||||
"clawsweeper_auto_repair",
|
||||
@ -42,6 +47,29 @@ function botFeedbackLead(command: LooseRecord, message: string): string {
|
||||
: `Thanks. ${message}`;
|
||||
}
|
||||
|
||||
function commandReplyBadge(command: LooseRecord, dispatched: LooseRecord): string {
|
||||
const intent = String(command?.intent ?? "");
|
||||
if (intent === "re_review") return CLAWSWEEPER_REPLY_BADGES.sweep;
|
||||
if (["stop", "clawsweeper_needs_human"].includes(intent)) return CLAWSWEEPER_REPLY_BADGES.done;
|
||||
if (intent === "autoclose")
|
||||
return dispatched?.autoclose?.status === "executed"
|
||||
? CLAWSWEEPER_REPLY_BADGES.done
|
||||
: CLAWSWEEPER_REPLY_BADGES.default;
|
||||
if (["autofix", "automerge"].includes(intent))
|
||||
return dispatched?.repair ? CLAWSWEEPER_REPLY_BADGES.repair : CLAWSWEEPER_REPLY_BADGES.sweep;
|
||||
if (REPAIR_INTENTS.has(intent)) return CLAWSWEEPER_REPLY_BADGES.repair;
|
||||
if (intent === "clawsweeper_auto_merge") {
|
||||
if (dispatched?.merge?.status === "executed") return CLAWSWEEPER_REPLY_BADGES.done;
|
||||
if (dispatched?.repair) return CLAWSWEEPER_REPLY_BADGES.repair;
|
||||
return CLAWSWEEPER_REPLY_BADGES.default;
|
||||
}
|
||||
if (intent === "maintainer_approve_automerge")
|
||||
return dispatched?.merge?.status === "executed"
|
||||
? CLAWSWEEPER_REPLY_BADGES.done
|
||||
: CLAWSWEEPER_REPLY_BADGES.default;
|
||||
return CLAWSWEEPER_REPLY_BADGES.default;
|
||||
}
|
||||
|
||||
export function repoSlug(repo: string) {
|
||||
return String(repo ?? "")
|
||||
.trim()
|
||||
@ -76,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,
|
||||
@ -418,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,
|
||||
@ -574,7 +633,7 @@ export function parseCommand(body: string) {
|
||||
const lines = String(body ?? "").split(/\r?\n/);
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index] ?? "";
|
||||
const automerge = line.match(/^\s*\/auto-?merge\s*$/i);
|
||||
const automerge = line.match(/^\s*\/auto(?:-|\s+)?merge\s*$/i);
|
||||
if (automerge) return commandFromText("slash", "automerge");
|
||||
const autoclose = line.match(/^\s*\/autoclose(?:\s+(.+))?\s*$/i);
|
||||
if (autoclose) return commandFromText("slash", `autoclose ${autoclose[1] ?? ""}`.trim());
|
||||
@ -588,7 +647,8 @@ export function parseCommand(body: string) {
|
||||
.slice(index + 1)
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (rest) return commandFromText("slash", `${slash[1]}\n${rest}`);
|
||||
if (rest)
|
||||
return commandFromText("slash", `${issueImplementationRestPrefix(command)}\n${rest}`);
|
||||
}
|
||||
return command;
|
||||
}
|
||||
@ -603,7 +663,7 @@ export function parseCommand(body: string) {
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (command.intent === "implement_issue" && rest)
|
||||
return commandFromText("mention", `${mention[1]}\n${rest}`);
|
||||
return commandFromText("mention", `${issueImplementationRestPrefix(command)}\n${rest}`);
|
||||
if (command.command === "status" && rest) return commandFromText("mention", rest);
|
||||
return command;
|
||||
}
|
||||
@ -627,10 +687,32 @@ export function parseTrustedAutomation(
|
||||
const body = String(comment?.body ?? "");
|
||||
const verdict = clawsweeperMarker(body, "verdict");
|
||||
const actionMarker = clawsweeperMarker(body, "action");
|
||||
if (verdict && ["needs-human", "human-review"].includes(verdict.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,
|
||||
});
|
||||
}
|
||||
if (verdict?.action === "needs-human" && trustedCommentHasPriorityFinding(body)) {
|
||||
return trustedRepair({
|
||||
author,
|
||||
reason: `structured ClawSweeper needs-human verdict with repairable P-severity findings${markerReasonSuffix(verdict.attrs)}`,
|
||||
marker: verdict,
|
||||
});
|
||||
}
|
||||
if (verdict?.action === "needs-human") {
|
||||
return trustedHumanReview({
|
||||
author,
|
||||
reason: trustedHumanReviewReason(body, verdict),
|
||||
marker: verdict,
|
||||
});
|
||||
}
|
||||
@ -681,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 = [
|
||||
@ -690,14 +814,14 @@ export function renderResponse(command: LooseRecord, dispatched: LooseRecord) {
|
||||
intent: command.intent,
|
||||
headSha: command.target?.head_sha ?? "na",
|
||||
}),
|
||||
CLAWSWEEPER_REPLY_BADGE,
|
||||
commandReplyBadge(command, dispatched),
|
||||
].join("\n");
|
||||
if (command.intent === "help") {
|
||||
return [
|
||||
marker,
|
||||
"ClawSweeper is here and listening for maintainer commands.",
|
||||
"",
|
||||
"Supported commands: `/review`, `/clawsweeper status`, `/clawsweeper re-review`, `/clawsweeper implement`, `/clawsweeper build`, `/clawsweeper fix ci`, `/clawsweeper address review`, `/clawsweeper rebase`, `/clawsweeper autofix`, `/clawsweeper automerge`, `/clawsweeper approve`, `/autoclose <reason>`, `/clawsweeper explain`, `/clawsweeper stop`.",
|
||||
"Supported commands: `/review`, `/clawsweeper status`, `/clawsweeper re-review`, `/clawsweeper implement`, `@clawsweeper fix`, `/clawsweeper build`, `/clawsweeper fix ci`, `/clawsweeper address review`, `/clawsweeper rebase`, `/clawsweeper autofix`, `/clawsweeper automerge`, `/clawsweeper approve`, `/autoclose <reason>`, `/clawsweeper explain`, `/clawsweeper stop`.",
|
||||
"",
|
||||
"I only act for maintainers, or for trusted ClawSweeper feedback on a ClawSweeper PR or PR opted into `clawsweeper:autofix` or `clawsweeper:automerge`.",
|
||||
].join("\n");
|
||||
@ -720,11 +844,15 @@ export function renderResponse(command: LooseRecord, dispatched: LooseRecord) {
|
||||
].join("\n");
|
||||
}
|
||||
if (command.intent === "stop") {
|
||||
const removedLabels = (command.actions ?? [])
|
||||
.filter((action: JsonValue) => action.action === "remove_label")
|
||||
.map((action: JsonValue) => `\`${action.label}\``)
|
||||
.filter(Boolean);
|
||||
return [
|
||||
marker,
|
||||
"Got it. ClawSweeper will leave this item for human review.",
|
||||
"",
|
||||
`I added \`${HUMAN_REVIEW_LABEL}\` and paused the automation trail until a maintainer asks again.`,
|
||||
`I added \`${HUMAN_REVIEW_LABEL}\`${removedLabels.length > 0 ? `, removed ${removedLabels.join(", ")},` : ""} and paused the automation trail until a maintainer asks again.`,
|
||||
].join("\n");
|
||||
}
|
||||
if (["autofix", "automerge"].includes(command.intent)) {
|
||||
@ -752,8 +880,8 @@ export function renderResponse(command: LooseRecord, dispatched: LooseRecord) {
|
||||
`- Head: ${head || "`unknown`"}`,
|
||||
`- Label: \`${label}\`${clearedHumanReview ? " (pause labels cleared)" : ""}`,
|
||||
repairQueued
|
||||
? repairDispatchLine(dispatched.repair, "Action")
|
||||
: "- Action: exact-head review queued.",
|
||||
? repairDispatchLine(dispatched.repair, "- Action")
|
||||
: 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`}.`,
|
||||
@ -771,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");
|
||||
}
|
||||
@ -932,7 +1064,7 @@ export function renderResponse(command: LooseRecord, dispatched: LooseRecord) {
|
||||
`Reason: ${command.reason ?? "unsupported command or target"}.`,
|
||||
"",
|
||||
"Supported re-review commands work on open issues and PRs: `/review`, `/clawsweeper re-review`, or `@clawsweeper re-review`.",
|
||||
"Supported issue implementation commands work on open issues: `/clawsweeper implement`, `/clawsweeper build`, `@clawsweeper create pr`, or `@clawsweeper fix issue`.",
|
||||
"Supported issue implementation commands work on open issues: `/clawsweeper implement`, `@clawsweeper fix`, `/clawsweeper build`, `@clawsweeper create pr`, or `@clawsweeper fix issue`.",
|
||||
"Supported repair commands work on existing ClawSweeper PRs and PRs opted into `clawsweeper:autofix` or `clawsweeper:automerge`: `/clawsweeper fix ci`, `/clawsweeper address review`, `/clawsweeper rebase`.",
|
||||
"A maintainer can opt a PR in with `/clawsweeper autofix` or `/clawsweeper automerge` and I can take another pass.",
|
||||
"A maintainer can close unsupported or declined work with `/autoclose <reason>`.",
|
||||
@ -966,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 ?? ""));
|
||||
}
|
||||
@ -1012,10 +1155,14 @@ export function autocloseReasonFromCommand(command: LooseRecord) {
|
||||
function implementationPromptFromCommand(command: LooseRecord) {
|
||||
return String(command ?? "")
|
||||
.trim()
|
||||
.replace(/^(?:implement|build|create\s+pr|open\s+pr|fix\s+issue)\b[:\s-]*/i, "")
|
||||
.replace(/^(?:implement|build|create\s+pr|open\s+pr|fix\s+issue|fix)\b[:\s-]*/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function issueImplementationRestPrefix(command: LooseRecord) {
|
||||
return command.command === "fix" ? "fix issue" : command.command;
|
||||
}
|
||||
|
||||
function normalizeIntent(command: LooseRecord) {
|
||||
if (!command || command === "status") return "status";
|
||||
if (["help", "?"].includes(command)) return "help";
|
||||
@ -1032,6 +1179,7 @@ function normalizeIntent(command: LooseRecord) {
|
||||
command.startsWith("create pr ") ||
|
||||
command === "open pr" ||
|
||||
command.startsWith("open pr ") ||
|
||||
command === "fix" ||
|
||||
command === "fix issue" ||
|
||||
command.startsWith("fix issue ")
|
||||
) {
|
||||
@ -1056,6 +1204,7 @@ function normalizeIntent(command: LooseRecord) {
|
||||
"merge when ready",
|
||||
"automerge on",
|
||||
"auto-merge on",
|
||||
"auto merge on",
|
||||
].includes(command)
|
||||
) {
|
||||
return "automerge";
|
||||
@ -1115,6 +1264,39 @@ export function staleAutomergeActivationReason({
|
||||
return `PR closed after this ${intent} command`;
|
||||
}
|
||||
|
||||
export function repairLoopStopPauseReason({ command, entries = [] }: LooseRecord): string | null {
|
||||
const stopAt = latestRepairLoopControlTime(entries, command, ["stop"]);
|
||||
if (!stopAt) return null;
|
||||
|
||||
let resumeAt = latestRepairLoopControlTime(entries, command, ["autofix", "automerge"]);
|
||||
const commandIntent = String(command?.intent ?? "");
|
||||
if (["autofix", "automerge"].includes(commandIntent) && !command?.trusted_bot) {
|
||||
resumeAt = Math.max(resumeAt, repairLoopControlTime(command));
|
||||
}
|
||||
|
||||
if (stopAt <= resumeAt) return null;
|
||||
return "ClawSweeper automation was paused by a later /clawsweeper stop command";
|
||||
}
|
||||
|
||||
function latestRepairLoopControlTime(entries: JsonValue, command: LooseRecord, intents: string[]) {
|
||||
if (!Array.isArray(entries)) return 0;
|
||||
const intentSet = new Set(intents);
|
||||
let latest = 0;
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
if (entry.repo !== command?.repo) continue;
|
||||
if (Number(entry.issue_number) !== Number(command?.issue_number)) continue;
|
||||
if (!intentSet.has(String(entry.intent ?? ""))) continue;
|
||||
latest = Math.max(latest, repairLoopControlTime(entry));
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
function repairLoopControlTime(entry: LooseRecord) {
|
||||
const parsed = Date.parse(String(entry?.comment_updated_at ?? entry?.comment_created_at ?? ""));
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function inlineQuote(value: JsonValue): string {
|
||||
const text = String(value ?? "")
|
||||
.replace(/\s+/g, " ")
|
||||
|
||||
@ -33,6 +33,7 @@ import {
|
||||
automergeTransientWaitConfig,
|
||||
buildAutomergeMergeArgs,
|
||||
commandHasAction,
|
||||
createCachedLabelNumberLookup,
|
||||
hasCommandResponseMarker,
|
||||
commandStatusMarker,
|
||||
commandStatusMarkerPrefix,
|
||||
@ -42,8 +43,10 @@ import {
|
||||
issueImplementationClusterId,
|
||||
issueImplementationJobPath,
|
||||
parseCommand,
|
||||
pausedModeStatusBlocksReplay,
|
||||
parseTrustedAutomation,
|
||||
repairableCheckBlockers,
|
||||
repairLoopStopPauseReason,
|
||||
reviewedHeadShaBlockReason,
|
||||
renderAutomergeJob,
|
||||
renderIssueImplementationJob,
|
||||
@ -125,10 +128,16 @@ const processedCommentVersions = forceReprocess
|
||||
.filter(Boolean),
|
||||
);
|
||||
const plannedAutoRepairHeads = new Set<string>();
|
||||
let repairLoopControlEntriesCache: LooseRecord[] | null = null;
|
||||
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[] = [];
|
||||
|
||||
@ -466,6 +475,8 @@ function classifyCommand(command: LooseRecord): JsonValue {
|
||||
}
|
||||
return automergeBlocked(next, `${mode} requires a pull request`);
|
||||
}
|
||||
const stoppedReason = repairLoopStoppedReason(next);
|
||||
if (stoppedReason) return { ...next, status: "skipped", reason: stoppedReason };
|
||||
const pauseLabels = pauseLabelsOn(target);
|
||||
const failedChecksRepairReason = automergeFailedChecksRepairReason(target.checks);
|
||||
const rebaseRepairReason = automergeRebaseRepairReason(target);
|
||||
@ -497,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({
|
||||
@ -554,10 +581,18 @@ function classifyCommand(command: LooseRecord): JsonValue {
|
||||
}
|
||||
|
||||
if (command.intent === "stop") {
|
||||
const removeRepairLoopLabelActions = [AUTOMERGE_LABEL, AUTOFIX_LABEL, MERGE_READY_LABEL]
|
||||
.filter((label) => hasLabel(target, label))
|
||||
.map((label) => ({
|
||||
action: "remove_label",
|
||||
label,
|
||||
status: execute ? "pending" : "planned",
|
||||
}));
|
||||
return {
|
||||
...next,
|
||||
status: "ready",
|
||||
actions: [
|
||||
...removeRepairLoopLabelActions,
|
||||
{ action: "label", label: HUMAN_REVIEW_LABEL, status: execute ? "pending" : "planned" },
|
||||
{ action: "comment", status: execute ? "pending" : "planned" },
|
||||
],
|
||||
@ -580,6 +615,10 @@ function classifyCommand(command: LooseRecord): JsonValue {
|
||||
if (!pull) {
|
||||
return repairBlocked(next, "repair commands require a pull request");
|
||||
}
|
||||
if (command.trusted_bot) {
|
||||
const stoppedReason = repairLoopStoppedReason(next);
|
||||
if (stoppedReason) return { ...next, status: "skipped", reason: stoppedReason };
|
||||
}
|
||||
if (!canRepairPullTarget(target)) {
|
||||
return repairBlocked(
|
||||
next,
|
||||
@ -700,6 +739,8 @@ function classifyAutomergePass(
|
||||
return { ...command, status: "skipped", reason: "PR is not open" };
|
||||
if (!pull)
|
||||
return { ...command, status: "skipped", reason: "ClawSweeper pass marker is not on a PR" };
|
||||
const stoppedReason = repairLoopStoppedReason(command);
|
||||
if (stoppedReason) return { ...command, status: "skipped", reason: stoppedReason };
|
||||
if (!hasRepairLoopLabel(command.target))
|
||||
return {
|
||||
...command,
|
||||
@ -712,11 +753,11 @@ function classifyAutomergePass(
|
||||
markerName: "pass",
|
||||
});
|
||||
if (headBlock) return { ...command, status: "skipped", reason: headBlock };
|
||||
const pauseLabelActions = pauseLabelsOn(command.target).map((label) => ({
|
||||
action: "remove_label",
|
||||
label,
|
||||
status: execute ? "pending" : "planned",
|
||||
}));
|
||||
const pauseLabels = pauseLabelsOn(command.target);
|
||||
if (pauseLabels.length > 0) {
|
||||
return { ...command, status: "skipped", reason: "PR is paused for human review" };
|
||||
}
|
||||
const pauseLabelActions: LooseRecord[] = [];
|
||||
const failedCheckBlockers = repairableCheckBlockers(command.target?.checks);
|
||||
if (failedCheckBlockers.length > 0) {
|
||||
return classifyPassedAutomergeRepair(
|
||||
@ -918,6 +959,34 @@ function latestAutomergeResumeAt(command: LooseRecord) {
|
||||
return latest;
|
||||
}
|
||||
|
||||
function repairLoopStoppedReason(command: LooseRecord) {
|
||||
return repairLoopStopPauseReason({
|
||||
command,
|
||||
entries: repairLoopControlEntries(),
|
||||
});
|
||||
}
|
||||
|
||||
function repairLoopControlEntries() {
|
||||
if (repairLoopControlEntriesCache) return repairLoopControlEntriesCache;
|
||||
const entries: LooseRecord[] = [];
|
||||
for (const entry of ledger.commands ?? []) {
|
||||
if (!isRepairLoopControlIntent(entry)) continue;
|
||||
if (!["executed", "waiting"].includes(String(entry.status ?? ""))) continue;
|
||||
entries.push(entry);
|
||||
}
|
||||
for (const command of rawCommands) {
|
||||
if (!isRepairLoopControlIntent(command) || command.trusted_bot) continue;
|
||||
const authorization = resolveMaintainerCommandAuthorization(command);
|
||||
if (authorization.allowed) entries.push(command);
|
||||
}
|
||||
repairLoopControlEntriesCache = entries;
|
||||
return entries;
|
||||
}
|
||||
|
||||
function isRepairLoopControlIntent(command: LooseRecord) {
|
||||
return ["stop", "autofix", "automerge"].includes(String(command?.intent ?? ""));
|
||||
}
|
||||
|
||||
function executeCommand(command: LooseRecord) {
|
||||
let dispatched = null;
|
||||
const shouldDispatchRepair = command.actions?.some(
|
||||
@ -1192,6 +1261,7 @@ function executeCommand(command: LooseRecord) {
|
||||
);
|
||||
}
|
||||
if (command.intent === "stop" && command.issue_number && shouldApplyHumanReviewLabel) {
|
||||
applyRemoveLabelActions(command);
|
||||
ensureHumanReviewLabel(command.repo);
|
||||
ghBestEffort([
|
||||
"issue",
|
||||
@ -1224,6 +1294,36 @@ function executeCommand(command: LooseRecord) {
|
||||
command.status = commandHasWaitingRepairDispatch(command) ? "waiting" : "executed";
|
||||
}
|
||||
|
||||
function applyRemoveLabelActions(command: LooseRecord) {
|
||||
const labelsToRemove = (command.actions ?? [])
|
||||
.filter((action: JsonValue) => action.action === "remove_label")
|
||||
.map((action: JsonValue) => String(action.label ?? ""))
|
||||
.filter(Boolean);
|
||||
if (labelsToRemove.length === 0) return;
|
||||
for (const label of labelsToRemove) {
|
||||
ghBestEffort([
|
||||
"issue",
|
||||
"edit",
|
||||
String(command.issue_number),
|
||||
"--repo",
|
||||
command.repo,
|
||||
"--remove-label",
|
||||
label,
|
||||
]);
|
||||
}
|
||||
command.target = {
|
||||
...command.target,
|
||||
labels: (command.target?.labels ?? []).filter(
|
||||
(label: JsonValue) => !labelsToRemove.includes(String(label)),
|
||||
),
|
||||
};
|
||||
command.actions = command.actions.map((action: JsonValue) =>
|
||||
action.action === "remove_label"
|
||||
? { ...action, status: "executed", label: action.label }
|
||||
: action,
|
||||
);
|
||||
}
|
||||
|
||||
function workerCapacityRequests(commands: LooseRecord[]) {
|
||||
const counts = new Map<string, { count: number; automergeLane: boolean }>();
|
||||
for (const command of commands) {
|
||||
@ -1409,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: {
|
||||
@ -1416,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(
|
||||
@ -1750,6 +1853,10 @@ function closeIssueOrPullRequest(repo: string, number: number, kind: string) {
|
||||
}
|
||||
|
||||
function executeAutomerge(command: LooseRecord) {
|
||||
const stoppedReason = repairLoopStoppedReason(command);
|
||||
if (stoppedReason) {
|
||||
return { action: "merge", status: "blocked", reason: stoppedReason, merge_method: "squash" };
|
||||
}
|
||||
const transientWait = automergeTransientWaitConfig(process.env);
|
||||
const transientObservations: LooseRecord[] = [];
|
||||
let waitedMs = 0;
|
||||
@ -2291,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) {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -13,12 +13,14 @@ import { assertAllowedOwner, parseArgs, parseJob, repoRoot, validateJob } from "
|
||||
import {
|
||||
automergeRepairOutcomeComment,
|
||||
externalMessageProvenance,
|
||||
issueImplementationResultStatusComment,
|
||||
repairContributorBranchComment,
|
||||
replacementPrBody,
|
||||
replacementSourceCloseComment,
|
||||
replacementSourceLinkComment,
|
||||
} from "./external-messages.js";
|
||||
import { runCommand as run } from "./command-runner.js";
|
||||
import { isCodexContextLimitError, isRetryableCodexTransportError } from "./codex-transient.js";
|
||||
import {
|
||||
branchHasBaseDiff,
|
||||
completeRebaseIfResolved,
|
||||
@ -55,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";
|
||||
@ -72,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,
|
||||
@ -262,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,
|
||||
@ -354,6 +372,12 @@ logProgress("starting fix execution", {
|
||||
model,
|
||||
reasoning: codexReasoningEffort,
|
||||
});
|
||||
updateAutomergeProgressStatus({
|
||||
id: "repair-started",
|
||||
label: "repair started",
|
||||
status: "running",
|
||||
details: result.cluster_id,
|
||||
});
|
||||
|
||||
if (plannedFixActions.length === 0) {
|
||||
report.status = "skipped";
|
||||
@ -473,11 +497,23 @@ logProgress("target validation plan accepted", {
|
||||
status: validationPreflight.status,
|
||||
target_branch: validationPreflight.target_branch,
|
||||
});
|
||||
updateAutomergeProgressStatus({
|
||||
id: "validation-plan",
|
||||
label: "validation plan",
|
||||
status: validationPreflight.status,
|
||||
details: listOrNone(validationPreflight.resolved_commands ?? []),
|
||||
});
|
||||
|
||||
logProgress("running Codex write preflight", {
|
||||
timeout_ms: codexPreflightTimeoutMs,
|
||||
sandbox: codexWriteSandbox,
|
||||
});
|
||||
updateAutomergeProgressStatus({
|
||||
id: "codex-write-preflight",
|
||||
label: "Codex write preflight",
|
||||
status: "running",
|
||||
details: codexWriteSandbox,
|
||||
});
|
||||
const writePreflight = runCodexWritePreflight();
|
||||
report.preflight = writePreflight;
|
||||
if (writePreflight.status === "blocked") {
|
||||
@ -494,6 +530,12 @@ if (writePreflight.status === "blocked") {
|
||||
process.exit(0);
|
||||
}
|
||||
logProgress("Codex write preflight passed", { status: writePreflight.status });
|
||||
updateAutomergeProgressStatus({
|
||||
id: "codex-write-preflight",
|
||||
label: "Codex write preflight",
|
||||
status: writePreflight.status,
|
||||
details: codexWriteSandbox,
|
||||
});
|
||||
|
||||
let outcome: JsonValue;
|
||||
try {
|
||||
@ -502,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({
|
||||
@ -547,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",
|
||||
@ -561,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(
|
||||
@ -662,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 });
|
||||
@ -714,6 +780,7 @@ function executeRepairBranch({ fixArtifact, targetDir }: LooseRecord) {
|
||||
});
|
||||
if (fastRepair.status === "ready") {
|
||||
return pushRepairBranchAndUpdateStatus({
|
||||
fixArtifact,
|
||||
sourcePr,
|
||||
pull,
|
||||
sameRepoBranch,
|
||||
@ -742,6 +809,7 @@ function executeRepairBranch({ fixArtifact, targetDir }: LooseRecord) {
|
||||
});
|
||||
(prep.merge_preflight as JsonValue).target = `#${sourcePr.number}`;
|
||||
return pushRepairBranchAndUpdateStatus({
|
||||
fixArtifact,
|
||||
sourcePr,
|
||||
pull,
|
||||
sameRepoBranch,
|
||||
@ -753,6 +821,7 @@ function executeRepairBranch({ fixArtifact, targetDir }: LooseRecord) {
|
||||
}
|
||||
|
||||
function pushRepairBranchAndUpdateStatus({
|
||||
fixArtifact,
|
||||
sourcePr,
|
||||
pull,
|
||||
sameRepoBranch,
|
||||
@ -787,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,
|
||||
@ -840,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,
|
||||
@ -1484,6 +1733,13 @@ function editValidatePrepareMerge({
|
||||
reconcile_with_base: reconcileWithBase,
|
||||
rebase_status: rebaseResult?.status ?? null,
|
||||
});
|
||||
updateAutomergeProgressStatus({
|
||||
id: `codex-edit-${mode}-${attempt}`,
|
||||
label: `Codex edit ${attempt}`,
|
||||
status: "running",
|
||||
details: reconcileWithBase ? "reconciling latest base" : "repairing branch",
|
||||
headSha: headBeforeAttempt,
|
||||
});
|
||||
const codexResult = spawnCodexSyncWithHeartbeat(
|
||||
`Codex fix worker ${mode} attempt ${attempt}`,
|
||||
[
|
||||
@ -1524,12 +1780,62 @@ function editValidatePrepareMerge({
|
||||
throw new Error(`Codex fix worker timed out after ${workerTimeoutMs}ms`);
|
||||
}
|
||||
if (codexResult.error) {
|
||||
throw new Error(codexResult.error.message || String(codexResult.error));
|
||||
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}`,
|
||||
label: `Codex edit ${attempt}`,
|
||||
status: "retrying",
|
||||
details: "transient Codex transport error",
|
||||
headSha: currentHead(targetDir),
|
||||
});
|
||||
sleepMs(retryDelayMs);
|
||||
continue;
|
||||
}
|
||||
throw new Error(codexFailureMessage("Codex fix worker failed", errorDetail));
|
||||
}
|
||||
if (codexResult.status !== 0) {
|
||||
throw new Error(codexResult.stderr || codexResult.stdout || "Codex fix worker failed");
|
||||
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}`,
|
||||
label: `Codex edit ${attempt}`,
|
||||
status: "retrying",
|
||||
details: "transient Codex transport error",
|
||||
headSha: currentHead(targetDir),
|
||||
});
|
||||
sleepMs(retryDelayMs);
|
||||
continue;
|
||||
}
|
||||
throw new Error(codexFailureMessage("Codex fix worker failed", errorDetail));
|
||||
}
|
||||
logProgress("Codex edit pass finished", { mode, attempt, status: codexResult.status });
|
||||
updateAutomergeProgressStatus({
|
||||
id: `codex-edit-${mode}-${attempt}`,
|
||||
label: `Codex edit ${attempt}`,
|
||||
status: "complete",
|
||||
details: `exit ${codexResult.status}`,
|
||||
headSha: currentHead(targetDir),
|
||||
});
|
||||
|
||||
const hasWorkingTreeChanges = Boolean(
|
||||
run("git", ["status", "--porcelain"], { cwd: targetDir }).trim(),
|
||||
@ -1571,10 +1877,17 @@ 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 });
|
||||
updateAutomergeProgressStatus({
|
||||
id: `validation-review-${mode}-${attempt}`,
|
||||
label: `validation and review ${attempt}`,
|
||||
status: "running",
|
||||
details: mode,
|
||||
headSha: currentHead(targetDir),
|
||||
});
|
||||
codexReview = validateAndReviewLoop({
|
||||
fixArtifact,
|
||||
targetDir,
|
||||
@ -1605,6 +1918,13 @@ function editValidatePrepareMerge({
|
||||
sourceHead,
|
||||
});
|
||||
logProgress("final base sync result", { mode, attempt, status: sync.status });
|
||||
updateAutomergeProgressStatus({
|
||||
id: `validation-review-${mode}-${attempt}`,
|
||||
label: `validation and review ${attempt}`,
|
||||
status: sync.status === "already-current" ? "complete" : "base moved",
|
||||
details: sync.status,
|
||||
headSha: currentHead(targetDir),
|
||||
});
|
||||
if (sync.status === "already-current") break;
|
||||
const checkpoint = commitCheckpointIfNeeded({
|
||||
targetDir,
|
||||
@ -1648,6 +1968,50 @@ function logProgress(message: string, details: LooseRecord = {}) {
|
||||
console.log(`[clawsweeper repair] ${new Date().toISOString()} ${message}${suffix}`);
|
||||
}
|
||||
|
||||
function updateAutomergeProgressStatus({
|
||||
id,
|
||||
label,
|
||||
status,
|
||||
details = null,
|
||||
headSha = null,
|
||||
}: LooseRecord) {
|
||||
if (!isAutomergeRepairJob() || dryRun) return false;
|
||||
const target = automergeOutcomeTargetPrNumber();
|
||||
if (!target) return false;
|
||||
try {
|
||||
const existingStatus = findAutomergeStatusComment(target);
|
||||
if (!existingStatus?.id) return false;
|
||||
const now = new Date();
|
||||
const bodyWithTimeline = mergeAutomergeTimelineSection({
|
||||
body: existingStatus.body,
|
||||
existingBody: existingStatus.body,
|
||||
events: [
|
||||
{
|
||||
id: `repair-progress:${currentActionsRunId() || path.basename(path.dirname(resultPath))}:${id}`,
|
||||
label,
|
||||
at: scriptStartedAt.toISOString(),
|
||||
completedAt: now.toISOString(),
|
||||
durationMs: now.getTime() - scriptStartedAt.getTime(),
|
||||
runUrl: currentActionsRunUrl(),
|
||||
headSha: headSha ?? automergeOutcomeReviewedSha(),
|
||||
repo: result.repo,
|
||||
status,
|
||||
details,
|
||||
},
|
||||
],
|
||||
});
|
||||
patchIssueComment(
|
||||
existingStatus.id,
|
||||
preserveStatusMarkers(existingStatus.body, bodyWithTimeline),
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[clawsweeper repair] failed to update automerge progress status: ${message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function reconcileLatestBaseBeforePush({
|
||||
fixArtifact,
|
||||
targetDir,
|
||||
@ -1872,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 (
|
||||
@ -1987,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;
|
||||
@ -2056,7 +2529,7 @@ function runCodexReview({
|
||||
"",
|
||||
"Fix artifact:",
|
||||
"```json",
|
||||
JSON.stringify(fixArtifact, null, 2),
|
||||
renderFixArtifactForPrompt(fixArtifact),
|
||||
"```",
|
||||
].join("\n");
|
||||
const reviewTimeoutMs = currentCodexTimeoutMs();
|
||||
@ -2165,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;",
|
||||
@ -2179,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();
|
||||
@ -2235,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())
|
||||
@ -2246,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;",
|
||||
@ -2266,7 +2741,7 @@ function runCodexValidationFix({
|
||||
"",
|
||||
"Fix artifact:",
|
||||
"```json",
|
||||
JSON.stringify(fixArtifact, null, 2),
|
||||
renderFixArtifactForPrompt(fixArtifact),
|
||||
"```",
|
||||
].join("\n");
|
||||
const validationFixTimeoutMs = currentCodexTimeoutMs();
|
||||
@ -2394,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"))) {
|
||||
@ -2409,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 });
|
||||
@ -2570,6 +3104,7 @@ function findLatestResultPath() {
|
||||
}
|
||||
|
||||
function writeReport(report: LooseRecord, resultPath: string) {
|
||||
appendIssueImplementationPrLinkComment(report);
|
||||
appendAutomergeRepairOutcomeComment(report, resultPath);
|
||||
const reportPath =
|
||||
typeof args.report === "string"
|
||||
@ -2583,6 +3118,90 @@ function writeReport(report: LooseRecord, resultPath: string) {
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
}
|
||||
|
||||
function appendIssueImplementationPrLinkComment(report: LooseRecord) {
|
||||
if (job.frontmatter.source !== "issue_implementation") return;
|
||||
if (!job.frontmatter.allowed_actions.includes("comment")) return;
|
||||
if (
|
||||
report.actions?.some(
|
||||
(action: JsonValue) => action.action === "issue_implementation_status_comment",
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
const prAction = issueImplementationOpenedPrAction(report);
|
||||
if (!prAction) return;
|
||||
const issueNumber = issueImplementationTargetIssueNumber();
|
||||
const base = {
|
||||
action: "issue_implementation_status_comment",
|
||||
target: issueNumber ? `#${issueNumber}` : null,
|
||||
pr_url: prAction.pr_url,
|
||||
};
|
||||
if (!issueNumber) {
|
||||
report.actions.push({ ...base, status: "skipped", reason: "missing source issue number" });
|
||||
return;
|
||||
}
|
||||
if (dryRun) {
|
||||
report.actions.push({ ...base, status: "planned" });
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = findIssueImplementationStatusComment(issueNumber);
|
||||
if (!existing?.id) {
|
||||
report.actions.push({
|
||||
...base,
|
||||
status: "skipped",
|
||||
reason: "no existing ClawSweeper issue implementation status comment",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const body = issueImplementationResultStatusComment({
|
||||
existingBody: existing.body,
|
||||
prUrl: prAction.pr_url,
|
||||
branch: prAction.branch,
|
||||
runUrl: currentActionsRunUrl(),
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
patchIssueComment(existing.id, preserveStatusMarkers(existing.body, body));
|
||||
report.actions.push({
|
||||
...base,
|
||||
status: "updated",
|
||||
comment_id: String(existing.id),
|
||||
});
|
||||
}
|
||||
|
||||
function issueImplementationOpenedPrAction(report: LooseRecord) {
|
||||
return [...(report.actions ?? [])].reverse().find((action: JsonValue) => {
|
||||
if (action?.action !== "open_fix_pr") return false;
|
||||
if (action?.status !== "opened") return false;
|
||||
return typeof action?.pr_url === "string" && action.pr_url.trim();
|
||||
});
|
||||
}
|
||||
|
||||
function issueImplementationTargetIssueNumber() {
|
||||
for (const ref of [
|
||||
...(job.frontmatter.canonical ?? []),
|
||||
...(job.frontmatter.candidates ?? []),
|
||||
...(job.frontmatter.cluster_refs ?? []),
|
||||
]) {
|
||||
const match = String(ref).match(/^#(\d+)$/);
|
||||
if (match) return Number(match[1]);
|
||||
}
|
||||
const clusterMatch = String(result.cluster_id ?? "").match(/-(\d+)$/);
|
||||
return clusterMatch ? Number(clusterMatch[1]) : 0;
|
||||
}
|
||||
|
||||
function findIssueImplementationStatusComment(number: JsonValue) {
|
||||
const issueNumber = Number(number);
|
||||
const marker = `<!-- clawsweeper-command-status:${Number.isFinite(issueNumber) ? issueNumber : "unknown"}:implement_issue:`;
|
||||
return issueCommentsFor(number)
|
||||
.reverse()
|
||||
.find((comment: LooseRecord) => {
|
||||
if (!isTrustedStatusComment(comment)) return false;
|
||||
return String(comment.body ?? "").includes(marker);
|
||||
});
|
||||
}
|
||||
|
||||
function appendAutomergeRepairOutcomeComment(report: LooseRecord, resultPath: string) {
|
||||
if (!isAutomergeRepairJob()) return;
|
||||
if (!job.frontmatter.allowed_actions.includes("comment")) return;
|
||||
@ -2677,7 +3296,7 @@ function updateAutomergeStatusCommentForBranchRepair({
|
||||
const runUrl = currentActionsRunUrl();
|
||||
const reviewDispatch = dispatchAutomergeReviewAfterBranchRepair({ target, commit });
|
||||
const body = [
|
||||
"🦞🦞",
|
||||
"🦞🔧",
|
||||
"ClawSweeper applied a repair to this PR branch.",
|
||||
"",
|
||||
fastRepair?.status === "ready"
|
||||
@ -2750,6 +3369,13 @@ function waitForAutomergeAfterBranchRepair({ target, commit }: LooseRecord) {
|
||||
max_wait_ms: config.maxWaitMs,
|
||||
poll_ms: config.intervalMs,
|
||||
});
|
||||
updateAutomergeProgressStatus({
|
||||
id: `automerge-wait-${commit}`,
|
||||
label: "automerge wait",
|
||||
status: "waiting",
|
||||
details: `up to ${Math.round(config.maxWaitMs / 1000)}s`,
|
||||
headSha: commit,
|
||||
});
|
||||
while (waitedMs <= config.maxWaitMs) {
|
||||
const view = fetchPullRequestViewForRepo({ repo: result.repo, number: target });
|
||||
const readiness = automergeShepherdReadiness({
|
||||
@ -2769,6 +3395,13 @@ function waitForAutomergeAfterBranchRepair({ target, commit }: LooseRecord) {
|
||||
waited_ms: waitedMs,
|
||||
dispatch_status: dispatch?.status ?? null,
|
||||
});
|
||||
updateAutomergeProgressStatus({
|
||||
id: `automerge-wait-${commit}`,
|
||||
label: "automerge wait",
|
||||
status: readiness.status,
|
||||
details: readiness.reason,
|
||||
headSha: commit,
|
||||
});
|
||||
return {
|
||||
status: readiness.status,
|
||||
reason: readiness.reason,
|
||||
@ -2782,6 +3415,13 @@ function waitForAutomergeAfterBranchRepair({ target, commit }: LooseRecord) {
|
||||
waitedMs = Date.now() - startedAt;
|
||||
}
|
||||
logProgress("automerge shepherd wait timed out", { target, commit, waited_ms: waitedMs });
|
||||
updateAutomergeProgressStatus({
|
||||
id: `automerge-wait-${commit}`,
|
||||
label: "automerge wait",
|
||||
status: "waiting",
|
||||
details: lastReason || "waiting for exact-head review/checks",
|
||||
headSha: commit,
|
||||
});
|
||||
return {
|
||||
status: "waiting",
|
||||
reason: lastReason || "waiting for exact-head review/checks",
|
||||
|
||||
@ -277,6 +277,30 @@ export function automergeRepairOutcomeComment({
|
||||
return withFishNotes(lines, provenance);
|
||||
}
|
||||
|
||||
export function issueImplementationResultStatusComment({
|
||||
existingBody,
|
||||
prUrl,
|
||||
branch,
|
||||
runUrl,
|
||||
completedAt,
|
||||
}: LooseRecord) {
|
||||
const marker = "<!-- clawsweeper-issue-implementation-result -->";
|
||||
const lines = [
|
||||
marker,
|
||||
"Result: implementation PR opened.",
|
||||
"",
|
||||
`- PR: ${prUrl}`,
|
||||
branch ? `- Branch: \`${branch}\`` : null,
|
||||
runUrl ? `- Worker: ${runUrl}` : null,
|
||||
completedAt ? `- Updated: ${completedAt}` : null,
|
||||
].filter(Boolean);
|
||||
const nextSection = lines.join("\n");
|
||||
const body = String(existingBody ?? "").trimEnd();
|
||||
const existingSection = new RegExp(`\\n\\n${escapeRegExp(marker)}[\\s\\S]*$`);
|
||||
if (existingSection.test(body)) return body.replace(existingSection, `\n\n${nextSection}`);
|
||||
return `${body}\n\n${nextSection}`;
|
||||
}
|
||||
|
||||
export function replacementSourceLinkComment({ replacementPrUrl, provenance }: LooseRecord) {
|
||||
return withFishNotes(
|
||||
[
|
||||
@ -315,6 +339,10 @@ function compactForComment(value: JsonValue, max: JsonValue) {
|
||||
return `${text.slice(0, Math.max(0, max - 1)).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
export function replacementSourceCloseComment({ replacementPrUrl, provenance }: LooseRecord) {
|
||||
return withFishNotes(
|
||||
[
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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
181
src/repair/limits.ts
Normal 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);
|
||||
}
|
||||
@ -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}`;
|
||||
|
||||
@ -15,6 +15,7 @@ export function codexSubprocessEnv(): NodeJS.ProcessEnv {
|
||||
}
|
||||
if (process.env.GITHUB_ACTIONS === "true") {
|
||||
delete env.OPENAI_API_KEY;
|
||||
delete env.CODEX_API_KEY;
|
||||
}
|
||||
return withoutColor(env);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
127
src/repair/update-command-status.ts
Normal file
127
src/repair/update-command-status.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
@ -64,7 +64,7 @@ export function parseAllowedValidationCommand(command: unknown): string[] {
|
||||
}
|
||||
const parts = normalizeEnvInvocation(text.split(/\s+/));
|
||||
const executable = validationExecutable(parts);
|
||||
if (!executable || !["pnpm", "npm", "node", "git"].includes(executable)) {
|
||||
if (!executable || !isAllowedValidationExecutable(executable)) {
|
||||
throw new Error(`unsupported validation command: ${text}`);
|
||||
}
|
||||
return parts;
|
||||
@ -83,6 +83,14 @@ function validationExecutable(parts: readonly string[]) {
|
||||
return commandParts[0] ?? "";
|
||||
}
|
||||
|
||||
function isAllowedValidationExecutable(executable: string) {
|
||||
return (
|
||||
["pnpm", "npm", "node", "git"].includes(executable) ||
|
||||
executable === "scripts/run-opengrep.sh" ||
|
||||
executable === "./scripts/run-opengrep.sh"
|
||||
);
|
||||
}
|
||||
|
||||
function isEnvAssignment(value: unknown) {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(String(value ?? ""));
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)));
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -23,10 +23,13 @@ test("mergeAutomergeTimelineSection appends and updates progress rows", () => {
|
||||
assert.match(first, /Automerge progress:/);
|
||||
assert.match(first, /review queued/);
|
||||
assert.match(first, /2026-05-01 05:00:00 UTC/);
|
||||
assert.match(first, /clawsweeper-automerge-timeline-event:review-queued:abc123:1 -->\n- /);
|
||||
assert.doesNotMatch(first, /- <!-- clawsweeper-automerge-timeline-event:/);
|
||||
assert.match(
|
||||
first,
|
||||
/\[`abcdef123456`\]\(https:\/\/github\.com\/openclaw\/openclaw\/commit\/abcdef1234567890\)/,
|
||||
);
|
||||
assert.doesNotMatch(first, /\]\s*\n\s*\(/);
|
||||
assert.match(first, /actions\/runs\/123/);
|
||||
|
||||
const second = mergeAutomergeTimelineSection({
|
||||
@ -87,3 +90,28 @@ test("mergeAutomergeTimelineSection drops same-PR comment URLs", () => {
|
||||
assert.doesNotMatch(body, /issuecomment-/);
|
||||
assert.doesNotMatch(body, /github\.com\/openclaw\/openclaw\/pull\/75423/);
|
||||
});
|
||||
|
||||
test("mergeAutomergeTimelineSection normalizes old inline marker rows", () => {
|
||||
const existingBody = [
|
||||
"old body",
|
||||
"<!-- clawsweeper-automerge-timeline:start -->",
|
||||
"Automerge progress:",
|
||||
"- <!-- clawsweeper-automerge-timeline-event:review-queued:old:1 --> 2026-05-03 15:19:34 UTC review queued [`f9bdd078dc0e`]",
|
||||
"(https://github.com/openclaw/openclaw/commit/f9bdd078dc0ee0927829f40ab4c441c79c91d7fe) (queued)",
|
||||
"<!-- clawsweeper-automerge-timeline:end -->",
|
||||
].join("\n");
|
||||
|
||||
const body = mergeAutomergeTimelineSection({
|
||||
body: "status body",
|
||||
existingBody,
|
||||
events: [],
|
||||
});
|
||||
|
||||
assert.match(body, /clawsweeper-automerge-timeline-event:review-queued:old:1 -->\n- /);
|
||||
assert.match(
|
||||
body,
|
||||
/\[`f9bdd078dc0e`\]\(https:\/\/github\.com\/openclaw\/openclaw\/commit\/f9bdd078dc0ee0927829f40ab4c441c79c91d7fe\)/,
|
||||
);
|
||||
assert.doesNotMatch(body, /- <!-- clawsweeper-automerge-timeline-event:/);
|
||||
assert.doesNotMatch(body, /\]\s*\n\s*\(/);
|
||||
});
|
||||
|
||||
39
test/repair/codex-transient.test.ts
Normal file
39
test/repair/codex-transient.test.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
isCodexContextLimitError,
|
||||
isRetryableCodexTransportError,
|
||||
} from "../../dist/repair/codex-transient.js";
|
||||
|
||||
test("Codex closed-stdin tool transport errors are retryable", () => {
|
||||
assert.equal(
|
||||
isRetryableCodexTransportError(
|
||||
"ERROR codex_core::tools::router: error=write_stdin failed: stdin is closed for this session; rerun exec_command with tty=true",
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("ordinary Codex failures are not classified as transient transport", () => {
|
||||
assert.equal(isRetryableCodexTransportError("Codex /review found an actionable bug"), false);
|
||||
assert.equal(
|
||||
isRetryableCodexTransportError("validation command failed: pnpm check:changed"),
|
||||
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);
|
||||
});
|
||||
@ -110,6 +110,33 @@ test("collectCodexDebug backs up Codex JSONL from repair run artifacts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("collectCodexDebug defaults to CODEX_HOME when set", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "clawsweeper-codex-debug-env-"));
|
||||
const codexHome = path.join(tmp, "isolated-codex-home");
|
||||
const outDir = path.join(tmp, "out");
|
||||
const previous = process.env.CODEX_HOME;
|
||||
fs.mkdirSync(path.join(codexHome, "sessions"), { recursive: true });
|
||||
fs.writeFileSync(path.join(codexHome, "sessions", "run.jsonl"), "ok\n");
|
||||
|
||||
try {
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
const result = collectCodexDebug({
|
||||
outDir,
|
||||
label: "env",
|
||||
sinceMinutes: 60,
|
||||
maxBytes: 1024 * 1024,
|
||||
homeDir: path.join(tmp, "home"),
|
||||
});
|
||||
|
||||
assert.equal(result.manifest.length, 1);
|
||||
assert.equal(fs.existsSync(path.join(outDir, "sessions", "run.jsonl")), true);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.CODEX_HOME;
|
||||
else process.env.CODEX_HOME = previous;
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("redactSecrets masks common token shapes", () => {
|
||||
assert.equal(
|
||||
redactSecrets(
|
||||
|
||||
@ -21,6 +21,7 @@ import {
|
||||
commandResponseMarker,
|
||||
commandResponseMarkerPrefix,
|
||||
commandStatusMarkerPrefix,
|
||||
createCachedLabelNumberLookup,
|
||||
existingCommandStatusBlocksReplay,
|
||||
existingModeStatusBlocksReplay,
|
||||
hasCommandResponseMarker,
|
||||
@ -29,8 +30,10 @@ import {
|
||||
issueImplementationJobPath,
|
||||
isMaintainerCommandAllowed,
|
||||
parseCommand,
|
||||
pausedModeStatusBlocksReplay,
|
||||
parseTrustedAutomation,
|
||||
repairableCheckBlockers,
|
||||
repairLoopStopPauseReason,
|
||||
reviewedHeadShaBlockReason,
|
||||
renderAutomergeJob,
|
||||
renderIssueImplementationJob,
|
||||
@ -67,6 +70,11 @@ test("parseCommand recognizes maintainer slash commands", () => {
|
||||
command: "auto-merge",
|
||||
intent: "automerge",
|
||||
});
|
||||
assert.deepEqual(parseCommand("/clawsweeper auto merge"), {
|
||||
trigger: "slash",
|
||||
command: "auto merge",
|
||||
intent: "automerge",
|
||||
});
|
||||
assert.deepEqual(parseCommand("@clawsweeper automerge."), {
|
||||
trigger: "mention",
|
||||
command: "automerge",
|
||||
@ -77,6 +85,11 @@ test("parseCommand recognizes maintainer slash commands", () => {
|
||||
command: "auto-merge",
|
||||
intent: "automerge",
|
||||
});
|
||||
assert.deepEqual(parseCommand("@clawsweeper auto merge"), {
|
||||
trigger: "mention",
|
||||
command: "auto merge",
|
||||
intent: "automerge",
|
||||
});
|
||||
assert.deepEqual(parseCommand("/clawsweeper automerge!"), {
|
||||
trigger: "slash",
|
||||
command: "automerge",
|
||||
@ -156,6 +169,11 @@ test("parseCommand recognizes maintainer slash commands", () => {
|
||||
command: "automerge",
|
||||
intent: "automerge",
|
||||
});
|
||||
assert.deepEqual(parseCommand("/auto merge"), {
|
||||
trigger: "slash",
|
||||
command: "automerge",
|
||||
intent: "automerge",
|
||||
});
|
||||
assert.deepEqual(parseCommand("/autoclose We do not plan to support this feature"), {
|
||||
trigger: "slash",
|
||||
command: "autoclose we do not plan to support this feature",
|
||||
@ -170,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"),
|
||||
@ -213,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", () => {
|
||||
@ -301,6 +352,49 @@ test("stale automerge activation commands after merge are skipped silently", ()
|
||||
);
|
||||
});
|
||||
|
||||
test("later stop command pauses older automerge automation", () => {
|
||||
const entries = [
|
||||
{
|
||||
repo: "openclaw/openclaw",
|
||||
issue_number: 76686,
|
||||
intent: "automerge",
|
||||
comment_updated_at: "2026-05-03T12:55:27Z",
|
||||
},
|
||||
{
|
||||
repo: "openclaw/openclaw",
|
||||
issue_number: 76686,
|
||||
intent: "stop",
|
||||
comment_updated_at: "2026-05-03T12:59:16Z",
|
||||
},
|
||||
];
|
||||
|
||||
assert.equal(
|
||||
repairLoopStopPauseReason({
|
||||
command: {
|
||||
repo: "openclaw/openclaw",
|
||||
issue_number: 76686,
|
||||
intent: "clawsweeper_auto_merge",
|
||||
trusted_bot: true,
|
||||
comment_updated_at: "2026-05-03T13:00:07Z",
|
||||
},
|
||||
entries,
|
||||
}),
|
||||
"ClawSweeper automation was paused by a later /clawsweeper stop command",
|
||||
);
|
||||
assert.equal(
|
||||
repairLoopStopPauseReason({
|
||||
command: {
|
||||
repo: "openclaw/openclaw",
|
||||
issue_number: 76686,
|
||||
intent: "automerge",
|
||||
comment_updated_at: "2026-05-03T13:05:00Z",
|
||||
},
|
||||
entries,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("automerge job helpers create stable adopted PR job identity", () => {
|
||||
assert.equal(automergeClusterId("openclaw/openclaw", 74112), "automerge-openclaw-openclaw-74112");
|
||||
assert.equal(
|
||||
@ -467,6 +561,18 @@ test("parseCommand recognizes ClawSweeper bot mentions", () => {
|
||||
intent: "implement_issue",
|
||||
implementation_prompt: "",
|
||||
});
|
||||
assert.deepEqual(parseCommand("@clawsweeper fix"), {
|
||||
trigger: "mention",
|
||||
command: "fix",
|
||||
intent: "implement_issue",
|
||||
implementation_prompt: "",
|
||||
});
|
||||
assert.deepEqual(parseCommand("@clawsweeper fix\nPlease keep it narrow."), {
|
||||
trigger: "mention",
|
||||
command: "fix issue please keep it narrow",
|
||||
intent: "implement_issue",
|
||||
implementation_prompt: "Please keep it narrow.",
|
||||
});
|
||||
assert.deepEqual(parseCommand("@clawsweeper build\nAdd export support."), {
|
||||
trigger: "mention",
|
||||
command: "build add export support",
|
||||
@ -597,6 +703,59 @@ 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(
|
||||
{
|
||||
user: { login: "clawsweeper[bot]" },
|
||||
body: [
|
||||
"ClawSweeper says this needs maintainer judgment.",
|
||||
"",
|
||||
"**Review findings**",
|
||||
"- **[P1] Unwrap sudo reset-timestamp carriers:** `src/infra/command-carriers.ts:74`",
|
||||
"<!-- clawsweeper-verdict:needs-human sha=abc123 -->",
|
||||
].join("\n"),
|
||||
},
|
||||
{ trustedAuthors },
|
||||
);
|
||||
|
||||
assert.equal(parsed.intent, "clawsweeper_auto_repair");
|
||||
assert.equal(parsed.expected_head_sha, "abc123");
|
||||
assert.match(parsed.repair_reason, /repairable P-severity findings/);
|
||||
});
|
||||
|
||||
test("parseTrustedAutomation accepts explicit repair verdicts", () => {
|
||||
const trustedAuthors = new Set(["clawsweeper[bot]"]);
|
||||
const parsed = parseTrustedAutomation(
|
||||
@ -854,13 +1013,52 @@ test("renderResponse reports trusted repair dispatches without losing guardrails
|
||||
assert.doesNotMatch(body, /ClawSweeper Repair/i);
|
||||
});
|
||||
|
||||
test("renderResponse gives command replies a lobster badge", () => {
|
||||
test("renderResponse gives command replies stateful lobster badges", () => {
|
||||
const body = renderResponse({ comment_id: "456", intent: "help", target: {} }, null);
|
||||
const reviewBody = renderResponse(
|
||||
{ comment_id: "457", intent: "re_review", target: {} },
|
||||
{ clawsweeper: { workflow: "sweep.yml" } },
|
||||
);
|
||||
const repairBody = renderResponse(
|
||||
{ comment_id: "458", intent: "implement_issue", target: {} },
|
||||
{ model: "gpt-5.5" },
|
||||
);
|
||||
const doneBody = renderResponse(
|
||||
{
|
||||
comment_id: "459",
|
||||
intent: "clawsweeper_auto_merge",
|
||||
target: {},
|
||||
trusted_bot_author: "clawsweeper[bot]",
|
||||
},
|
||||
{ merge: { status: "executed" } },
|
||||
);
|
||||
|
||||
assert.match(
|
||||
body,
|
||||
/^<!-- clawsweeper-command-status:unknown:help:na -->\n<!-- clawsweeper-command:456:help:na -->\n🦞🦞\nClawSweeper is here/,
|
||||
/^<!-- clawsweeper-command-status:unknown:help:na -->\n<!-- clawsweeper-command:456:help:na -->\n🦞👀\nClawSweeper is here/,
|
||||
);
|
||||
assert.match(reviewBody, /\n🦞🧹\nClawSweeper re-review requested/);
|
||||
assert.match(repairBody, /\n🦞🔧\nClawSweeper issue implementation requested/);
|
||||
assert.match(doneBody, /\n🦞✅\nClawSweeper merged this PR/);
|
||||
assert.match(body, /@clawsweeper fix/);
|
||||
});
|
||||
|
||||
test("renderResponse describes stop as revoking repair-loop labels", () => {
|
||||
const body = renderResponse(
|
||||
{
|
||||
comment_id: "456",
|
||||
intent: "stop",
|
||||
target: { head_sha: "abc123" },
|
||||
actions: [
|
||||
{ action: "remove_label", label: "clawsweeper:automerge", status: "executed" },
|
||||
{ action: "label", label: "clawsweeper:human-review", status: "executed" },
|
||||
],
|
||||
},
|
||||
null,
|
||||
);
|
||||
|
||||
assert.match(body, /added `clawsweeper:human-review`/);
|
||||
assert.match(body, /removed `clawsweeper:automerge`/);
|
||||
});
|
||||
|
||||
test("renderResponse avoids self-linking current item numbers in status replies", () => {
|
||||
@ -945,7 +1143,7 @@ test("renderResponse reports automerge repair dispatches as enabled", () => {
|
||||
);
|
||||
|
||||
assert.match(body, /ClawSweeper automerge is enabled/);
|
||||
assert.match(body, /Action: repair worker queued/);
|
||||
assert.match(body, /- Action: repair worker queued/);
|
||||
assert.doesNotMatch(body, /could not enable automerge/);
|
||||
assert.doesNotMatch(body, /requires a pull request/);
|
||||
assert.doesNotMatch(body, /automerge-openclaw-openclaw-75401/);
|
||||
@ -991,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/);
|
||||
});
|
||||
|
||||
@ -4,6 +4,7 @@ import test from "node:test";
|
||||
import {
|
||||
automergeRepairOutcomeComment,
|
||||
externalMessageProvenance,
|
||||
issueImplementationResultStatusComment,
|
||||
repairContributorBranchComment,
|
||||
} from "../../dist/repair/external-messages.js";
|
||||
|
||||
@ -54,6 +55,37 @@ test("repairContributorBranchComment avoids self PR references", () => {
|
||||
assert.doesNotMatch(body, /75183/);
|
||||
});
|
||||
|
||||
test("issueImplementationResultStatusComment appends and updates PR link section", () => {
|
||||
const existing = [
|
||||
"<!-- clawsweeper-command-status:76734:implement_issue:na -->",
|
||||
"ClawSweeper issue implementation requested.",
|
||||
"",
|
||||
"Action: repair worker queued.",
|
||||
].join("\n");
|
||||
const first = issueImplementationResultStatusComment({
|
||||
existingBody: existing,
|
||||
prUrl: "https://github.com/openclaw/openclaw/pull/76744",
|
||||
branch: "clawsweeper/issue-openclaw-openclaw-76734",
|
||||
runUrl: "https://github.com/openclaw/clawsweeper/actions/runs/25282203827",
|
||||
completedAt: "2026-05-03T14:52:08Z",
|
||||
});
|
||||
|
||||
assert.match(first, /clawsweeper-command-status:76734:implement_issue:na/);
|
||||
assert.match(first, /Result: implementation PR opened/);
|
||||
assert.match(first, /https:\/\/github\.com\/openclaw\/openclaw\/pull\/76744/);
|
||||
assert.match(first, /clawsweeper\/issue-openclaw-openclaw-76734/);
|
||||
|
||||
const second = issueImplementationResultStatusComment({
|
||||
existingBody: first,
|
||||
prUrl: "https://github.com/openclaw/openclaw/pull/76745",
|
||||
branch: "clawsweeper/issue-openclaw-openclaw-76734",
|
||||
});
|
||||
|
||||
assert.match(second, /https:\/\/github\.com\/openclaw\/openclaw\/pull\/76745/);
|
||||
assert.doesNotMatch(second, /pull\/76744/);
|
||||
assert.equal(second.match(/clawsweeper-issue-implementation-result/g)?.length, 1);
|
||||
});
|
||||
|
||||
test("external message provenance normalizes accidental xhigh reasoning", () => {
|
||||
const provenance = externalMessageProvenance({ model: "gpt-test", reasoning: "xhigh" });
|
||||
const body = automergeRepairOutcomeComment({
|
||||
|
||||
19
test/repair/github-cli.test.ts
Normal file
19
test/repair/github-cli.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
@ -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/);
|
||||
});
|
||||
|
||||
@ -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],
|
||||
);
|
||||
});
|
||||
|
||||
@ -17,6 +17,9 @@ test("codexSubprocessEnv forces ClawSweeper git identity and strips tokens", ()
|
||||
CLAWSWEEPER_TARGET_GH_TOKEN: "secret",
|
||||
GH_TOKEN: "secret",
|
||||
GITHUB_TOKEN: "secret",
|
||||
GITHUB_ACTIONS: "true",
|
||||
OPENAI_API_KEY: "secret",
|
||||
CODEX_API_KEY: "secret",
|
||||
},
|
||||
() => {
|
||||
const env = codexSubprocessEnv();
|
||||
@ -28,6 +31,8 @@ test("codexSubprocessEnv forces ClawSweeper git identity and strips tokens", ()
|
||||
assert.equal(env.GH_TOKEN, undefined);
|
||||
assert.equal(env.GITHUB_TOKEN, undefined);
|
||||
assert.equal(env.CLAWSWEEPER_TARGET_GH_TOKEN, undefined);
|
||||
assert.equal(env.OPENAI_API_KEY, undefined);
|
||||
assert.equal(env.CODEX_API_KEY, undefined);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -140,6 +140,37 @@ test("validation parser requires env assignments before env command", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("validation preflight accepts scoped OpenGrep commands", () => {
|
||||
const cwd = packageFixture({ "check:changed": "node check.js" });
|
||||
const command =
|
||||
"scripts/run-opengrep.sh --error -- src/infra/net/http-connect-tunnel.ts src/infra/push-apns-http2.ts src/infra/push-apns.ts";
|
||||
|
||||
assert.deepEqual(parseAllowedValidationCommand(command), [
|
||||
"scripts/run-opengrep.sh",
|
||||
"--error",
|
||||
"--",
|
||||
"src/infra/net/http-connect-tunnel.ts",
|
||||
"src/infra/push-apns-http2.ts",
|
||||
"src/infra/push-apns.ts",
|
||||
]);
|
||||
assert.deepEqual(
|
||||
preflightTargetValidationPlan(
|
||||
{
|
||||
fixArtifact: {
|
||||
validation_commands: [command],
|
||||
},
|
||||
targetDir: cwd,
|
||||
},
|
||||
validationOptions("openclaw/openclaw"),
|
||||
),
|
||||
{
|
||||
status: "passed",
|
||||
resolved_commands: ["pnpm check:changed"],
|
||||
available_scripts: ["check:changed"],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("validation preflight preserves scoped git diff checks", () => {
|
||||
const cwd = packageFixture({ "check:changed": "node check.js" });
|
||||
const sourceHead = "0123456789abcdef0123456789abcdef01234567";
|
||||
|
||||
@ -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-"));
|
||||
|
||||
@ -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],
|
||||
|
||||
Loading…
Reference in New Issue
Block a user