perf: route ClawSweeper comments immediately

This commit is contained in:
Peter Steinberger 2026-05-01 04:14:21 +01:00
parent 435ec933dc
commit ee50422df5
No known key found for this signature in database
8 changed files with 284 additions and 45 deletions

View File

@ -1,6 +1,8 @@
name: repair comment router
on:
repository_dispatch:
types: [clawsweeper_comment]
workflow_dispatch:
inputs:
execute:
@ -33,6 +35,11 @@ on:
required: false
default: ""
type: string
comment_ids:
description: "Optional comma-separated issue comment ids to route directly"
required: false
default: ""
type: string
runner:
description: "Runner label for repair planning/review work"
required: true
@ -44,7 +51,7 @@ on:
default: blacksmith-16vcpu-ubuntu-2404
type: string
schedule:
- cron: "*/10 * * * *"
- cron: "*/5 * * * *"
permissions:
contents: write
@ -65,6 +72,15 @@ jobs:
with:
filter: blob:none
fetch-depth: 0
sparse-checkout: |
.github
jobs
results
src/repair
package.json
pnpm-lock.yaml
tsconfig.repair.json
sparse-checkout-cone-mode: false
- name: Create GitHub App token
id: app_token
@ -95,8 +111,17 @@ jobs:
since="${{ github.event_name == 'workflow_dispatch' && inputs.since || '' }}"
max_comments="${{ github.event_name == 'workflow_dispatch' && inputs.max_comments || vars.CLAWSWEEPER_COMMENT_MAX_COMMENTS || '100' }}"
item_numbers="${{ github.event_name == 'workflow_dispatch' && inputs.item_numbers || '' }}"
comment_ids="${{ github.event_name == 'workflow_dispatch' && inputs.comment_ids || '' }}"
runner="${{ github.event_name == 'workflow_dispatch' && inputs.runner || vars.CLAWSWEEPER_WORKER_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}"
execution_runner="${{ github.event_name == 'workflow_dispatch' && inputs.execution_runner || vars.CLAWSWEEPER_EXECUTION_RUNNER || 'blacksmith-16vcpu-ubuntu-2404' }}"
if [ "${{ github.event_name }}" = "repository_dispatch" ]; then
target_repo="${{ github.event.client_payload.target_repo || 'openclaw/openclaw' }}"
lookback_minutes="${{ github.event.client_payload.lookback_minutes || vars.CLAWSWEEPER_COMMENT_LOOKBACK_MINUTES || '180' }}"
since=""
max_comments="${{ github.event.client_payload.max_comments || vars.CLAWSWEEPER_COMMENT_MAX_COMMENTS || '20' }}"
item_numbers="${{ github.event.client_payload.item_number || '' }}"
comment_ids="${{ github.event.client_payload.comment_id || '' }}"
fi
args=(
--write-report
@ -113,7 +138,11 @@ jobs:
if [ -n "$item_numbers" ]; then
args+=(--item-numbers "$item_numbers")
fi
if [ -n "$comment_ids" ]; then
args+=(--comment-ids "$comment_ids")
fi
if { [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.execute }}" = "true" ]; } ||
{ [ "${{ github.event_name }}" = "repository_dispatch" ]; } ||
{ [ "${{ github.event_name }}" = "schedule" ] && [ "${{ vars.CLAWSWEEPER_COMMENT_ROUTER_EXECUTE || '0' }}" = "1" ]; }; then
args+=(--execute)
fi
@ -122,6 +151,10 @@ jobs:
- name: Commit comment router ledger
if: always()
run: |
if [ -z "$(git status --porcelain -- results/comment-router.json jobs)" ]; then
echo "No durable comment-router ledger or job changes to publish."
exit 0
fi
pnpm run repair:publish-main -- \
--message "chore: record ClawSweeper comment routing" \
--path results/comment-router.json \

View File

@ -349,6 +349,10 @@ jobs:
- name: Commit event comment router ledger
if: always()
run: |
if [ -z "$(git status --porcelain -- results/comment-router.json jobs)" ]; then
echo "No durable event comment-router ledger or job changes to publish."
exit 0
fi
pnpm run repair:publish-main -- \
--message "chore: record ClawSweeper event comment routing" \
--path results/comment-router.json \

View File

@ -174,6 +174,10 @@ Live preflight hydrates job-provided refs by default and records linked refs wit
## Maintainer Comment Routing
`pnpm run repair:comment-router` scans recent issue and PR comments in the target repo.
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.
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.
@ -210,10 +214,13 @@ include the target PR head SHA. That lets edited ClawSweeper comments wake
ClawSweeper again after the PR branch changes while unchanged comment versions
remain idempotent.
Use `--item-number <number>` or `--item-numbers <a,b>` to route only specific
open issue or PR comments. The event review workflow uses this targeted path
after syncing its durable ClawSweeper verdict so automerge can act on a fresh
`pass` marker without waiting for the scheduled comment-router sweep.
Use `--comment-id <id>`, `--comment-ids <a,b>`, `--item-number <number>`, or
`--item-numbers <a,b>` to route only specific comments or specific open issue
or PR comments. The event review workflow uses this targeted path after syncing
its durable ClawSweeper verdict so automerge can act on a fresh `pass` marker
without waiting for the scheduled comment-router sweep. No-op targeted
acknowledgements, such as already-processed commands or already-enabled
automerge commands, do not publish a durable ledger commit.
If the adopted automerge worker returns no executable fix artifact, the
executor posts one idempotent outcome comment on the opted-in PR. That status

View File

@ -22,6 +22,8 @@ name: ClawSweeper Dispatch
on:
issues:
types: [opened, reopened, edited, labeled, unlabeled]
issue_comment:
types: [created, edited]
pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned external dispatch; no checkout or untrusted PR code execution
types: [opened, reopened, synchronize, ready_for_review, edited, labeled, unlabeled]
@ -54,8 +56,22 @@ jobs:
private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}
owner: openclaw
repositories: clawsweeper
permission-contents: write
- name: Create target comment token
id: target_token
if: ${{ github.event_name == 'issue_comment' && env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }}
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }}
private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
permission-issues: write
permission-pull-requests: read
- name: Dispatch exact ClawSweeper review
if: ${{ github.event_name != 'issue_comment' }}
env:
GH_TOKEN: ${{ steps.token.outputs.token }}
TARGET_REPO: ${{ github.repository }}
@ -79,9 +95,50 @@ jobs:
gh api repos/openclaw/clawsweeper/dispatches \
--method POST \
--input - <<< "$payload"
- name: Acknowledge and dispatch ClawSweeper comment
if: ${{ github.event_name == 'issue_comment' }}
env:
DISPATCH_TOKEN: ${{ steps.token.outputs.token }}
TARGET_TOKEN: ${{ steps.target_token.outputs.token }}
TARGET_REPO: ${{ github.repository }}
ITEM_NUMBER: ${{ github.event.issue.number }}
COMMENT_ID: ${{ github.event.comment.id }}
COMMENT_BODY: ${{ github.event.comment.body }}
SOURCE_ACTION: ${{ github.event.action }}
run: |
if [ -z "$DISPATCH_TOKEN" ]; then
echo "::notice::Skipping ClawSweeper dispatch because no dispatch credential is configured."
exit 0
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
echo "No ClawSweeper command found in comment."
exit 0
fi
if [ -n "$TARGET_TOKEN" ]; then
GH_TOKEN="$TARGET_TOKEN" gh api -X POST \
-H "Accept: application/vnd.github+json" \
"repos/$TARGET_REPO/issues/comments/$COMMENT_ID/reactions" \
-f content="eyes" >/dev/null || true
fi
payload="$(jq -nc \
--arg target_repo "$TARGET_REPO" \
--argjson item_number "$ITEM_NUMBER" \
--argjson comment_id "$COMMENT_ID" \
--arg source_event "issue_comment" \
--arg source_action "$SOURCE_ACTION" \
'{event_type:"clawsweeper_comment",client_payload:{target_repo:$target_repo,item_number:$item_number,comment_id:$comment_id,source_event:$source_event,source_action:$source_action}}')"
GH_TOKEN="$DISPATCH_TOKEN" gh api repos/openclaw/clawsweeper/dispatches \
--method POST \
--input - <<< "$payload"
```
Comments are intentionally not a trigger. Bot-authored label churn is also
Comments are a lightweight trigger only when the body contains a ClawSweeper
command. The target workflow reacts with `eyes` immediately and dispatches
`clawsweeper_comment` to the comment router with the exact comment id, so it
does not need to wait for the scheduled sweep. Bot-authored label churn is also
ignored. Human label changes are debounced and may run after an active
dispatcher, but they must not cancel a content-changing dispatch before it posts
to ClawSweeper. Content-changing events such as issue edits and PR synchronizes

View File

@ -125,6 +125,7 @@ export function readLedger(file: JsonValue) {
export function appendLedger(current: LooseRecord, entries: LooseRecord[]) {
const compact = entries
.filter((entry: JsonValue) => ["executed", "skipped"].includes(entry.status))
.filter((entry: JsonValue) => !isNoopSkip(entry))
.map((entry: JsonValue) => {
const actions = compactLedgerActions(entry.actions);
return {
@ -161,12 +162,36 @@ export function appendLedger(current: LooseRecord, entries: LooseRecord[]) {
...(actions.length > 0 ? { actions } : {}),
};
});
if (compact.length === 0) return false;
const byCommentVersion = new Map(
(current.commands ?? []).map((entry: JsonValue) => [ledgerEntryKey(entry), entry]),
);
for (const entry of compact) byCommentVersion.set(ledgerEntryKey(entry), entry);
let changed = false;
for (const entry of compact) {
const key = ledgerEntryKey(entry);
const previous = byCommentVersion.get(key);
if (previous && stableLedgerEntry(previous) === stableLedgerEntry(entry)) continue;
byCommentVersion.set(key, entry);
changed = true;
}
if (!changed) return false;
current.updated_at = new Date().toISOString();
current.commands = [...byCommentVersion.values()].slice(-1000);
return true;
}
function isNoopSkip(entry: LooseRecord) {
if (String(entry.status ?? "") !== "skipped") return false;
const reason = String(entry.reason ?? "");
return (
reason === "comment version already processed in ledger" ||
reason === "matching ClawSweeper response comment already exists" ||
/already enabled for this PR/i.test(reason)
);
}
function stableLedgerEntry(entry: LooseRecord) {
return JSON.stringify({ ...entry, processed_at: null });
}
function ledgerEntryKey(entry: LooseRecord) {

View File

@ -90,11 +90,14 @@ const {
lookupConcurrency,
since,
itemNumbers,
commentIds,
allowedAssociations,
allowedRepositoryPermissions,
trustedBots,
} = config;
const startedAtMs = Date.now();
const timings: LooseRecord[] = [];
const ledger = readLedger(ledgerPath());
const TARGET_LOOKUP_RETRY_ATTEMPTS = 3;
const processedCommentVersions = new Set(
@ -107,7 +110,7 @@ const plannedAutoRepairHeads = new Set<string>();
const collaboratorPermissionCache = new Map();
const liveTargetCache = new Map<number, LooseRecord>();
const issueCommentsCache = new Map<number, JsonValue[]>();
const comments = listCandidateComments();
const comments = measure("list_candidate_comments", () => listCandidateComments());
const rawCommands: LooseRecord[] = [];
for (const comment of comments) {
@ -148,8 +151,10 @@ for (const comment of comments) {
rawCommands.push(command);
}
await prehydrateCommandLookups(rawCommands);
const commands = rawCommands.map((command) => classifyCommand(command));
await measureAsync("prehydrate_command_lookups", () => prehydrateCommandLookups(rawCommands));
const commands = measure("classify_commands", () =>
rawCommands.map((command) => classifyCommand(command)),
);
const actionable = commands.filter((command: JsonValue) => command.status === "ready");
const report: LooseRecord = {
@ -162,6 +167,7 @@ const report: LooseRecord = {
execute,
max_comments: maxComments,
item_numbers: [...itemNumbers],
comment_ids: [...commentIds],
max_autoclose_targets: maxAutocloseTargets,
scanned_comments: comments.length,
commands_seen: commands.length,
@ -175,34 +181,59 @@ const report: LooseRecord = {
};
if (execute) {
assertMutationActorIsClawsweeperBot();
for (const command of commands) acknowledgeSkippedMaintainerCommand(command);
const dispatchCount = actionable.filter((command: JsonValue) =>
REPAIR_INTENTS.has(command.intent),
).length;
if (dispatchCount > 0) {
report.live_worker_capacity_before_dispatch = waitForCapacity
? waitForLiveWorkerCapacity({
repo: repairRepo,
workflow,
requested: dispatchCount,
maxLiveWorkers,
})
: assertLiveWorkerCapacity({
repo: repairRepo,
workflow,
requested: dispatchCount,
maxLiveWorkers,
});
}
for (const command of actionable) executeCommand(command);
appendLedger(ledger, commands);
writeLedger(ledgerPath(), ledger);
await measureAsync("execute_commands", async () => {
assertMutationActorIsClawsweeperBot();
for (const command of commands) acknowledgeSkippedMaintainerCommand(command);
const dispatchCount = actionable.filter(
(command: JsonValue) =>
REPAIR_INTENTS.has(command.intent) || MERGE_INTENTS.has(command.intent),
).length;
if (dispatchCount > 0) {
report.live_worker_capacity_before_dispatch = waitForCapacity
? waitForLiveWorkerCapacity({
repo: repairRepo,
workflow,
requested: dispatchCount,
maxLiveWorkers,
})
: assertLiveWorkerCapacity({
repo: repairRepo,
workflow,
requested: dispatchCount,
maxLiveWorkers,
});
}
for (const command of actionable) executeCommand(command);
});
report.ledger_changed = measure("append_ledger", () => appendLedger(ledger, commands));
if (report.ledger_changed) writeLedger(ledgerPath(), ledger);
}
report.timings = {
total_ms: Date.now() - startedAtMs,
phases: timings,
};
if (writeReport) writeReportFile(repoRoot(), report);
console.log(JSON.stringify(report, null, 2));
function measure<T>(name: string, fn: () => T): T {
const start = Date.now();
try {
return fn();
} finally {
timings.push({ name, ms: Date.now() - start });
}
}
async function measureAsync<T>(name: string, fn: () => Promise<T>): Promise<T> {
const start = Date.now();
try {
return await fn();
} finally {
timings.push({ name, ms: Date.now() - start });
}
}
function assertMutationActorIsClawsweeperBot() {
try {
const viewer = ghJson<LooseRecord>(["api", "user"]);
@ -1407,6 +1438,16 @@ function executeAutomerge(command: LooseRecord) {
};
const block = validateAutomergeReadiness({ command, view, target: latestTarget });
if (block) {
if (isAutomergeChangelogBlock(block)) {
return {
action: "merge",
status: "repair_needed",
reason: block,
repair_reason:
"CHANGELOG.md entry is required before automerge; dispatch a focused changelog repair",
merge_method: "squash",
};
}
if (isTransientAutomergeBlock(block, view)) {
return { action: "merge", status: "waiting", reason: block, merge_method: "squash" };
}
@ -1624,6 +1665,10 @@ function validateAutomergeReadiness({ command, view, target }: LooseRecord) {
return "";
}
function isAutomergeChangelogBlock(reason: string) {
return /CHANGELOG\.md entry is required/i.test(String(reason ?? ""));
}
function isTransientAutomergeBlock(reason: string, view: LooseRecord) {
const text = String(reason ?? "").toLowerCase();
if (text.includes("checks are not green")) return hasPendingChecks(view.statusCheckRollup ?? []);
@ -1713,6 +1758,13 @@ function listRecentComments() {
}
function listCandidateComments() {
if (commentIds.size > 0) {
return selectCommentsForRouting({
recentComments: [...commentIds].map((commentId) => fetchIssueComment(commentId)),
durableComments: [],
maxComments,
});
}
if (itemNumbers.size > 0) {
return selectCommentsForRouting({
recentComments: [...itemNumbers].flatMap((number) => issueCommentsFor(number)),
@ -1766,6 +1818,12 @@ function listRepairLoopReviewComments() {
);
}
function fetchIssueComment(commentId: JsonValue) {
return ghJson(["api", `repos/${targetRepo}/issues/comments/${commentId}`], {
attempts: TARGET_LOOKUP_RETRY_ATTEMPTS,
});
}
function listOpenIssueNumbersWithLabel(label: string) {
return ghPaged<JsonValue>(
`repos/${targetRepo}/issues?state=open&labels=${encodeURIComponent(label)}&per_page=100`,

View File

@ -35,6 +35,7 @@ export type CommentRouterConfig = {
lookbackMinutes: number;
since: string;
itemNumbers: Set<number>;
commentIds: Set<number>;
allowedAssociations: Set<string>;
allowedRepositoryPermissions: Set<string>;
trustedBots: Set<string>;
@ -128,6 +129,12 @@ export function readCommentRouterConfig(args: LooseRecord): CommentRouterConfig
.join(","),
"item-numbers",
),
commentIds: numberSet(
[args["comment-id"], args["comment-ids"], process.env.CLAWSWEEPER_COMMENT_IDS]
.filter((value) => value !== undefined && value !== null)
.join(","),
"comment-ids",
),
allowedAssociations: upperCaseSet(
process.env.CLAWSWEEPER_COMMENT_ALLOWED_ASSOCIATIONS ??
DEFAULT_ALLOWED_ASSOCIATIONS.join(","),

View File

@ -48,22 +48,70 @@ test("appendLedger keeps edited comment versions separate", () => {
test("appendLedger leaves waiting commands retryable", () => {
const ledger = { updated_at: null, commands: [] };
appendLedger(ledger, [
{
idempotency_key: "transient",
comment_id: "124",
comment_version_key: "124:2026-04-29T03:00:00Z",
comment_updated_at: "2026-04-29T03:00:00Z",
status: "waiting",
intent: "clawsweeper_re_review",
issue_number: 74499,
repo: "openclaw/openclaw",
},
]);
assert.equal(
appendLedger(ledger, [
{
idempotency_key: "transient",
comment_id: "124",
comment_version_key: "124:2026-04-29T03:00:00Z",
comment_updated_at: "2026-04-29T03:00:00Z",
status: "waiting",
intent: "clawsweeper_re_review",
issue_number: 74499,
repo: "openclaw/openclaw",
},
]),
false,
);
assert.equal(ledger.commands.length, 0);
});
test("appendLedger ignores no-op skipped command versions", () => {
const ledger = { updated_at: null, commands: [] };
assert.equal(
appendLedger(ledger, [
{
idempotency_key: "already-processed",
comment_id: "124",
comment_version_key: "124:2026-04-29T03:00:00Z",
comment_updated_at: "2026-04-29T03:00:00Z",
status: "skipped",
reason: "comment version already processed in ledger",
intent: "automerge",
issue_number: 74499,
repo: "openclaw/openclaw",
},
]),
false,
);
assert.equal(ledger.commands.length, 0);
});
test("appendLedger reports compact executed writes", () => {
const ledger = { updated_at: null, commands: [] };
assert.equal(
appendLedger(ledger, [
{
idempotency_key: "processed",
comment_id: "125",
comment_version_key: "125:2026-04-29T03:01:00Z",
comment_updated_at: "2026-04-29T03:01:00Z",
status: "executed",
intent: "clawsweeper_re_review",
issue_number: 74499,
repo: "openclaw/openclaw",
},
]),
true,
);
assert.equal(ledger.commands.length, 1);
});
test("appendLedger preserves compact executed actions for repair caps", () => {
const ledger = { updated_at: null, commands: [] };