Skip to content

Qwen Autofix

Qwen Autofix #276

Workflow file for this run

name: 'Qwen Autofix'
# One workflow for the whole autonomous-fix lifecycle:
#
# issue → locate → fix → open PR (issue phase)
# open PR → review → triage → fix → push (review phase)
#
# The lifecycle is asynchronous — a PR is opened in one run and its review is
# addressed in a later run once a reviewer has weighed in — so each scheduled
# tick runs only the phase(s) that make sense, decided by the `route` job:
# • every 10m → review phase; issue phase only if no PR needs work
# • issues:labeled → issue phase when ready label, state, and sender match
# • pull_request_review → review phase for submitted feedback on bot PRs
# • pull_request:labeled → maintainer applies autofix/takeover → the loop
# manages that PR (human-authored included, and
# maintainer FORKS too: the fork's author must
# hold write+ live and the PR must allow
# maintainer edits — the bot then fetches/pushes
# the fork branch directly; org-owned forks
# cannot enable allow-edits → adoption instead);
# unlabeled releases it. autofix/skip opts any PR
# out everywhere and wins over takeover. Labels
# need GitHub triage+, so the permission gate is
# GitHub's own. The bot's OWN fork PRs (author ==
# the autofix bot, e.g. its codex flow) are auto-
# managed WITHOUT a label when allow-edits is on —
# they are the bot's own generated work, trust-
# equal to an in-repo bot PR; autofix/skip still
# opts them out.
# • issue_comment → '@qwen-code /takeover' (apply the label) and
# '@qwen-code /takeover stop' (remove it) — sugar
# for people without label access: the PR author,
# or write+ collaborators. Exact-match constants,
# and the ONLY side effect is the label toggle;
# engagement/release still happen exclusively via
# the label events, so manual labeling and the
# commands are the same single mechanism.
# • workflow_dispatch → force a phase, an issue, or a PR
#
# Every GitHub write (issue/PR comments, labels, branch push, PR create) goes
# through CI_DEV_BOT_PAT so the bot acts as the configured autofix identity.
# PAT label writes can emit issues:labeled events; the route guards below make
# those runs exit unless the label, issue state, and ready label all match.
on:
issues:
types:
- 'labeled'
- 'assigned'
pull_request_review:
types:
- 'submitted'
pull_request:
types:
- 'labeled'
- 'unlabeled'
issue_comment:
types:
- 'created'
schedule:
- cron: '*/10 * * * *' # Review first; issue fallback only when no PR needs work
workflow_dispatch:
inputs:
phase:
description: 'Which phase(s) to run'
required: false
default: 'auto'
type: 'choice'
options:
- 'auto' # review always; issue on schedule or ready-for-agent label
- 'issue' # locate + fix one bug only
- 'review' # address review on open PRs only
- 'both' # issue and review
issue_number:
description: 'Force a specific issue number (implies the issue phase)'
required: false
type: 'string'
pr_number:
description: 'Force a specific bot PR number (implies the review phase)'
required: false
type: 'string'
dry_run:
description: 'Assess/develop/address and verify, but do not claim, push, or comment'
required: false
type: 'boolean'
default: false
defaults:
run:
shell: 'bash'
permissions:
contents: 'read'
env:
# Identity of the autofix bot. All open in-repo PRs authored by this bot are
# eligible for the review phase — not limited to autofix/issue-* branches.
AUTOFIX_BOT: "${{ vars.AUTOFIX_BOT_LOGIN || 'qwen-code-dev-bot' }}"
# Branch-name prefix used by the issue phase when creating PRs. Also used
# for duplicate-PR detection and issue-number extraction from branch names.
BRANCH_PREFIX: 'autofix/issue-'
# The automated Qwen PR reviewer posts as this account; its review counts as
# actionable feedback even though it is not a human collaborator.
REVIEW_BOT: 'qwen-code-ci-bot'
# Human reviews/comments only count when the author is a real maintainer. This
# is the prompt-injection trust gate: feedback from anyone else is ignored so a
# hostile commenter cannot steer the agent.
TRUSTED_ASSOC: '["OWNER", "MEMBER", "COLLABORATOR"]'
# Hard cap on automated review-address rounds per PR. After this the bot stops
# and leaves the PR for a human. Raised from 5: across the last 40 bot PRs
# only 3 ever reached the cap and all 3 merged AT it (one having spent two of
# its five rounds on the verify-gate ENOENT that #7330 fixed), so the ceiling
# was near enough to bind on a bad day without any headroom for one. The cap
# exists to stop an unproductive LOOP, not to ration ordinary iteration —
# a genuinely stuck PR still stops, just later.
MAX_ROUNDS: '10'
# Suggestions may improve a PR, but continuing to implement them after ten
# change-producing rounds expands the diff and creates fresh review churn.
# From round 11 onward, only Critical findings, formally requested changes,
# failed checks, and base conflicts may drive code changes; lower-severity
# feedback is recorded and left open.
CRITICAL_ONLY_AFTER_ROUND: '10'
# Per-author tail budget inside Critical-only mode. An account is an
# ACCOUNTABILITY unit, not a throttle: a human login can host an automated
# reviewer loop with the exact regeneration property the review bot has
# (feedback re-generated after every push, at zero marginal cost). So the
# brake keys on measured regeneration, not identity: every source gets a
# bounded number of untagged feedback batches per counting window once
# Critical-only engages — the review bot's budget is zero (all deferred),
# a human's is this many CONSUMED batches. Past it, continuing requires
# one conscious act (**[Critical]**, a Request changes review, or /retry),
# which is precisely what separates intent from automation.
CRITICAL_ONLY_HUMAN_BATCHES: '2'
# An auth/access model error (401/402/403, "no access"/"does not exist")
# never self-heals - only a maintainer can fix the key - and every retry
# costs an agent run AND a PR comment. Cap those attempts far below
# MAX_ROUNDS so the actionable "check the model key" message lands in an
# hour instead of a day. Transient (429/5xx) errors keep the full budget.
API_AUTH_MAX_ROUNDS: '3'
# Checks the "wait for checks to settle" gate does NOT wait for. The gate
# exists so a FAILED check can be read as feedback, which is why build/test/
# lint are still waited for. `review-pr` is the LLM code review: its output
# is a REVIEW, delivered by its own real-time pull_request_review trigger and
# counted by the review path — the check conclusion carries nothing the loop
# acts on. Blocking on it bought nothing and cost a median 49 minutes per
# round (p90 123, max 158, over 32 completed runs), during which the PR was
# invisible to the scan even when it already had unaddressed feedback.
# Names must match job ids in qwen-code-pr-review.yml; a test pins that.
NON_BLOCKING_CHECKS: '["review-pr"]'
# Failed-check annotation patterns that mean the INFRASTRUCTURE died, not the
# code — a self-hosted runner losing the server, the disk filling, a runner
# shutdown, or a git fetch/clone dying mid-transfer. Such a check is red for a
# reason unrelated to the PR and clears on a re-run (observed: #7490's E2E
# "runner lost communication"; #6506's checkout "RPC failed; curl 92" /
# "fetch-pack: invalid index-pack output" — both green on the rerun). The scan
# auto-reruns those failed jobs ONCE, guarded by run_attempt so a persistent
# infra problem cannot loop. Deliberately conservative — only unambiguous
# machine/transport failures, never a bare test-level timeout, which could be
# a real regression (a co-present timeout does not block a match — one
# matching line classifies the run). Case-insensitive, vs the annotations.
INFRA_FAILURE_SIGNATURES: 'lost communication with the server|No space left on device|ENOSPC|received a shutdown signal|The runner has received|Failed to initialize container|runner (was|has been) (lost|terminated)|invalid index-pack output|RPC failed'
# Upper bound on review targets emitted per scan (fan-out defense-in-depth;
# excess is logged and deferred to the next scan).
MAX_TARGETS_PER_SCAN: '10'
# Upper bound on candidates INSPECTED per scan: idle candidates consume
# serial API calls even when they emit nothing, and takeover widens the
# candidate pool. Candidates are inspected NEWEST-first; past the budget
# the oldest tail defers — old quiet PRs are the least likely to hold new
# feedback, and a deferred PR with a live conflict is still picked up by
# the shepherd's conflict lever.
MAX_CANDIDATE_INSPECTIONS: '60'
# Maintainer-facing engagement labels (applying labels requires GitHub
# triage+, so the permission gate is GitHub's own): TAKEOVER opts a PR —
# including a human-authored one — into the loop; SKIP opts any PR out
# everywhere, and wins when both are present.
TAKEOVER_LABEL: 'autofix/takeover'
SKIP_LABEL: 'autofix/skip'
# Comment-command sugar over TAKEOVER_LABEL ('<cmd>' applies it, '<cmd>
# stop' removes it). Matched EXACTLY against the trimmed comment body.
TAKEOVER_COMMAND: '@qwen-code /takeover'
# Re-arm sugar. Recovering a stranded PR previously meant DELETING the
# bot's autofix-eval marker comment by hand (undiscoverable, destructive,
# and it erases the audit trail). This command instead posts an
# 'autofix-rearm' marker that supersedes the earlier evaluation markers:
# the scan re-reads the feedback from scratch and the round counter resets.
RETRY_COMMAND: '@qwen-code /retry'
# Round cap while TAKEOVER_LABEL is present. Large managed PRs routinely
# need dozens of feedback rounds — that is the point of takeover — so the
# unattended cap (MAX_ROUNDS) would strangle it. The circuit breaker stays
# (a bot↔review-bot ping-pong is still bounded), it is just sized for
# explicitly delegated work; removing the label restores the strict cap,
# and re-engaging opens a fresh counting window (see REARM_KEY below).
TAKEOVER_MAX_ROUNDS: '100'
# Consecutive-failure sub-cap, distinct from the total round cap above. The
# total cap bounds how many PRODUCTIVE rounds a PR may take; this bounds how
# many rounds may fail IN A ROW with nothing pushed. Under takeover a PR gets
# up to 100 rounds, but a PR that fails to push this many times running is not
# iterating, it is stuck — a too-large / fast-conflicting PR whose fix keeps
# timing out or failing the gate. Retrying at the same budget will not fix
# that; a human has to rebase or split it. Any pushed round OR a legitimate
# "no changes needed" no-op resets the streak, so this only ever fires on an
# unbroken run of failures. Observed on #6723: 7 straight failed rounds (3
# timeouts, 4 gate rejections) over 8 hours, heading for 100.
CONSECUTIVE_FAILURE_CAP: '5'
# Cumulative agent-timeout sub-cap, the sibling of the consecutive cap for
# the failure shape it cannot see: timeouts INTERLEAVED with successful
# rounds. A success resets the consecutive streak, but it does not make the
# next timeout any cheaper — each one burns a full agent budget (~50m of
# runner time) and pushes nothing. Observed on #7929: three timeouts with
# pushed rounds in between, so the consecutive cap never fired and the PR
# kept walking into the same wall; #7846 the same, twice. Counted over the
# current counting window (window-scoped like every other census), so a
# re-arm clears it along with the round counter.
TIMEOUT_WINDOW_CAP: '3'
# Do not claim more issues when too many existing autofix PRs are still open.
MAX_OPEN_AUTOFIX_PRS: '5'
jobs:
# ---------------------------------------------------------------------------
# Router: fork the run into phases by schedule/dispatch input.
# ---------------------------------------------------------------------------
route:
# The issue_comment clause is a cheap expression-level prefilter: the
# overwhelming majority of comments never start a job at all. The real
# gates (exact body match, sender authorization) live in 'Decide phases'.
# Nuance: a body with LEADING whitespace dies here even though the decide
# branch would trim it — fail closed, command must start the comment.
# Both commands are prefiltered here (/takeover toggles the label, /retry
# re-arms a stranded PR); everything else never starts a job.
if: |-
${{ github.repository == 'QwenLM/qwen-code' && (github.event_name != 'issue_comment' || (github.event.issue.pull_request && (startsWith(github.event.comment.body, '@qwen-code /takeover') || startsWith(github.event.comment.body, '@qwen-code /retry')))) && (github.event_name != 'pull_request' || github.event.label.name == 'autofix/takeover') }}
runs-on: 'ubuntu-latest'
timeout-minutes: 5
concurrency:
# Concurrency is keyed by TARGET, not shared and not fully unique:
# • cron ticks share one group (a newer tick supersedes a queued one)
# • review events coalesce PER PR (two reviews on the same PR seconds
# apart route once — the old shared group's one useful side effect,
# kept, without letting events on OTHER PRs cancel this one)
# • issue events coalesce PER issue
# • dispatches are unique per run and are never cancelled
# The old single shared cancel-in-progress group let ANY newer event kill
# pending full scans while route jobs sat queued behind runner backlog —
# observed as hours of scan starvation during review-event storms.
# Five cases: schedule → one shared cron group (newer tick supersedes);
# pull_request_review → per-PR, but ONLY when the review payload
# already looks trusted (the group is entered before any step runs, so
# an arbitrary commenter's review would otherwise cancel a queued
# legitimate route and then die in 'Decide phases' — untrusted payloads
# get a run-unique group and still face the real permission gate
# inside; the association literal mirrors TRUSTED_ASSOC and the login
# mirrors REVIEW_BOT); pull_request label events → per-PR (GitHub only
# lets triage+ apply labels, so the whole event class is trusted —
# in their OWN per-PR group (label-{N}), distinct from the review
# group so a simultaneous review and label toggle on the same PR can
# never cancel each other, and only the takeover label routes at all
# (unrelated labels are filtered at the job gate); issue_comment → its own per-PR command group, but
# ONLY when the commenter's payload association already looks trusted
# (same prefilter pattern as reviews — an untrusted commenter must not
# cancel a maintainer's queued command; untrusted payloads get a
# run-unique group and still face the real permission gate inside), so
# a burst of trusted command comments coalesces to at most two runs
# with latest-intent semantics, never touching review routes;
# issues → per-issue; anything else (dispatch) → unique per run_id,
# never cancelled.
group: >-
${{ github.event_name == 'schedule' && 'qwen-autofix-route-cron' || (github.event_name == 'pull_request_review' && (contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.review.author_association) || github.event.review.user.login == 'qwen-code-ci-bot') && format('qwen-autofix-route-pr-{0}', github.event.pull_request.number)) || (github.event_name == 'pull_request' && github.event.label.name == 'autofix/takeover' && format('qwen-autofix-route-label-{0}', github.event.pull_request.number)) || (github.event_name == 'issue_comment' && contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association) && format('qwen-autofix-route-cmd-{0}', github.event.issue.number)) || (github.event_name == 'issues' && format('qwen-autofix-route-issue-{0}', github.event.issue.number)) || format('qwen-autofix-route-{0}', github.run_id) }}
cancel-in-progress: |-
${{ github.event_name != 'workflow_dispatch' }}
permissions:
contents: 'read'
outputs:
do_issue: '${{ steps.decide.outputs.do_issue }}'
do_review: '${{ steps.decide.outputs.do_review }}'
dry_run: '${{ steps.decide.outputs.dry_run }}'
issue_number: '${{ steps.decide.outputs.issue_number }}'
pr_number: '${{ steps.decide.outputs.pr_number }}'
takeover_ack: '${{ steps.decide.outputs.takeover_ack }}'
ack_pr: '${{ steps.decide.outputs.ack_pr }}'
ack_base: '${{ steps.decide.outputs.ack_base }}'
takeover_cmd: '${{ steps.decide.outputs.takeover_cmd }}'
retry_pr: '${{ steps.decide.outputs.retry_pr }}'
cmd_pr: '${{ steps.decide.outputs.cmd_pr }}'
steps:
- name: 'Decide phases'
id: 'decide'
env:
PHASE: '${{ inputs.phase }}'
FORCED_ISSUE: '${{ inputs.issue_number }}'
FORCED_PR: '${{ inputs.pr_number }}'
DRY_RUN_INPUT: '${{ inputs.dry_run }}'
EVENT_NAME: '${{ github.event_name }}'
GITHUB_TOKEN: '${{ github.token }}'
BUG_LABEL: 'type/bug'
ISSUE_LABEL: '${{ github.event.label.name }}'
ISSUE_LABELS_JSON: '${{ toJSON(github.event.issue.labels.*.name) }}'
ISSUE_NUMBER: '${{ github.event.issue.number }}'
ISSUE_STATE: '${{ github.event.issue.state }}'
READY_FOR_AGENT_LABEL: 'status/ready-for-agent'
AUTOFIX_APPROVED_LABEL: 'autofix/approved'
REPO: '${{ github.repository }}'
SENDER_LOGIN: '${{ github.event.sender.login }}'
ASSIGNEE_LOGIN: '${{ github.event.assignee.login }}'
SCHEDULE: '${{ github.event.schedule }}'
PR_AUTHOR: '${{ github.event.pull_request.user.login }}'
PR_NUMBER_EVENT: '${{ github.event.pull_request.number }}'
PR_HEAD_REPO: '${{ github.event.pull_request.head.repo.full_name }}'
PR_BASE_REF: '${{ github.event.pull_request.base.ref }}'
PR_STATE: '${{ github.event.pull_request.state }}'
EVENT_ACTION: '${{ github.event.action }}'
COMMENT_BODY: '${{ github.event.comment.body }}'
COMMENT_PR_AUTHOR: '${{ github.event.issue.user.login }}'
HAS_PR_URL: '${{ github.event.issue.pull_request.url }}'
run: |-
DO_ISSUE=false
DO_REVIEW=false
TAKEOVER_ACK=''
ACK_BASE=''
TAKEOVER_CMD=''
CMD_PR=''
RETRY_PR=''
DRY_RUN="${DRY_RUN_INPUT:-false}"
sanitize_number() {
local value="${1//$'\r'/}"
value="${value//$'\n'/}"
if [[ "${value}" =~ ^[0-9]+$ ]]; then
printf '%s' "${value}"
elif [[ -n "${value}" ]]; then
echo "::warning::Rejected non-numeric routing input: '${value}'" >&2
fi
}
# workflow_dispatch inputs are user-controlled; keep GITHUB_OUTPUT
# routing values single-line numeric before later jobs consume them.
ROUTE_ISSUE="$(sanitize_number "${FORCED_ISSUE}")"
ROUTE_PR="$(sanitize_number "${FORCED_PR}")"
case "${PHASE}" in
issue) DO_ISSUE=true ;;
review) DO_REVIEW=true ;;
both) DO_ISSUE=true; DO_REVIEW=true ;;
*)
# auto only runs review from scheduled/manual events. Label events
# route below after their trust gates pass.
if [[ "${EVENT_NAME}" == 'schedule' || "${EVENT_NAME}" == 'workflow_dispatch' ]]; then
DO_REVIEW=true
fi
# Scheduled runs scan review PRs first; issue-autofix runs only
# when review-scan reports no target.
if [[ "${EVENT_NAME}" == 'schedule' ]]; then
DO_ISSUE=true
fi
# Real-time review triggers: process the SAME managed set the
# scheduled scan does, so feedback is picked up seconds after the
# review instead of waiting for a schedule GitHub throttles hard
# (the */10 cron actually lands every 40-70min on this repo).
# Reviews must come from trusted senders (collaborators or the
# review bot) so arbitrary commenters cannot force expensive
# review-scan runs. Only pull_request_review:submitted triggers
# (not per-comment events) to avoid redundant runs on
# multi-comment reviews.
if [[ "${EVENT_NAME}" == 'pull_request_review' ]]; then
DO_ISSUE=false
pr_is_managed=false
if [[ "${PR_BASE_REF}" != "main" ]]; then
echo "🧭 review event ignored: PR targets '${PR_BASE_REF}' not 'main'"
elif [[ "${PR_HEAD_REPO}" == "${REPO}" ]]; then
if [[ "${PR_AUTHOR}" == "${AUTOFIX_BOT}" ]]; then
pr_is_managed=true
else
echo "🧭 review event ignored: PR author '${PR_AUTHOR}' is not ${AUTOFIX_BOT}"
fi
else
# Fork PR. The scheduled scan already admits these for
# takeover, so real-time pickup applies the SAME admission
# (allow-edits on, and either the bot's own fork or an
# explicit TAKEOVER_LABEL) rather than making the takeover
# PRs — the ones a maintainer is actively waiting on — sit
# through a throttled schedule. This event runs in BASE-repo
# context, and review-address independently re-verifies
# allow-edits, a live write+ author and a matching live head
# repo before it touches the branch, so this only decides
# WHEN that same gated work happens, never whether it may.
fork_meta=''
if fork_meta="$(gh pr view "${PR_NUMBER_EVENT}" --repo "${REPO}" --json labels,maintainerCanModify 2> /dev/null)"; then
fork_allows_edits="$(jq -r '.maintainerCanModify == true' <<< "${fork_meta}")"
fork_has_takeover="$(jq -r --arg t "${TAKEOVER_LABEL}" '[.labels[]?.name] | index($t) != null' <<< "${fork_meta}")"
if [[ "${fork_allows_edits}" != 'true' ]]; then
echo "🧭 review event ignored: fork PR #${PR_NUMBER_EVENT} does not allow maintainer edits"
elif [[ "${PR_AUTHOR}" == "${AUTOFIX_BOT}" || "${fork_has_takeover}" == 'true' ]]; then
pr_is_managed=true
else
echo "🧭 review event ignored: fork PR #${PR_NUMBER_EVENT} is neither ${AUTOFIX_BOT}'s own fork nor ${TAKEOVER_LABEL}-labeled"
fi
else
echo "🧭 review event ignored: could not read fork PR #${PR_NUMBER_EVENT} metadata"
fi
fi
if [[ "${pr_is_managed}" == 'true' ]]; then
# Verify the reviewer/commenter is trusted (prompt-injection gate).
sender_permission=''
sender_is_trusted=false
if [[ "${SENDER_LOGIN}" == "${REVIEW_BOT}" ]]; then
sender_is_trusted=true
elif [[ -n "${SENDER_LOGIN}" ]]; then
api_error_file="$(mktemp)"
if sender_permission="$(gh api "repos/${REPO}/collaborators/${SENDER_LOGIN}/permission" --jq '.permission // ""' 2>"${api_error_file}")"; then
case "${sender_permission}" in
admin|maintain|write) sender_is_trusted=true ;;
esac
else
api_error="$(tr '\r\n' ' ' < "${api_error_file}")"
echo "::warning::Permission API call failed for ${SENDER_LOGIN}: ${api_error:-unknown error}"
sender_permission=''
fi
rm -f "${api_error_file}"
fi
if [[ "${sender_is_trusted}" == "true" ]]; then
DO_REVIEW=true
ROUTE_PR="$(sanitize_number "${PR_NUMBER_EVENT}")"
echo "🧭 review event on bot PR #${PR_NUMBER_EVENT} by ${SENDER_LOGIN} (${sender_permission:-review-bot}) → review phase"
else
echo "🧭 review event ignored: sender '${SENDER_LOGIN}' permission='${sender_permission:-none}' is not trusted"
fi
fi
fi
# Comment-command sugar over the labels: TAKEOVER_COMMAND
# applies TAKEOVER_LABEL, 'TAKEOVER_COMMAND stop' removes it —
# nothing else. The label stays the single source of truth:
# engagement and release happen ONLY via the label events
# below; the command also posts acks directly in both
# directions (#7999, #8002). Exact match on the trimmed body (constants, never
# user-input parsing); allowed senders: the PR author (who may
# lack label access) or a write+ collaborator. This immediately
# narrows a previously fully-closed surface reopened under
# maintainer mandate.
if [[ "${EVENT_NAME}" == 'issue_comment' ]]; then
DO_ISSUE=false
DO_REVIEW=false
BODY_TRIMMED="$(printf '%s' "${COMMENT_BODY}" | tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
CMD=''
[[ "${BODY_TRIMMED}" == "${TAKEOVER_COMMAND}" ]] && CMD='add'
[[ "${BODY_TRIMMED}" == "${TAKEOVER_COMMAND} stop" ]] && CMD='remove'
# Re-arm shares the takeover command's authorization exactly:
# both summon bot activity on a managed PR, so inventing a
# second policy would only add surface.
RETRY_REQ=''
[[ "${BODY_TRIMMED}" == "${RETRY_COMMAND}" ]] && RETRY_REQ='true'
if [[ -z "${HAS_PR_URL}" ]]; then
echo "🧭 command ignored: not a PR comment"
elif [[ -z "${CMD}" && -z "${RETRY_REQ}" ]]; then
echo "🧭 command ignored: body is not an exact command"
elif [[ "${ISSUE_STATE}" != 'open' ]]; then
echo "🧭 command ignored: PR is not open"
elif [[ -z "${SENDER_LOGIN}" || "${SENDER_LOGIN}" == "${AUTOFIX_BOT}" ]]; then
echo "🧭 command ignored: sender '${SENDER_LOGIN:-n/a}'"
else
sender_is_authorized=false
sender_permission=''
# Author privilege applies to IN-REPO PRs only: on a fork
# PR the author is an arbitrary external account, and
# accepting them here would let them drive PAT-authored
# writes (even just refusal comments) onto their own PR at
# will. Fork authors without write+ are dropped silently.
CMD_HEAD_REPO="$(gh api "repos/${REPO}/pulls/${ISSUE_NUMBER}" --jq '.head.repo.full_name // ""' 2> /dev/null || echo '')"
if [[ "${SENDER_LOGIN}" == "${COMMENT_PR_AUTHOR}" && "${CMD_HEAD_REPO}" == "${REPO}" ]]; then
# Author privilege is LIVE, not durable: an author removed
# from the repo keeps their PR/head-repo match forever, so
# authorship alone must not keep summoning secret-bearing
# runs. Authors qualify at triage+ (the sugar exists for
# members below write who cannot apply labels).
api_error_file="$(mktemp)"
if sender_permission="$(gh api "repos/${REPO}/collaborators/${SENDER_LOGIN}/permission" --jq '.permission // ""' 2>"${api_error_file}")"; then
case "${sender_permission}" in
admin|maintain|write|triage) sender_is_authorized=true; sender_permission="pr-author/${sender_permission}" ;;
esac
else
api_error="$(tr '\r\n' ' ' < "${api_error_file}")"
echo "::warning::Permission API call failed for author ${SENDER_LOGIN}: ${api_error:-unknown error}"
sender_permission=''
fi
rm -f "${api_error_file}"
else
api_error_file="$(mktemp)"
if sender_permission="$(gh api "repos/${REPO}/collaborators/${SENDER_LOGIN}/permission" --jq '.permission // ""' 2>"${api_error_file}")"; then
case "${sender_permission}" in
admin|maintain|write) sender_is_authorized=true ;;
esac
else
api_error="$(tr '\r\n' ' ' < "${api_error_file}")"
echo "::warning::Permission API call failed for ${SENDER_LOGIN}: ${api_error:-unknown error}"
sender_permission=''
fi
rm -f "${api_error_file}"
fi
if [[ "${sender_is_authorized}" == 'true' ]]; then
if [[ -n "${RETRY_REQ}" ]]; then
RETRY_PR="$(sanitize_number "${ISSUE_NUMBER}")"
echo "🧭 retry command accepted: re-arm PR #${ISSUE_NUMBER} by ${SENDER_LOGIN} (${sender_permission})"
else
TAKEOVER_CMD="${CMD}"
CMD_PR="$(sanitize_number "${ISSUE_NUMBER}")"
echo "🧭 takeover command accepted: ${CMD} ${TAKEOVER_LABEL} on PR #${ISSUE_NUMBER} by ${SENDER_LOGIN} (${sender_permission})"
fi
else
echo "🧭 command ignored: sender '${SENDER_LOGIN}' permission='${sender_permission:-none}' is not the PR author or write+"
fi
fi
fi
# Label-driven takeover: TAKEOVER_LABEL applied to an eligible
# PR summons the loop onto it (human-authored included);
# removing it releases the PR — future engagement stops, and an
# in-flight round, if any, completes its bounded work (matrix
# runs are shared across PRs, so cancelling one is not possible
# without collateral damage). ISSUE_LABEL carries the label
# name for pull_request events too (same payload field).
if [[ "${EVENT_NAME}" == 'pull_request' ]]; then
DO_ISSUE=false
if [[ "${ISSUE_LABEL}" != "${TAKEOVER_LABEL}" ]]; then
echo "🧭 pull_request ${EVENT_ACTION} ignored: label '${ISSUE_LABEL:-n/a}' is not ${TAKEOVER_LABEL}"
elif [[ "${EVENT_ACTION}" == 'labeled' ]]; then
if [[ "${PR_HEAD_REPO}" != "${REPO}" ]]; then
# Fork pull_request events carry NO secrets, so neither
# the immediate scan nor the ack job can run from this
# event. The label still counts: the next scheduled scan
# (repo context, ≤10m) admits fork takeover PRs whose
# author holds write+ and whose PR allows maintainer
# edits, and posts the engage ack on first pickup.
echo "🧭 fork takeover noted for #${PR_NUMBER_EVENT} — the next scheduled scan engages (author write+ and allow-edits required)"
elif [[ "${PR_STATE}" != 'open' ]]; then
echo "🧭 takeover ignored: PR state '${PR_STATE:-unknown}' is not open"
elif [[ "${PR_BASE_REF}" != 'main' ]]; then
# Refuse OUT LOUD. Staying silent here made a labelled
# stacked PR indistinguishable from a managed one: the
# label stuck, the route run went green, and the only
# trace was this log line — so the PR sat unmanaged for
# hours with nobody able to tell without reading the job
# log. The ack job posts the explanation instead.
TAKEOVER_ACK='base-refused'
ACK_BASE="${PR_BASE_REF}"
echo "🧭 takeover ignored: PR targets '${PR_BASE_REF}' not 'main'"
else
DO_REVIEW=true
ROUTE_PR="$(sanitize_number "${PR_NUMBER_EVENT}")"
if [[ "${SENDER_LOGIN}" == "${AUTOFIX_BOT}" ]]; then
# The bot only applies this label from takeover-command,
# which posts the engage ack ITSELF: the labeled event
# has been observed to simply not fire (#7999 — the
# author read the silence as failure and removed the
# label; #8002), so the user-visible ack must not
# depend on this round-trip. Suppress only the ack —
# the immediate scan is this event's real work and
# still routes.
echo "🧭 engage ack skipped: label applied by ${AUTOFIX_BOT} — the command path already acked"
else
TAKEOVER_ACK='engaged'
fi
echo "🧭 ${TAKEOVER_LABEL} applied by ${SENDER_LOGIN} on PR #${PR_NUMBER_EVENT} → review phase (takeover)"
fi
elif [[ "${EVENT_ACTION}" == 'unlabeled' ]]; then
# Mirror the labeled-path guards: a fork, closed, or
# non-main PR was never engaged, so a release ack would
# announce a disengagement that never existed.
if [[ "${PR_STATE}" != 'open' || "${PR_BASE_REF}" != 'main' ]]; then
echo "🧭 takeover release ignored: PR state '${PR_STATE:-unknown}' base '${PR_BASE_REF:-unknown}' was never engaged"
elif [[ "${PR_HEAD_REPO}" != "${REPO}" ]]; then
# Fork pull_request events carry no secrets: emitting the
# ack here would start takeover-ack with an empty PAT and
# fail its identity check — a red run for a label that
# never engaged anything. Log and stop.
echo "🧭 takeover release ignored: PR is a fork (${PR_HEAD_REPO} != ${REPO})"
elif [[ "${SENDER_LOGIN}" == "${AUTOFIX_BOT}" ]]; then
# Mirror of the labeled-path suppression: the bot only
# removes this label from takeover-command, which posts
# the release ack itself — acking here too would
# double-post on every command-driven stop.
echo "🧭 release ack skipped: label removed by ${AUTOFIX_BOT} — the command path already acked"
else
TAKEOVER_ACK='released'
echo "🧭 ${TAKEOVER_LABEL} removed from PR #${PR_NUMBER_EVENT} by ${SENDER_LOGIN} → released"
fi
fi
fi
if [[ "${EVENT_NAME}" == 'issues' ]]; then
DO_REVIEW=false
label_is_trigger=false
[[ "${ISSUE_LABEL}" == "${READY_FOR_AGENT_LABEL}" || "${ISSUE_LABEL}" == "${BUG_LABEL}" || "${ISSUE_LABEL}" == "${AUTOFIX_APPROVED_LABEL}" ]] && label_is_trigger=true
[[ "${ASSIGNEE_LOGIN}" == "${AUTOFIX_BOT}" ]] && label_is_trigger=true
sender_permission=''
sender_is_trusted=false
if [[ -n "${SENDER_LOGIN}" ]]; then
if ! sender_permission="$(gh api "repos/${REPO}/collaborators/${SENDER_LOGIN}/permission" --jq '.permission // ""' 2>&1)"; then
api_error="${sender_permission}"
sender_permission=''
api_error="${api_error//$'\r'/ }"
api_error="${api_error//$'\n'/ }"
echo "::warning::Permission API call failed for ${SENDER_LOGIN}: ${api_error}"
fi
[[ "${sender_permission}" == 'write' || "${sender_permission}" == 'maintain' || "${sender_permission}" == 'admin' ]] && sender_is_trusted=true
fi
if [[ "${label_is_trigger}" != 'true' ]]; then
# A non-trigger label (e.g. scope/*, priority/*) may arrive
# after the trigger labels and cancel their runs via per-issue
# concurrency. If the issue already carries both required
# labels and the current sender is trusted, proceed anyway.
_late_ready="$(jq -r --arg l "${READY_FOR_AGENT_LABEL}" 'index($l) != null' <<< "${ISSUE_LABELS_JSON:-[]}")"
_late_approved="$(jq -r --arg l "${AUTOFIX_APPROVED_LABEL}" 'index($l) != null' <<< "${ISSUE_LABELS_JSON:-[]}")"
if [[ "${ISSUE_STATE}" == 'open' && "${_late_ready}" == 'true' && "${_late_approved}" == 'true' && "${sender_is_trusted}" == 'true' ]]; then
echo "🧭 non-trigger label '${ISSUE_LABEL:-n/a}' but issue #${ISSUE_NUMBER} already approved+ready → issue phase"
DO_ISSUE=true
else
echo "🧭 issue event ignored: trigger_label=false label='${ISSUE_LABEL:-n/a}' issue='#${ISSUE_NUMBER:-n/a}'"
fi
else
issue_is_bug="$(jq -r --arg label "${BUG_LABEL}" 'index($label) != null' <<< "${ISSUE_LABELS_JSON:-[]}")"
issue_is_ready="$(jq -r --arg label "${READY_FOR_AGENT_LABEL}" 'index($label) != null' <<< "${ISSUE_LABELS_JSON:-[]}")"
issue_is_approved="$(jq -r --arg label "${AUTOFIX_APPROVED_LABEL}" 'index($label) != null' <<< "${ISSUE_LABELS_JSON:-[]}")"
if [[ "${ISSUE_STATE}" == 'open' && "${issue_is_ready}" == 'true' && "${issue_is_approved}" == 'true' && "${label_is_trigger}" == 'true' && "${sender_is_trusted}" == 'true' ]]; then
DO_ISSUE=true
else
if [[ "${ISSUE_STATE}" == 'open' && "${label_is_trigger}" == 'true' && "${sender_is_trusted}" == 'true' && "${issue_is_ready}" != "${issue_is_approved}" ]]; then
echo "::notice::Issue #${ISSUE_NUMBER:-n/a} needs both ${READY_FOR_AGENT_LABEL} and ${AUTOFIX_APPROVED_LABEL} before autofix can run."
fi
echo "🧭 issue event ignored: state_open=$([[ "${ISSUE_STATE}" == 'open' ]] && echo true || echo false) bug=${issue_is_bug} ready=${issue_is_ready} approved=${issue_is_approved} trigger_label=${label_is_trigger} sender_permission='${sender_permission:-none}' sender_trusted=${sender_is_trusted} label='${ISSUE_LABEL:-n/a}' issue='#${ISSUE_NUMBER:-n/a}'"
fi
fi
fi
;;
esac
# Forcing a specific issue/PR implies running that phase only for
# explicit manual dispatch. Event payload numbers still flow to the
# phase jobs after routing, but must not bypass the label/schedule gates.
# Explicit phases (issue/review/both) take precedence over forced
# issue/PR overrides — only apply forced routing in auto/default mode.
if [[ "${EVENT_NAME}" == 'workflow_dispatch' && ( -z "${PHASE}" || "${PHASE}" == 'auto' ) ]]; then
[[ -n "${ROUTE_ISSUE}" && -z "${ROUTE_PR}" ]] && DO_ISSUE=true && DO_REVIEW=false
[[ -n "${ROUTE_PR}" && -z "${ROUTE_ISSUE}" ]] && DO_ISSUE=false && DO_REVIEW=true
[[ -n "${ROUTE_ISSUE}" && -n "${ROUTE_PR}" ]] && DO_ISSUE=true && DO_REVIEW=true
fi
echo "do_issue=${DO_ISSUE}" >> "${GITHUB_OUTPUT}"
echo "do_review=${DO_REVIEW}" >> "${GITHUB_OUTPUT}"
echo "dry_run=${DRY_RUN}" >> "${GITHUB_OUTPUT}"
echo "issue_number=${ROUTE_ISSUE}" >> "${GITHUB_OUTPUT}"
echo "pr_number=${ROUTE_PR}" >> "${GITHUB_OUTPUT}"
echo "takeover_ack=${TAKEOVER_ACK}" >> "${GITHUB_OUTPUT}"
echo "ack_pr=$(sanitize_number "${PR_NUMBER_EVENT}")" >> "${GITHUB_OUTPUT}"
echo "ack_base=${ACK_BASE}" >> "${GITHUB_OUTPUT}"
echo "takeover_cmd=${TAKEOVER_CMD}" >> "${GITHUB_OUTPUT}"
echo "retry_pr=${RETRY_PR}" >> "${GITHUB_OUTPUT}"
echo "cmd_pr=${CMD_PR}" >> "${GITHUB_OUTPUT}"
echo "🧭 phase='${PHASE:-auto}' event='${EVENT_NAME}' issue='#${ISSUE_NUMBER:-n/a}' pr='#${PR_NUMBER_EVENT:-n/a}' schedule='${SCHEDULE:-n/a}' dry_run=${DRY_RUN} → issue=${DO_ISSUE} review=${DO_REVIEW}"
# ===========================================================================
# ISSUE PHASE — locate one maintainer-ready issue, fix it, open a PR.
# ===========================================================================
issue-autofix:
needs: ['route', 'review-scan']
if: |-
${{
always() &&
needs.route.outputs.do_issue == 'true' &&
(github.event_name != 'schedule' || (needs.review-scan.result == 'success' && needs.review-scan.outputs.has_targets != 'true'))
}}
# Secret-bearing and executes agent-driven code, but the agent runs inside
# the docker sandbox image and only ever writes a new branch as the
# dev-bot — it never executes a foreign author's code. Forks of this repo
# (and MAINTAINER_ECS_RUNNER_DISABLED) fall back to hosted. On
# pull_request / pull_request_review events the ECS route additionally
# needs a same-repo head or a write+ author (ci.yml's pick_runner form);
# the other triggers skip that clause and rely on their own gates
# instead: issues / schedule require autofix/approved plus
# status/ready-for-agent on the issue, and workflow_dispatch rides the
# actor's own write access. Docker availability on this pool is proven
# in-repo by qwen-triage's container jobs, which run on the same
# runner labels.
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event_name != ''pull_request'' && github.event_name != ''pull_request_review'' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON(''["OWNER","MEMBER","COLLABORATOR"]''), github.event.pull_request.author_association))) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}'
timeout-minutes: 180
# route.issue_number is only set for forced dispatches; label events carry
# the issue in the payload, and scan-and-pick runs (cron, unforced
# dispatch) share one 'scheduled' group. The old github.run_id fallback
# made every scan-and-pick run its own group, so two overlapping scans
# (cron fires every 40-70min, this job runs up to 180) could double-claim
# the same issue — the claim recheck runs after assess and only narrows
# the race to the short gap between the recheck and the claim's label
# write; it does not close it. Queued (never cancelled) so the newest
# pending tick still runs after a long scan if targets remain;
# intermediate ticks are superseded, which is fine because each run
# rescans from scratch.
#
# GitHub evaluates concurrency before the job `if`, but after `needs`, so
# the group is gated on the same runnability predicate as the `if` above,
# plus a dry-run exclusion: runs whose issue phase will not execute
# (do_issue=false takeover, review, and command events; label events
# failing the decide gates; scheduled ticks whose review-scan still has
# targets) and dry runs (if-runnable, but their Claim/Publish steps are
# gated off) get a run-unique group instead — a run that never claims
# entering a target-keyed group would replace the single pending run
# there and silently cancel it. Same precedent as qwen-triage.yml's
# triage/tmux jobs.
concurrency:
group: >-
${{ needs.route.outputs.do_issue == 'true' && needs.route.outputs.dry_run != 'true' && (github.event_name != 'schedule' || (needs.review-scan.result == 'success' && needs.review-scan.outputs.has_targets != 'true')) && format('qwen-autofix-issue-{0}', needs.route.outputs.issue_number || github.event.issue.number || 'scheduled') || format('qwen-autofix-issue-run-{0}', github.run_id) }}
cancel-in-progress: false
permissions:
contents: 'read'
env:
REPO: '${{ github.repository }}'
# Per-run private dir: this pool carries many registrations sharing one
# OS /tmp, and issue-phase runs never serialize against each other, so
# a fixed path let concurrent runs clobber each other's decision files.
WORKDIR: '/tmp/autofix-${{ github.run_id }}'
EVENT_NAME: '${{ github.event_name }}'
READY_FOR_AGENT_LABEL: 'status/ready-for-agent'
AUTOFIX_APPROVED_LABEL: 'autofix/approved'
AUTOFIX_ISSUE_EXCLUDES: 'no:assignee -linked:pr -label:autofix/skip -label:autofix/in-progress -label:status/need-information -label:status/need-retesting sort:created-desc'
steps:
# Self-hosted runners reuse the workspace; a prior containerised job
# can leave root-owned, read-only files anywhere in it. Restore
# ownership and write permission unconditionally before checkout.
- name: 'Restore workspace ownership'
run: |-
set -uo pipefail
RUNNER_UID="$(id -u)"
RUNNER_GID="$(id -g)"
if [ "$RUNNER_UID" != "0" ]; then
chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files"
fi
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files"
# Self-hosted runners keep the workspace between runs, and other pool
# jobs execute human-authored code as the runner user, so a prior job
# can plant git exec knobs (core.fsmonitor, filter.*.smudge,
# diff.external, includeIf, hooks) in the local config that would fire
# inside THIS job's PAT-bearing git steps. It keeps
# a known-safe allowlist and unsets everything else, hardened against
# the worktree-config and global-hooksPath bypasses verified in
# qwen-triage on this pool. No-op on a fresh hosted runner.
- name: 'Sanitize workspace git config'
run: |-
set -uo pipefail
# `.git` is a directory in a normal checkout but a gitlink file in
# a worktree; -e covers both, and a missing .git (first run) too.
if [ ! -e .git ]; then
echo "no prior workspace; nothing to sanitize"
exit 0
fi
# Worktree-scoped config FIRST: `extensions.worktreeConfig=true` is
# on the allowlist below (it carries no command itself), but it
# activates `.git/config.worktree` — a second config file that
# `git config --local` neither lists nor unsets, and that CAN carry
# core.hooksPath. Verified in qwen-triage: a prior run can set
# `--worktree core.hooksPath=/`, survive the sweep untouched, and
# make the hooks deletion below walk /. Delete the file outright,
# then drop the extension.
rm -f "$(git rev-parse --git-path config.worktree 2>/dev/null || echo /nonexistent)" 2>/dev/null || true
git config --local --unset-all extensions.worktreeConfig 2>/dev/null || true
# Rather than denylist each exec-vector family (which kept missing
# new ones), KEEP a known-safe allowlist and --unset-all everything
# else: this closes the whole class, including knobs not yet
# enumerated. The kept set is only plumbing that carries no command
# — repo format, remote, branch, fetch/gc/pack/index, safe.directory,
# extensions, and submodule url/active/branch (NOT
# submodule.*.update, which can be `!cmd`). actions/checkout
# re-establishes remote/auth afterward. `|| true` on the grep: no
# non-allowlisted keys (the steady state on an already-sanitized
# runner) means grep exits 1, which would kill the step exactly
# when there is nothing to clean.
git config --local --name-only --list 2>/dev/null \
| { grep -ivE '^(core\.(repositoryformatversion|bare|filemode|symlinks|ignorecase|precomposeunicode|logallrefupdates|worktree|hidedotfiles|protecthfs|protectntfs)|remote\.[^.]+\.(url|fetch|pushurl)|branch\.|extensions\.|gc\.|pack\.|fetch\.|index\.|safe\.|submodule\.[^.]+\.(url|active|branch))' || true; } \
| while IFS= read -r key; do git config --local --unset-all "$key" 2>/dev/null || true; done
# Belt and braces after the config scrub: only delete inside the
# repository's own git dir. A hooks path resolving anywhere else is
# unlinked, never swept — a recursive delete of a planted path is
# far worse than a stale hook on a runner the pool re-cleans.
# Resolve hooks with global/system config OUT of the way. Verified
# in qwen-triage: with a global core.hooksPath set, `git rev-parse
# --git-path hooks` returns that path, the guard below sees
# "outside the git dir", and a planted `.git/hooks` symlink
# survives untouched. Keep this resolution AFTER the sweep above.
GIT_DIR_ABS="$(git rev-parse --absolute-git-dir 2>/dev/null || echo '')"
HOOKS_DIR="$(GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git rev-parse --git-path hooks 2>/dev/null || echo .git/hooks)"
HOOKS_ABS="$(cd "$HOOKS_DIR" 2>/dev/null && pwd -P || echo '')"
if [ -n "$GIT_DIR_ABS" ] && [ -n "$HOOKS_ABS" ] && [ "${HOOKS_ABS#"$GIT_DIR_ABS"/}" != "$HOOKS_ABS" ]; then
# Match -type f OR -type l: a symlinked hook survives a bare
# `-type f` sweep and still fires on the next checkout.
find "$HOOKS_ABS" \( -type f -o -type l \) ! -name '*.sample' -delete 2>/dev/null || true
else
# Resolves outside the git dir (or not at all). Warning and
# walking away would leave a live hook directory that the next
# git command executes, so unlink the ENTRY without descending
# into it and put an empty hooks directory back.
RAW_HOOKS="$(GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git rev-parse --git-path hooks 2>/dev/null || echo .git/hooks)"
echo "::warning::hooks path did not resolve inside the git dir (${HOOKS_ABS:-unresolved}); unlinking it."
rm -f "$RAW_HOOKS" 2>/dev/null || echo "::warning::refusing to recursively delete planted hooks path '$RAW_HOOKS' (a hooksPath resolving to the git dir itself would otherwise wipe .git); leaving it to the pool re-clean."
mkdir -p "${GIT_DIR_ABS:-.git}/hooks" 2>/dev/null || true
git config --local --unset-all core.hooksPath 2>/dev/null || true
fi
- name: 'Checkout'
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
fetch-depth: 0
persist-credentials: false
- name: 'Reset autofix workspace'
run: |-
rm -rf "${WORKDIR}"
# 0700: the dir holds agent transcripts and decision files; the
# sandbox container runs as this same user, so the tighter mode
# costs the job nothing. umask at creation, not mkdir-then-chmod —
# the chmod form leaves a world-readable window on this shared /tmp.
(umask 077; mkdir -p "${WORKDIR}")
# Age-sweep abandoned run-scoped dirs on this shared /tmp: a hard
# runner kill skips the always() teardown and run_id never repeats,
# so nothing else ever reclaims them.
find /tmp -maxdepth 1 -name 'autofix*' -mmin +1440 -exec rm -rf {} + 2>/dev/null || true
# The reused workspace's .git accumulates unreferenced objects
# across fetch runs on this persistent pool; prune them.
git -c gc.autoDetach=false gc --auto --prune=now --quiet 2>/dev/null || true
# Self-hosted runners keep the workspace's .git across runs, so a
# failed earlier attempt's local branch survives here: the agent's
# branch create then dies "branch already exists", or an adaptation
# checks out the stale line and pushes the failed attempt's commits
# into the new PR. Drop them deterministically (refs survive
# actions/checkout's untracked-file clean).
- name: 'Drop stale autofix branches'
run: |-
# Detach first: `git branch -D` refuses the currently checked-out
# branch, so a stale autofix branch holding HEAD would otherwise
# silently survive the sweep (actions/checkout normally leaves
# HEAD on the default branch; this makes the sweep unconditional).
git checkout --detach 2>/dev/null || true
git for-each-ref --format='%(refname:short)' "refs/heads/${BRANCH_PREFIX}*" \
| xargs -r -n 1 git branch -D 2>/dev/null || true
# Same staging as the review-address job: the verify gate always runs the
# trusted checkout's copy of the schema gate, never a working-tree copy.
- name: 'Stage trusted schema gate'
run: |-
cp .github/scripts/check-settings-schema.sh "${RUNNER_TEMP}/check-settings-schema.sh"
cp .github/scripts/check-autofix-contracts.sh "${RUNNER_TEMP}/check-autofix-contracts.sh"
cp .github/scripts/resolve-owning-packages.sh "${RUNNER_TEMP}/resolve-owning-packages.sh"
- name: 'Check bot credentials'
env:
GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}'
run: |-
if [[ -z "${GITHUB_TOKEN}" ]]; then
echo '::error::CI_DEV_BOT_PAT is required to run the issue autofix job.'
exit 1
fi
api_error_file="$(mktemp)"
if ! bot_actor="$(GH_TOKEN="${GITHUB_TOKEN}" gh api user --jq '.login' 2>"${api_error_file}")"; then
api_error="$(tr '\r\n' ' ' < "${api_error_file}")"
rm -f "${api_error_file}"
echo "::error::Failed to verify CI_DEV_BOT_PAT identity with gh api user: ${api_error:-unknown error}."
exit 1
fi
rm -f "${api_error_file}"
echo "CI_DEV_BOT_PAT authenticates as ${bot_actor}"
if [[ "${bot_actor}" != "${AUTOFIX_BOT}" ]]; then
echo "::error::CI_DEV_BOT_PAT authenticates as ${bot_actor}; expected ${AUTOFIX_BOT}."
exit 1
fi
- name: 'Check runner environment'
env:
RUNNER_ENVIRONMENT: '${{ runner.environment }}'
RUNNER_NAME: '${{ runner.name }}'
run: |-
case "${RUNNER_ENVIRONMENT}" in
github-hosted|self-hosted) ;;
*)
echo "::error::Unsupported runner environment: ${RUNNER_ENVIRONMENT:-unset}."
exit 1
;;
esac
# The label routing pins ecs-qwen, but a mis-labelled registration
# must not silently claim a PAT-bearing 300-minute job — assert the
# pool by name on the self-hosted branch too.
if [[ "${RUNNER_ENVIRONMENT}" == 'self-hosted' ]]; then
case "${RUNNER_NAME}" in
ecs-qwen-*) ;;
*)
echo "::error::self-hosted runner '${RUNNER_NAME}' is not an ecs-qwen pool member; refusing to run here."
exit 1
;;
esac
fi
# Capability preflight for the persistent pool: this job's agent
# runs inside the docker sandbox, and a missing daemon otherwise
# surfaces only at 'Resolve sandbox image' — after npm ci/build
# has already burned tens of minutes. Fail in seconds instead.
# Hosted runners ship docker; the ECS pool's docker is proven by
# qwen-triage's container jobs on the same labels.
if ! docker info > /dev/null 2>&1; then
echo "::error::docker daemon is not reachable on this runner; the sandboxed agent cannot start."
exit 1
fi
- name: 'Set up Node.js'
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22.x'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
- name: 'Install tmux'
run: |-
if command -v tmux > /dev/null 2>&1; then
tmux -V
elif command -v sudo > /dev/null 2>&1 && command -v apt-get > /dev/null 2>&1; then
# sudo -n: a host without passwordless sudo must fail fast with a
# clear message, not die on a password prompt (the pr-review pool
# steps make the same assumption with `sudo -n ... || ::warning`).
sudo -n apt-get update -qq && sudo -n apt-get install -y -qq tmux || {
echo '::error::tmux is required on the autofix runner and passwordless install failed.'
exit 1
}
else
echo '::error::tmux is required on the autofix runner.'
exit 1
fi
# The npm-ci retry recipe and the 'Prepare Qwen Code CLI' shim below
# are duplicated in build-cli and review-address; the workflow
# contract tests pin every copy in lockstep — edit them together.
- name: 'Install dependencies and build'
env:
QWEN_SKIP_PREPARE: '1'
run: |-
for attempt in 1 2 3; do
if npm ci --prefer-offline --no-audit --progress=false; then
break
fi
if [[ "${attempt}" == "3" ]]; then
exit 1
fi
sleep $((attempt * 15))
done
git config core.hooksPath .husky
npm run build
npm run bundle
- name: 'Prepare Qwen Code CLI'
run: |-
qwen_version="$(node -p "require('./package.json').version")"
echo "Using checked-out Qwen Code bundle ${qwen_version}"
qwen_bin="${RUNNER_TEMP}/qwen-bin"
mkdir -p "${qwen_bin}"
cat > "${qwen_bin}/qwen" <<'EOF'
#!/usr/bin/env bash
exec node "${GITHUB_WORKSPACE}/dist/cli.js" "$@"
EOF
chmod +x "${qwen_bin}/qwen"
echo "${qwen_bin}" >> "${GITHUB_PATH}"
PATH="${qwen_bin}:${PATH}"
qwen --version
- name: 'Find candidate issues'
id: 'scan'
env:
GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}'
# Must resolve to the same issue as this job's concurrency group
# expression; a test pins the two equal.
FORCED_ISSUE: '${{ needs.route.outputs.issue_number || github.event.issue.number }}'
run: |-
mkdir -p "${WORKDIR}"
OPEN_AUTOFIX_PR_COUNT=0
if [[ -n "${FORCED_ISSUE}" ]]; then
echo "🎯 Forced issue #${FORCED_ISSUE}"
forced_issue_json="${WORKDIR}/forced-issue.json"
gh issue view "${FORCED_ISSUE}" --repo "${REPO}" \
--json number,title,body,labels,createdAt,url,state \
> "${forced_issue_json}"
if jq -e \
'(.labels // []) | map(.name) | any(. == "autofix/skip" or . == "autofix/in-progress")' \
"${forced_issue_json}" > /dev/null; then
echo "⏭️ Forced issue #${FORCED_ISSUE} has an autofix exclusion label; skipping."
jq -n -c '[]' > "${WORKDIR}/candidates.json"
elif [[ "$(jq -r '.state // ""' "${forced_issue_json}")" != 'OPEN' ]]; then
echo "⏭️ Forced issue #${FORCED_ISSUE} is not open; skipping."
jq -n -c '[]' > "${WORKDIR}/candidates.json"
# workflow_dispatch is a maintainer-initiated escape hatch, so it
# intentionally bypasses the label gates that protect event/cron
# paths from issue-content prompt injection.
elif [[ "${EVENT_NAME}" != 'workflow_dispatch' ]] && ! jq -e --arg ready "${READY_FOR_AGENT_LABEL}" \
'(.labels // []) | map(.name) as $labels | ($labels | index($ready))' \
"${forced_issue_json}" > /dev/null; then
echo "⏭️ Forced issue #${FORCED_ISSUE} is missing ${READY_FOR_AGENT_LABEL}; skipping."
jq -n -c '[]' > "${WORKDIR}/candidates.json"
elif [[ "${EVENT_NAME}" != 'workflow_dispatch' ]] && ! jq -e --arg approved "${AUTOFIX_APPROVED_LABEL}" \
'(.labels // []) | map(.name) as $labels | ($labels | index($approved))' \
"${forced_issue_json}" > /dev/null; then
echo "⏭️ Forced issue #${FORCED_ISSUE} is missing ${AUTOFIX_APPROVED_LABEL}; skipping."
jq -n -c '[]' > "${WORKDIR}/candidates.json"
else
if ! jq -c '[. + {autofixTier: 0}]' "${forced_issue_json}" > "${WORKDIR}/candidates.json"; then
echo "::warning::Forced issue #${FORCED_ISSUE} processing failed; falling back to an empty candidate list."
jq -n -c '[]' > "${WORKDIR}/candidates.json"
fi
fi
else
if ! gh pr list --repo "${REPO}" --state open --author "${AUTOFIX_BOT}" \
--limit 100 --json number,headRefName,isCrossRepository > "${WORKDIR}/open-autofix-prs.json"; then
echo "::warning::Open autofix PR scan failed; proceeding without WIP-cap enforcement."
else
OPEN_AUTOFIX_PR_COUNT="$(jq --arg p "${BRANCH_PREFIX}" \
'[.[] | select((.isCrossRepository != true) and ((.headRefName // "") | startswith($p)))] | length' \
"${WORKDIR}/open-autofix-prs.json")"
fi
if [[ "${OPEN_AUTOFIX_PR_COUNT}" -ge "${MAX_OPEN_AUTOFIX_PRS}" ]]; then
echo "⏭️ ${OPEN_AUTOFIX_PR_COUNT} open autofix PR(s) already exist; WIP limit is ${MAX_OPEN_AUTOFIX_PRS}; skipping issue fallback."
jq -n -c '[]' > "${WORKDIR}/candidates.json"
else
echo "🔍 Ready-for-agent issues (newest first)..."
if ! gh issue list --repo "${REPO}" \
--search "is:open is:issue label:${READY_FOR_AGENT_LABEL} label:${AUTOFIX_APPROVED_LABEL} ${AUTOFIX_ISSUE_EXCLUDES}" \
--limit 30 --json number,title,body,labels,createdAt,url \
> "${WORKDIR}/scan.json"; then
echo "::warning::Ready-for-agent issue scan failed; falling back to an empty candidate list."
jq -n -c '[]' > "${WORKDIR}/candidates.json"
else
if ! jq -c '.[0:10] | map(. + {autofixTier: 1})' \
"${WORKDIR}/scan.json" > "${WORKDIR}/candidates.json"; then
echo "::warning::Ready-for-agent result processing failed; falling back to an empty candidate list."
jq -n -c '[]' > "${WORKDIR}/candidates.json"
fi
fi
fi
fi
COUNT="$(jq length "${WORKDIR}/candidates.json")"
if [[ "${COUNT}" -gt 0 ]]; then
if [[ -s "${WORKDIR}/open-autofix-prs.json" ]]; then
echo "ℹ️ Reusing open autofix PR scan for duplicate-PR annotation."
elif ! gh pr list --repo "${REPO}" --state open --author "${AUTOFIX_BOT}" \
--limit 100 --json number,headRefName,isCrossRepository > "${WORKDIR}/open-autofix-prs.json"; then
echo "::warning::Open autofix PR scan failed; candidates will proceed without duplicate-PR annotation."
fi
if [[ -s "${WORKDIR}/open-autofix-prs.json" ]]; then
if ! jq -c --arg p "${BRANCH_PREFIX}" --slurpfile prs "${WORKDIR}/open-autofix-prs.json" '
($prs[0] // []) as $prs
| map(
($p + (.number | tostring)) as $branch
| (
first($prs[] | select((.isCrossRepository != true) and ((.headRefName // "") == $branch)) | {
number,
headRefName
}) // null
) as $existing
| . + {existingAutofixPr: $existing}
)
' "${WORKDIR}/candidates.json" > "${WORKDIR}/annotated-candidates.json"; then
echo "::warning::Open autofix PR annotation failed; candidates will proceed without duplicate-PR annotation."
else
mv "${WORKDIR}/annotated-candidates.json" "${WORKDIR}/candidates.json"
CANDIDATES_WITH_PRS="$(jq '[.[] | select(.existingAutofixPr != null)] | length' "${WORKDIR}/candidates.json")"
if [[ "${CANDIDATES_WITH_PRS}" -gt 0 ]]; then
echo "ℹ️ ${CANDIDATES_WITH_PRS} candidate(s) already have open autofix PRs; the skill must skip them."
fi
fi
fi
fi
COUNT="$(jq length "${WORKDIR}/candidates.json")"
echo "📋 ${COUNT} candidate(s) found"
if [[ "${COUNT}" -gt 0 ]]; then
OLDEST_CREATED="$(jq -r 'map(.createdAt) | min' "${WORKDIR}/candidates.json")"
NEWEST_CREATED="$(jq -r 'map(.createdAt) | max' "${WORKDIR}/candidates.json")"
echo "🕒 Candidate createdAt range: ${OLDEST_CREATED} .. ${NEWEST_CREATED}"
fi
echo "has_candidates=$([[ "${COUNT}" -gt 0 ]] && echo true || echo false)" >> "${GITHUB_OUTPUT}"
- name: 'Resolve sandbox image'
if: |-
${{ steps.scan.outputs.has_candidates == 'true' }}
run: |-
node .github/scripts/resolve-sandbox-image.mjs \
"$(node -p "require('./package.json').config.sandboxImageUri")"
- name: 'Fast-track decision'
id: 'fasttrack'
if: |-
${{ steps.scan.outputs.has_candidates == 'true' }}
env:
EVENT_NAME: '${{ github.event_name }}'
FORCED_ISSUE: '${{ inputs.issue_number }}'
run: |-
FAST_TRACK=false
if [[ "${EVENT_NAME}" == 'workflow_dispatch' && -n "${FORCED_ISSUE}" ]]; then
FAST_TRACK=true
fi
if [[ "${EVENT_NAME}" == 'issues' ]]; then
FAST_TRACK=true
fi
if [[ "${FAST_TRACK}" == 'true' ]]; then
ISSUE_NUM="$(jq -r '.[0].number' "${WORKDIR}/candidates.json")"
jq -n -c --argjson num "${ISSUE_NUM}" \
'{go: $num, reason: "Fast-tracked: trusted trigger bypasses LLM assessment.", skip: []}' \
> "${WORKDIR}/decision.json"
echo "⚡ Fast-track decision: issue #${ISSUE_NUM}"
echo 'fast_tracked=true' >> "${GITHUB_OUTPUT}"
else
echo 'fast_tracked=false' >> "${GITHUB_OUTPUT}"
fi
- name: 'Assess candidates'
id: 'assess'
if: |-
${{ steps.scan.outputs.has_candidates == 'true' && steps.fasttrack.outputs.fast_tracked != 'true' }}
env:
GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}'
OPENAI_API_KEY: '${{ secrets.AUTOFIX_OPENAI_API_KEY }}'
OPENAI_BASE_URL: '${{ secrets.AUTOFIX_OPENAI_BASE_URL || secrets.OPENAI_BASE_URL }}'
OPENAI_MODEL: '${{ vars.QWEN_AUTOFIX_MODEL || vars.QWEN_PR_REVIEW_MODEL }}'
NO_PROXY: '127.0.0.1,localhost,::1'
QWEN_HOME: '${{ runner.temp }}/qwen-autofix-home'
SETTINGS_JSON: |-
{
"maxSessionTurns": 60,
"coreTools": [
"read_file",
"read_many_files",
"glob",
"search_file_content",
"write_file",
"run_shell_command(cat)",
"run_shell_command(git log)",
"run_shell_command(git diff)"
],
"tools": {
"sandbox": "docker"
}
}
run: |-
rm -rf "${QWEN_HOME}"
mkdir -p .qwen "${QWEN_HOME}"
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
echo '::error::AUTOFIX_OPENAI_API_KEY secret is required for Qwen Autofix.'
exit 1
fi
printf '%s\n' "${SETTINGS_JSON}" > .qwen/settings.json
rm -f "${WORKDIR}/decision.json" "${WORKDIR}/failure.md"
node .qwen/skills/autofix/scripts/run-agent.mjs \
--mode assess-candidates \
--workdir "${WORKDIR}"
- name: 'Read decision'
id: 'decision'
if: |-
${{ steps.scan.outputs.has_candidates == 'true' }}
env:
GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}'
DRY_RUN: '${{ needs.route.outputs.dry_run }}'
EVENT_NAME: '${{ github.event_name }}'
run: |-
if [[ ! -s "${WORKDIR}/decision.json" ]] || ! jq -e . "${WORKDIR}/decision.json" > /dev/null; then
echo "❌ Assessment produced no valid decision.json"
echo "go_issue=" >> "${GITHUB_OUTPUT}"
exit 0
fi
GO="$(jq -r '.go // empty' "${WORKDIR}/decision.json")"
if [[ -n "${GO}" && ! "${GO}" =~ ^[1-9][0-9]*$ ]]; then
echo "❌ Assessment produced an invalid issue number"
echo "go_issue=" >> "${GITHUB_OUTPUT}"
exit 0
fi
CANDIDATE_NUMS="$(jq -r '.[].number' "${WORKDIR}/candidates.json")"
if [[ -n "${GO}" ]] && ! grep -qx "${GO}" <<< "${CANDIDATE_NUMS}"; then
echo "❌ Assessment selected issue #${GO} which is not in the candidate list"
echo "go_issue=" >> "${GITHUB_OUTPUT}"
exit 0
fi
if [[ -n "${GO}" ]]; then
EXISTING_PR="$(jq -r --argjson go "${GO}" '
first(.[] | select(.number == $go) | .existingAutofixPr.number) // empty
' "${WORKDIR}/candidates.json")"
if [[ -n "${EXISTING_PR}" ]]; then
echo "⏭️ Selected issue #${GO} already has open autofix PR #${EXISTING_PR}; skipping issue develop."
echo "go_issue=" >> "${GITHUB_OUTPUT}"
exit 0
fi
fi
if [[ -n "${GO}" && "${DRY_RUN}" != "true" && "${EVENT_NAME}" != 'workflow_dispatch' ]]; then
if ! live_issue_json="$(gh issue view "${GO}" --repo "${REPO}" --json labels,state)"; then
echo "::warning::Failed to re-validate live labels for issue #${GO}; skipping due to API error"
echo "go_issue=" >> "${GITHUB_OUTPUT}"
exit 0
fi
if [[ "$(jq -r '.state // ""' <<< "${live_issue_json}")" != 'OPEN' ]]; then
echo "⏭️ Selected issue #${GO} is no longer open; skipping."
echo "go_issue=" >> "${GITHUB_OUTPUT}"
exit 0
fi
if ! jq -e --arg ready "${READY_FOR_AGENT_LABEL}" --arg approved "${AUTOFIX_APPROVED_LABEL}" \
'(.labels // []) | map(.name) as $labels | (($labels | index($ready)) and ($labels | index($approved)))' \
<<< "${live_issue_json}" > /dev/null; then
echo "⏭️ Selected issue #${GO} no longer has both ${READY_FOR_AGENT_LABEL} and ${AUTOFIX_APPROVED_LABEL}; skipping."
echo "go_issue=" >> "${GITHUB_OUTPUT}"
exit 0
fi
fi
echo "go_issue=${GO}" >> "${GITHUB_OUTPUT}"
echo "🧭 Decision: go=${GO:-none}"
jq -r '.reason // empty' "${WORKDIR}/decision.json"
# Label permanently-skipped issues so future scans move past them.
if [[ "${DRY_RUN}" != "true" ]]; then
gh label create 'autofix/skip' --repo "${REPO}" \
--description 'Not eligible for the scheduled autofix agent' \
--color 'ededed' 2> /dev/null || true
jq -c '(.skip // [])[] | select(.permanent == true)' "${WORKDIR}/decision.json" \
| while read -r row; do
NUM="$(jq -r '.number' <<< "${row}")"
if [[ ! "${NUM}" =~ ^[1-9][0-9]*$ ]]; then
echo "⚠️ Invalid skip number: ${NUM}"
continue
fi
if ! grep -qx "${NUM}" <<< "${CANDIDATE_NUMS}"; then
echo "⚠️ Skip issue #${NUM} is not in the candidate list"
continue
fi
echo "🏷️ Skipping #${NUM} permanently: $(jq -r '.reason' <<< "${row}")"
gh issue edit "${NUM}" --repo "${REPO}" --add-label 'autofix/skip' || true
done
fi
- name: 'Claim issue'
id: 'claim'
if: |-
${{ steps.decision.outputs.go_issue != '' && needs.route.outputs.dry_run != 'true' }}
env:
GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}'
ISSUE: '${{ steps.decision.outputs.go_issue }}'
run: |-
BODY="🤖 The scheduled autofix agent is picking this issue up. It will attempt to establish the current behavior, implement the requested change, run E2E verification, and open a pull request linked to this issue. If the attempt fails, this claim will be withdrawn so a human can take over.
Maintainers: comment or assign someone to stop future automated attempts, or add the \`autofix/skip\` label."
# The label, not the comment, is what future scans key off to
# avoid double-claiming.
gh label create 'autofix/in-progress' --repo "${REPO}" \
--description 'The scheduled autofix agent has claimed this issue' \
--color '1d76db' 2> /dev/null || true
gh label create "${AUTOFIX_APPROVED_LABEL}" --repo "${REPO}" \
--description 'Maintainer explicitly approved this issue for autonomous autofix' \
--color '0e8a16' 2> /dev/null || true
if ! gh issue edit "${ISSUE}" --repo "${REPO}" \
--add-label 'autofix/in-progress'; then
echo "::error::Failed to add autofix/in-progress label on #${ISSUE} before claim comment was posted"
exit 1
fi
gh issue edit "${ISSUE}" --repo "${REPO}" \
--remove-label "${AUTOFIX_APPROVED_LABEL}" || true
COMMENT_URL="$(gh issue comment "${ISSUE}" --repo "${REPO}" --body "${BODY}")"
COMMENT_ID="${COMMENT_URL##*-}"
echo "comment_id=${COMMENT_ID}" >> "${GITHUB_OUTPUT}"
echo "📌 Claimed #${ISSUE} (comment ${COMMENT_ID})"
- name: 'Develop fix'
id: 'develop'
if: |-
${{ steps.decision.outputs.go_issue != '' }}
env:
ISSUE: '${{ steps.decision.outputs.go_issue }}'
OPENAI_API_KEY: '${{ secrets.AUTOFIX_OPENAI_API_KEY }}'
OPENAI_BASE_URL: '${{ secrets.AUTOFIX_OPENAI_BASE_URL || secrets.OPENAI_BASE_URL }}'
OPENAI_MODEL: '${{ vars.QWEN_AUTOFIX_MODEL || vars.QWEN_PR_REVIEW_MODEL }}'
NO_PROXY: '127.0.0.1,localhost,::1'
QWEN_HOME: '${{ runner.temp }}/qwen-autofix-home'
SETTINGS_JSON: |-
{
"maxSessionTurns": 400,
"coreTools": [
"read_file",
"read_many_files",
"glob",
"search_file_content",
"write_file",
"run_shell_command(cat)",
"run_shell_command(git add)",
"run_shell_command(git checkout)",
"run_shell_command(git commit)",
"run_shell_command(git diff)",
"run_shell_command(git log)",
"run_shell_command(git status)",
"run_shell_command(git switch)",
"run_shell_command(ls)",
"run_shell_command(mkdir)",
"run_shell_command(npm run build)",
"run_shell_command(npm run typecheck)",
"run_shell_command(npm run lint)",
"run_shell_command(npx vitest)",
"run_shell_command(npm run generate:settings-schema)",
"run_shell_command(pwd)"
],
"tools": {
"sandbox": "docker"
}
}
run: |-
rm -rf "${QWEN_HOME}"
mkdir -p .qwen "${QWEN_HOME}"
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
echo '::error::AUTOFIX_OPENAI_API_KEY secret is required for Qwen Autofix.'
exit 1
fi
printf '%s\n' "${SETTINGS_JSON}" > .qwen/settings.json
rm -f "${WORKDIR}/failure.md"
node .qwen/skills/autofix/scripts/run-agent.mjs \
--mode develop-issue \
--issue "${ISSUE}" \
--workdir "${WORKDIR}"
- name: 'Verification gate'
id: 'verify'
if: |-
${{ steps.decision.outputs.go_issue != '' }}
env:
ISSUE: '${{ steps.decision.outputs.go_issue }}'
run: |-
BRANCH="autofix/issue-${ISSUE}"
if [[ -f "${WORKDIR}/failure.md" && -n "$(git status --porcelain)" ]]; then
echo "❌ Agent wrote failure.md after leaving a dirty workspace:"
git status --short
cat "${WORKDIR}/failure.md"
exit 1
fi
if [[ -f "${WORKDIR}/failure.md" ]]; then
echo "🛑 Agent aborted intentionally:"
cat "${WORKDIR}/failure.md"
exit 1
fi
if ! git rev-parse --verify "${BRANCH}" > /dev/null 2>&1; then
echo "❌ Expected branch ${BRANCH} does not exist"
exit 1
fi
git config core.hooksPath /dev/null
git checkout "${BRANCH}"
if git diff --quiet origin/main..."${BRANCH}"; then
echo "❌ Branch has no changes against main"
exit 1
fi
for f in pr-title.txt pr-body.md e2e-report.md; do
if [[ ! -s "${WORKDIR}/${f}" ]]; then
echo "❌ Missing required output ${f}"
exit 1
fi
done
echo '🔬 Re-running deterministic checks (independent of the agent)...'
npm run build
npm run typecheck
npm run lint
# Settings-schema freshness gate, shared with the triage-and-address
# verify step so the two copies cannot drift (rationale + the
# generator crash guard live in the script). On failure it writes
# outcome=failed to GITHUB_OUTPUT and exits 1.
# Run the copy staged from the trusted base checkout: a PR branch
# that predates the script does not contain it (bash would exit 127
# and kill the gate with no outcome), and the gate logic must come
# from the trusted base, not the branch under verification.
bash "${RUNNER_TEMP}/check-settings-schema.sh"
git diff --name-only "origin/main...${BRANCH}" \
| bash "${RUNNER_TEMP}/check-autofix-contracts.sh"
# Run changed/related tests for the packages this fix touches.
# --changed follows the import graph so transitive breakage is caught.
# Full regression is covered by regular CI on the PR after the push.
# Map each changed file to its OWNING npm workspace via the trusted
# staged resolver, shared with the other verify gate so both resolve
# packages identically. It expands the on-disk root package.json
# workspaces globs (so a workspace the branch ADDS is included) and
# takes each file's longest-prefix workspace — never a flat
# 'packages/<dir>' (ENOENT-crashes on nested packages) nor a fixture
# package.json inside a workspace's src tree (would skip the owning
# workspace's tests). No '|| true': a resolver error (missing node, an
# unreadable manifest) must fail the gate loudly rather than silently
# skip package tests; legitimate no-match input already exits 0 empty.
CHANGED_PKGS="$(git diff --name-only "origin/main...${BRANCH}" \
| bash "${RUNNER_TEMP}/resolve-owning-packages.sh")"
if [[ -z "${CHANGED_PKGS}" ]]; then
echo 'No package changes detected; skipping package tests.'
else
for p in ${CHANGED_PKGS}; do
if [[ ! -f "${p}/package.json" ]]; then
echo "Skipping ${p}: no package.json."
continue
fi
test_script="$(node -e 'const fs = require("node:fs"); const pkg = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); process.stdout.write(pkg.scripts?.test || "");' "${p}/package.json")"
if [[ "${test_script}" != *vitest* ]]; then
echo "Skipping ${p}: test script is not Vitest."
continue
fi
echo "🧪 Testing ${p} (changed files only)..."
npm run test --workspace "${p}" --if-present -- --changed origin/main --passWithNoTests
done
fi
- name: 'Show run artifacts'
if: |-
${{ always() && steps.decision.outputs.go_issue != '' }}
env:
ISSUE: '${{ steps.decision.outputs.go_issue }}'
run: |-
BRANCH="autofix/issue-${ISSUE}"
if git rev-parse --verify "${BRANCH}" > /dev/null 2>&1; then
git diff "origin/main...${BRANCH}" > "${WORKDIR}/fix.diff" || true
fi
for f in decision.json pr-title.txt pr-body.md e2e-report.md failure.md fix.diff; do
if [[ -f "${WORKDIR}/${f}" ]]; then
echo "=============== ${f} ==============="
cat "${WORKDIR}/${f}"
echo
fi
done
- name: 'Upload run artifacts'
if: |-
${{ always() && steps.scan.outputs.has_candidates == 'true' }}
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
with:
name: 'autofix-issue-artifacts'
path: '${{ env.WORKDIR }}/'
if-no-files-found: 'ignore'
- name: 'Publish PR'
id: 'publish'
if: |-
${{ steps.decision.outputs.go_issue != '' && needs.route.outputs.dry_run != 'true' }}
env:
# CI_DEV_BOT_PAT opens the PR as the configured autofix bot. This is
# required: the default GITHUB_TOKEN is
# blocked from creating PRs ("GitHub Actions is not permitted to
# create or approve pull requests"), and PRs it does create do not
# trigger CI. The bot PAT clears both problems.
GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}'
ISSUE: '${{ steps.decision.outputs.go_issue }}'
MODEL: '${{ vars.QWEN_AUTOFIX_MODEL || vars.QWEN_PR_REVIEW_MODEL }}'
run: |-
MODEL_DISPLAY="${MODEL:-default}"
if [[ -z "${GITHUB_TOKEN}" ]]; then
echo '::error::CI_DEV_BOT_PAT is required to publish the PR as the autofix bot.'
exit 1
fi
api_error_file="$(mktemp)"
if ! publish_actor="$(GH_TOKEN="${GITHUB_TOKEN}" gh api user --jq '.login' 2>"${api_error_file}")"; then
api_error="$(tr '\r\n' ' ' < "${api_error_file}")"
rm -f "${api_error_file}"
echo "::error::Failed to verify CI_DEV_BOT_PAT identity with gh api user: ${api_error:-unknown error}."
exit 1
fi
rm -f "${api_error_file}"
echo "CI_DEV_BOT_PAT authenticates as ${publish_actor}"
if [[ "${publish_actor}" != "${AUTOFIX_BOT}" ]]; then
echo "::error::CI_DEV_BOT_PAT authenticates as ${publish_actor}; expected ${AUTOFIX_BOT}."
exit 1
fi
BRANCH="autofix/issue-${ISSUE}"
git config --local --unset-all http.https://github.com/.extraheader || true
git config core.hooksPath /dev/null
# Authenticate the push with a one-shot, host-scoped credential
# helper via `git -c`: nothing is written to the reused
# workspace's .git/config (no error path can strand it there),
# argv holds only the literal ${GITHUB_TOKEN} reference, and the
# host scope means it cannot answer a non-GitHub URL.
git -c credential."https://github.com".helper='!f(){ echo username=x-access-token; echo "password=${GITHUB_TOKEN}"; };f' \
push --no-verify "https://github.com/${REPO}.git" "${BRANCH}"
PR_URL="$(gh pr create --repo "${REPO}" \
--base main --head "${BRANCH}" \
--title "$(cat "${WORKDIR}/pr-title.txt")" \
--body-file "${WORKDIR}/pr-body.md")"
echo "🚀 Opened ${PR_URL}"
# Per AGENTS.md, post the E2E report as a separate PR comment.
{
echo
echo "---"
echo "🧠 Handled by **Qwen Code** · model/模型 \`${MODEL_DISPLAY}\`"
} >> "${WORKDIR}/e2e-report.md"
gh pr comment "${PR_URL}" --body-file "${WORKDIR}/e2e-report.md"
- name: 'Report dry-run / failure'
if: |-
${{ always() && (needs.route.outputs.dry_run == 'true' || failure() || cancelled()) }}
env:
ISSUE: '${{ steps.decision.outputs.go_issue }}'
DRY_RUN: '${{ needs.route.outputs.dry_run }}'
OUTCOME: '${{ steps.verify.outputs.outcome }}'
run: |-
SUFFIX=''
[[ "${DRY_RUN}" == "true" ]] && SUFFIX=' (dry-run, nothing pushed)'
{
echo "### Issue autofix${ISSUE:+ #${ISSUE}} — outcome=${OUTCOME:-unknown}${SUFFIX}"
echo
for f in decision.json pr-title.txt pr-body.md e2e-report.md failure.md fix.diff; do
if [[ -s "${WORKDIR}/${f}" ]]; then
echo "**${f}:**"
echo '```'
cat "${WORKDIR}/${f}"
echo '```'
echo
fi
done
} >> "${GITHUB_STEP_SUMMARY}"
- name: 'Withdraw claim on failure'
if: |-
${{ (failure() || cancelled()) && steps.claim.outcome == 'success' }}
env:
GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}'
ISSUE: '${{ steps.decision.outputs.go_issue }}'
COMMENT_ID: '${{ steps.claim.outputs.comment_id }}'
PUBLISH_OUTCOME: '${{ steps.publish.outcome }}'
run: |-
# shellcheck disable=SC2016
if [[ -f "${WORKDIR}/failure.md" ]]; then
REASON='no further automated attempts will be made on this issue.'
DETAIL="$(head -c 1500 "${WORKDIR}/failure.md")"
LABEL_ARGS=(--remove-label 'autofix/in-progress' --add-label 'autofix/skip')
elif [[ "${PUBLISH_OUTCOME}" == 'failure' ]]; then
REASON='the issue will require the `autofix/approved` label to be re-added before any future automated attempt.'
DETAIL='The agent produced and verified a fix, but publishing the PR failed. Check the Publish PR step logs for the CI_DEV_BOT_PAT actor, git push, PR creation, or PR comment error.'
LABEL_ARGS=(--remove-label 'autofix/in-progress')
else
REASON='the issue will require the `autofix/approved` label to be re-added before any future automated attempt.'
DETAIL='The run failed before producing a verified fix.'
LABEL_ARGS=(--remove-label 'autofix/in-progress')
fi
gh issue edit "${ISSUE}" --repo "${REPO}" "${LABEL_ARGS[@]}" || true
gh issue comment "${ISSUE}" --repo "${REPO}" --body "🤖 Withdrawing the claim above — the automated fix attempt did not succeed; ${REASON}
What the agent found, in case it helps a human contributor:
${DETAIL}" || true
if [[ -n "${COMMENT_ID}" ]]; then
gh api -X DELETE "/repos/${REPO}/issues/comments/${COMMENT_ID}" || true
fi
# Nothing else removes the per-run WORKDIR; on the persistent pool
# every run would leave its transcripts and decision files behind
# forever. Last step, after every reader including the artifact
# upload. always() covers cancellation too; only a hard runner kill
# abandons the dir, and run_id never repeats — the age sweep in
# Reset autofix workspace reclaims it.
- name: 'Clean up autofix workdir'
if: 'always()'
run: 'rm -rf "${WORKDIR}"'
# ===========================================================================
# TAKEOVER COMMAND — the accepted comment command's side effects are the
# label toggle plus its own acknowledgements: add on an unlabeled PR
# toggles TAKEOVER_LABEL (the resulting pull_request event drives
# engagement exactly like a manual label edit — one mechanism, two entry
# points); add on an already-managed PR posts the re-arm ack (the round
# window anchor); skip-labeled and fork PRs get bilingual refusals instead
# of a toggle.
# ===========================================================================
takeover-command:
needs: 'route'
if: |-
${{ needs.route.outputs.takeover_cmd != '' }}
# Two quick commands are independent runs; without serialization an older
# /takeover could land its label write AFTER a newer /takeover stop read
# the unlabeled state, leaving the PR engaged against the latest intent.
# Queued (never cancelled) per-PR execution preserves command order.
concurrency:
group: 'qwen-autofix-takeover-cmd-${{ needs.route.outputs.cmd_pr }}'
cancel-in-progress: false
runs-on: 'ubuntu-latest'
timeout-minutes: 5
permissions:
contents: 'read'
env:
REPO: '${{ github.repository }}'
CMD: '${{ needs.route.outputs.takeover_cmd }}'
PR: '${{ needs.route.outputs.cmd_pr }}'
GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}'
steps:
- name: 'Toggle takeover label'
run: |-
api_error_file="$(mktemp)"
if ! bot_actor="$(GH_TOKEN="${GITHUB_TOKEN}" gh api user --jq '.login' 2>"${api_error_file}")"; then
api_error="$(tr '\r\n' ' ' < "${api_error_file}")"
rm -f "${api_error_file}"
echo "::error::Failed to verify CI_DEV_BOT_PAT identity with gh api user: ${api_error:-unknown error}."
exit 1
fi
rm -f "${api_error_file}"
if [[ "${bot_actor}" != "${AUTOFIX_BOT}" ]]; then
echo "::error::CI_DEV_BOT_PAT authenticates as '${bot_actor:-unknown}'; expected ${AUTOFIX_BOT}."
exit 1
fi
# Presence-aware toggle: adding a present label / removing an
# absent one fires no event, so make the no-op explicit instead.
# The same fetch carries isCrossRepository: fork PRs are refused UP
# FRONT — unlike the label path this job runs in issue_comment
# context WITH secrets, so it can explain itself, and refusing here
# keeps the label from ever sticking to a fork PR via the command.
if ! PR_INFO="$(gh pr view "${PR}" --repo "${REPO}" --json labels,isCrossRepository,state,baseRefName,author,maintainerCanModify 2> /dev/null)"; then
echo "::error::could not read PR #${PR}"
exit 1
fi
# State/base can change while this job sits in its per-PR queue —
# re-verify what the route checked so a stale command cannot label
# a closed or non-main PR.
if [[ "$(jq -r '.state // ""' <<< "${PR_INFO}")" != "OPEN" ]]; then
echo "🧭 takeover command dropped: PR #${PR} is no longer an open PR"
exit 0
fi
CMD_BASE_REF="$(jq -r '.baseRefName // ""' <<< "${PR_INFO}")"
if [[ "${CMD_BASE_REF}" != "main" ]]; then
if [[ "${CMD}" == 'add' ]]; then
# Refuse OUT LOUD — the silent drop made a /takeover on a
# stacked PR indistinguishable from a lost event. Mirrors the
# label path's base-refused ack, except no label was applied
# here, so the ask is to re-run the command after retargeting
# (not "the label is left in place").
gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '🚫 Takeover not engaged: the loop only manages PRs that target `main`, and this one targets `%s`. A stacked PR moves whenever its base branch does, so "new feedback since the last round" and base-conflict resolution are not well defined until the base lands. Retarget this PR to `main` once the base PR merges and re-run `%s` — or take over the base PR instead.\n\n<details>\n<summary>中文说明</summary>\n\n🚫 未接管:循环只管理以 `main` 为 base 的 PR,而本 PR 的 base 是 `%s`。堆叠 PR 会随 base 分支移动,因此“自上一轮以来的新反馈”与 base 冲突处理都无法良定义。待 base 的 PR 合入后将本 PR 改为面向 `main` 并重新执行 `%s`;或改为接管 base 那个 PR。\n\n</details>\n\n<!-- takeover-ack base-refused -->' "${CMD_BASE_REF}" "${TAKEOVER_COMMAND}" "${CMD_BASE_REF}" "${TAKEOVER_COMMAND}")"
echo "🧭 takeover command refused: PR #${PR} targets '${CMD_BASE_REF}' not 'main'"
exit 0
fi
# 'stop' proceeds: removing the label from a non-main PR is
# harmless and matches the latest intent — dropping it here left
# a manually-applied label stuck with no command able to remove
# it (the label path's release ack ignores non-main PRs too).
fi
# Skip wins over takeover EVERYWHERE — including here: engaging or
# re-arming a skip-labeled PR would post an 'engaged' window anchor
# for management that the scans deliberately refuse to perform.
if [[ "${CMD}" == 'add' && "$(jq -r --arg t "${SKIP_LABEL}" '[.labels[].name] | index($t) != null' <<< "${PR_INFO}")" == "true" ]]; then
gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '🚫 Takeover not engaged: this PR carries `%s`, which wins over takeover. Remove `%s` first, then re-run `%s`.\n\n<details>\n<summary>中文说明</summary>\n\n🚫 未接管:本 PR 带有 `%s` 标签,其优先级高于接管。请先移除 `%s`,再执行 `%s`。\n\n</details>\n\n<!-- takeover-ack skip-blocked -->' "${SKIP_LABEL}" "${SKIP_LABEL}" "${TAKEOVER_COMMAND}" "${SKIP_LABEL}" "${SKIP_LABEL}" "${TAKEOVER_COMMAND}")"
echo "🧭 takeover command refused: ${SKIP_LABEL} present on #${PR}"
exit 0
fi
# Fork PRs are manageable when the bot can actually push: the
# author must have granted 'Allow edits from maintainers'
# (org-owned forks cannot — adoption stays the path there), and
# only write+ senders reach this job for forks (fork authors are
# silently dropped at route). Without allow-edits, refuse with the
# actionable ask. Engage-side requirement only: release ('stop')
# is never blocked.
if [[ "${CMD}" == 'add' && "$(jq -r 'if has("isCrossRepository") then .isCrossRepository else true end' <<< "${PR_INFO}")" != "false" \
&& "$(jq -r '.maintainerCanModify // false' <<< "${PR_INFO}")" != "true" ]]; then
gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '🚫 Takeover needs push access to this fork branch: please tick “Allow edits from maintainers” on the PR and re-run `%s`. (Org-owned forks cannot enable it — a maintainer can adopt the PR instead: snapshot the head into an in-repo branch and take that over.)\n\n<details>\n<summary>中文说明</summary>\n\n🚫 托管需要对 fork 分支的推送权限:请在 PR 上勾选 “Allow edits from maintainers” 后重新执行 `%s`。(组织账号的 fork 无法勾选 —— 维护者可改用领养:将 head 快照为本仓库分支后接管。)\n\n</details>\n\n<!-- takeover-ack fork-refused -->' "${TAKEOVER_COMMAND}" "${TAKEOVER_COMMAND}")"
echo "🧭 takeover command refused: fork PR #${PR} without maintainer-edit access"
exit 0
fi
# The fork AUTHOR must hold write+ too (the scan re-checks this on
# every pickup and the address job once more live): engaging a
# below-write fork would stick a label that nothing ever manages —
# a silent ghost engagement with no ack and no explanation.
# Engage-side only; release is never blocked.
if [[ "${CMD}" == 'add' && "$(jq -r 'if has("isCrossRepository") then .isCrossRepository else true end' <<< "${PR_INFO}")" != "false" ]]; then
FORK_PR_AUTHOR="$(jq -r '.author.login // ""' <<< "${PR_INFO}")"
FORK_AUTHOR_PERM="$(gh api "repos/${REPO}/collaborators/${FORK_PR_AUTHOR}/permission" --jq '.permission // ""' 2> /dev/null || echo '')"
case "${FORK_AUTHOR_PERM}" in
admin|maintain|write) : ;;
*)
gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '🚫 Takeover not engaged: fork takeover requires the PR author to hold write access on this repository (author `%s` currently: `%s`). A maintainer can adopt the PR instead: snapshot the head into an in-repo branch, open a new PR (commit authorship is preserved), and take that over.\n\n<details>\n<summary>中文说明</summary>\n\n🚫 未接管:fork 托管要求 PR 作者在本仓库持有 write 及以上权限(作者 `%s` 当前为:`%s`)。维护者可改用领养:将 head 快照为本仓库分支并另开 PR(commit 署名保留),再对新 PR 执行接管。\n\n</details>\n\n<!-- takeover-ack fork-refused -->' "${FORK_PR_AUTHOR}" "${FORK_AUTHOR_PERM:-none}" "${FORK_PR_AUTHOR}" "${FORK_AUTHOR_PERM:-none}")"
echo "🧭 takeover command refused: fork PR #${PR} author '${FORK_PR_AUTHOR}' permission='${FORK_AUTHOR_PERM:-none}' below write"
exit 0
;;
esac
fi
HAS="$(jq -r --arg t "${TAKEOVER_LABEL}" '[.labels[].name] | index($t) != null' <<< "${PR_INFO}")"
if [[ "${CMD}" == 'add' ]]; then
if [[ "${HAS}" == 'true' ]]; then
# Already managed: repeating the command is the ROUND-COUNTER
# RESET. A fresh engage ack starts a new counting window (only
# markers newer than the latest ack count toward the cap), so
# a PR that exhausted its rounds continues under management —
# no label churn needed. The watermark is untouched: feedback
# already addressed is never replayed.
gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '🔄 Takeover re-armed: the round counter starts a fresh window (previous rounds no longer count toward the cap); management continues.\n\n<details>\n<summary>中文说明</summary>\n\n🔄 已重新武装:轮次计数开启新窗口(此前轮次不再计入上限),托管继续。\n\n</details>\n\n<!-- takeover-ack engaged -->')"
echo "🔄 re-armed ${TAKEOVER_LABEL} window on #${PR}"
else
gh pr edit "${PR}" --repo "${REPO}" --add-label "${TAKEOVER_LABEL}"
echo "🏷️ applied ${TAKEOVER_LABEL} to #${PR}"
# Ack HERE, not via the pull_request:labeled round-trip: that
# event has been observed to simply not fire (#7999 — the
# author read the silence as failure and removed the label;
# #8002 — no ack for hours), and fork label events could never
# ack at all (they carry no secrets). Every admission gate
# above has already passed, so 'engaged' is truthful for both
# in-repo and fork PRs. The route side suppresses the
# label-path ack when the label sender is the bot, and the
# scan's first-pickup ack dedups against this comment — and
# heals it on the next scan if this post fails, which is why
# a failure here only warns.
FORK_NOTE=''
FORK_NOTE_ZH=''
if [[ "$(jq -r 'if has("isCrossRepository") then .isCrossRepository else true end' <<< "${PR_INFO}")" != "false" ]]; then
FORK_NOTE=' This is a fork PR, so the first round comes from the next scheduled scan (usually within minutes).'
FORK_NOTE_ZH='本 PR 来自 fork,首轮处理将由下一次定时扫描执行(通常几分钟内)。'
fi
gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached.%s Remove the `%s` label (or comment `%s stop`) to release.\n\n<details>\n<summary>中文说明</summary>\n\n🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。%s移除 `%s` 标签(或评论 `%s stop`)即可释放。\n\n</details>\n\n<!-- takeover-ack engaged -->' "${FORK_NOTE}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${FORK_NOTE_ZH}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}")" \
|| echo "::warning::engage ack comment failed on #${PR}; the scan's first-pickup ack heals it"
fi
else
if [[ "${HAS}" != 'true' ]]; then
echo "ℹ️ #${PR} does not carry ${TAKEOVER_LABEL} — nothing to do"
else
gh pr edit "${PR}" --repo "${REPO}" --remove-label "${TAKEOVER_LABEL}"
echo "🏷️ removed ${TAKEOVER_LABEL} from #${PR}"
# Release ack, direct from the command — the exact mirror of
# the engage side above, for the same reason: the unlabeled
# round-trip is the thing we no longer trust, fork unlabeled
# events can never ack (no secrets), and a non-main release
# never even reaches the ack job. A loud add next to a mute
# stop would re-create the "did it work or did the event get
# lost?" ambiguity on the release side. Variant selection
# mirrors the ack job verbatim (live author + skip label from
# the same PR_INFO the gates used); the route side suppresses
# the unlabeled-path ack when the label sender is the bot.
REL_AUTHOR="$(jq -r '.author.login // ""' <<< "${PR_INFO}")"
REL_HAS_SKIP="$(jq -r --arg t "${SKIP_LABEL}" '[.labels[].name] | index($t) != null' <<< "${PR_INFO}")"
if [[ "${REL_AUTHOR}" == "${AUTOFIX_BOT}" && "${REL_HAS_SKIP}" == "true" ]]; then
REL_BODY="$(printf '👋 Takeover mode ended. This bot-authored PR also carries `%s`, which opts it out of standard bot management entirely — nothing will engage it until that label is removed.\n\n<details>\n<summary>中文说明</summary>\n\n👋 接管模式结束。本 bot 创建的 PR 同时带有 `%s`,已完全退出常规 bot 管理 —— 移除该标签前不会有任何介入。\n\n</details>\n\n<!-- takeover-ack released -->' "${SKIP_LABEL}" "${SKIP_LABEL}")"
elif [[ "${REL_AUTHOR}" == "${AUTOFIX_BOT}" ]]; then
REL_BODY="$(printf '👋 Takeover mode ended: the raised round cap no longer applies. This is a bot-authored PR, so STANDARD bot management continues under the strict cap (apply `%s` to opt it out entirely). Re-apply `%s` (or comment `%s`) for the raised cap again.\n\n<details>\n<summary>中文说明</summary>\n\n👋 接管模式结束:提升的轮次上限不再适用。这是 bot 创建的 PR,常规 bot 管理仍将继续(严格上限;如需完全退出请打 `%s`)。重新打上 `%s` 标签(或评论 `%s`)可恢复提升上限。\n\n</details>\n\n<!-- takeover-ack released -->' "${SKIP_LABEL}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${SKIP_LABEL}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}")"
else
REL_BODY="$(printf '👋 Takeover released: the autofix loop will no longer engage this PR (an in-flight round, if any, completes its bounded work). Re-apply `%s` (or comment `%s`) to re-engage.\n\n<details>\n<summary>中文说明</summary>\n\n👋 已释放:autofix 循环不再介入此 PR(在飞的一轮如有,将完成其有界工作)。重新打上 `%s` 标签(或评论 `%s`)即可再次接管。\n\n</details>\n\n<!-- takeover-ack released -->' "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}")"
fi
gh pr comment "${PR}" --repo "${REPO}" --body "${REL_BODY}" \
|| echo "::warning::release ack comment failed on #${PR}"
fi
fi
# ===========================================================================
# TAKEOVER ACK — visible confirmation when a maintainer engages or releases
# a PR via the takeover label. Manual label toggles are explicit user
# actions, so every one acks (no dedup wanted). Command-driven toggles are
# acked by takeover-command itself in BOTH directions — the label event has
# been observed to not fire at all (#7999, #8002), so those acks cannot
# depend on this round-trip — and the route suppresses this job for them
# (label sender is the bot). In-repo PRs only reach this job.
# ===========================================================================
# Re-arm a stranded PR without deleting anything. Recovery previously meant
# `gh api -X DELETE` on the bot's own autofix-eval marker comment: raw API
# access, an erased audit trail, and undiscoverable unless you had read the
# workflow. This posts ONE marker instead — the scan then re-reads the
# feedback (the marker releases the watermark those older markers held) and
# the round counter resets, because the marker also opens a fresh counting
# window exactly like an engage ack.
retry-command:
needs: 'route'
if: |-
${{ needs.route.outputs.retry_pr != '' }}
# Queued (never cancelled) per PR so two quick /retry comments cannot
# interleave their marker writes.
concurrency:
group: 'qwen-autofix-retry-cmd-${{ needs.route.outputs.retry_pr }}'
cancel-in-progress: false
runs-on: 'ubuntu-latest'
timeout-minutes: 5
permissions:
contents: 'read'
env:
REPO: '${{ github.repository }}'
PR: '${{ needs.route.outputs.retry_pr }}'
GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}'
steps:
- name: 'Post re-arm marker'
run: |-
# The marker only counts when the AUTOFIX_BOT authored it (both the
# scan and the address-side recheck filter markers by that login), so
# a mis-scoped PAT would post a comment that silently does nothing.
api_error_file="$(mktemp)"
if ! bot_actor="$(GH_TOKEN="${GITHUB_TOKEN}" gh api user --jq '.login' 2>"${api_error_file}")"; then
api_error="$(tr '\r\n' ' ' < "${api_error_file}")"
rm -f "${api_error_file}"
echo "::error::Failed to verify CI_DEV_BOT_PAT identity with gh api user: ${api_error:-unknown error}."
exit 1
fi
rm -f "${api_error_file}"
if [[ "${bot_actor}" != "${AUTOFIX_BOT}" ]]; then
echo "::error::CI_DEV_BOT_PAT authenticates as '${bot_actor:-unknown}'; expected ${AUTOFIX_BOT}."
exit 1
fi
gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '\U0001F504 AutoFix re-armed. The next scan re-reads this PR'"'"'s feedback from the start and the round counter resets. Nothing was deleted — this marker supersedes the evaluation markers above it.\n\n<details>\n<summary>中文说明</summary>\n\n\U0001F504 已重新武装 AutoFix。下一次扫描会从头重新读取本 PR 的反馈,轮次计数也已重置。未删除任何内容 —— 本标记使其上方的评估标记失效。\n\n</details>\n\n<!-- autofix-rearm -->')"
echo "🔄 re-armed PR #${PR}"
takeover-ack:
needs: 'route'
if: |-
${{ needs.route.outputs.takeover_ack != '' }}
runs-on: 'ubuntu-latest'
timeout-minutes: 5
permissions:
contents: 'read'
env:
REPO: '${{ github.repository }}'
ACK: '${{ needs.route.outputs.takeover_ack }}'
PR: '${{ needs.route.outputs.ack_pr }}'
# The base the ROUTE saw when it refused — naming a live re-read here
# could report 'main' for a refusal that was decided against something
# else, so the message stays tied to the decision it explains.
ACK_BASE: '${{ needs.route.outputs.ack_base }}'
GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}'
steps:
- name: 'Acknowledge takeover state change'
run: |-
api_error_file="$(mktemp)"
if ! bot_actor="$(GH_TOKEN="${GITHUB_TOKEN}" gh api user --jq '.login' 2>"${api_error_file}")"; then
api_error="$(tr '\r\n' ' ' < "${api_error_file}")"
rm -f "${api_error_file}"
echo "::error::Failed to verify CI_DEV_BOT_PAT identity with gh api user: ${api_error:-unknown error}."
exit 1
fi
rm -f "${api_error_file}"
if [[ "${bot_actor}" != "${AUTOFIX_BOT}" ]]; then
echo "::error::CI_DEV_BOT_PAT authenticates as '${bot_actor:-unknown}'; expected ${AUTOFIX_BOT}."
exit 1
fi
# Bilingual with COLLAPSED Chinese (project convention), built via
# printf so no workflow indentation leaks into the markdown (4+
# leading spaces would render the marker line as a code block).
# Live label/author state decides WHAT to acknowledge: a skip label
# vetoes the engagement (skip wins — no engaged anchor for
# management the scans refuse), and a release on a BOT-authored PR
# must not claim disengagement — standard bot management continues,
# only takeover mode (raised cap) ends.
# Fail CLOSED like the sibling takeover-command job: empty metadata
# here would default HAS_SKIP to false and post a wrong "engaged"
# ack on a skip-labeled PR during a transient API failure. A red
# ack job posts nothing — engagement itself is scan-driven and
# unaffected.
# A base refusal needs no live state — it is decided entirely by the
# route — so it does NOT ride on this read. Making the one ack whose
# whole purpose is "say why nothing happened" depend on an unrelated
# API call would reintroduce the silence it exists to remove.
HAS_SKIP=''
PR_AUTHOR_LIVE=''
if [[ "${ACK}" != 'base-refused' ]]; then
if ! PR_STATE_INFO="$(gh pr view "${PR}" --repo "${REPO}" --json labels,author 2> /dev/null)"; then
echo "::error::could not read PR #${PR} state for takeover ack"
exit 1
fi
HAS_SKIP="$(jq -r --arg t "${SKIP_LABEL}" '[.labels[]?.name] | index($t) != null' <<< "${PR_STATE_INFO}")"
PR_AUTHOR_LIVE="$(jq -r '.author.login // ""' <<< "${PR_STATE_INFO}")"
fi
if [[ "${ACK}" == 'base-refused' ]]; then
BODY="$(printf '🚫 Takeover not engaged: the loop only manages PRs that target `main`, and this one targets `%s`. A stacked PR moves whenever its base branch does, so "new feedback since the last round" and base-conflict resolution are not well defined until the base lands. Two ways forward: retarget this PR to `main` once the base PR merges — the `%s` label is left in place and the scan lists by label, so the next scan engages it with no re-labelling — or take over the base PR instead.\n\n<details>\n<summary>中文说明</summary>\n\n🚫 未接管:循环只管理以 `main` 为 base 的 PR,而本 PR 的 base 是 `%s`。堆叠 PR 会随 base 分支移动,因此“自上一轮以来的新反馈”与 base 冲突处理都无法良定义。两条路:待 base 的 PR 合入后把本 PR 改为面向 `main` —— `%s` 标签予以保留,扫描按标签枚举,下一次扫描即会自动接管,无需重新打标签;或改为接管 base 那个 PR。\n\n</details>\n\n<!-- takeover-ack base-refused -->' "${ACK_BASE}" "${TAKEOVER_LABEL}" "${ACK_BASE}" "${TAKEOVER_LABEL}")"
elif [[ "${ACK}" == 'engaged' && "${HAS_SKIP}" == "true" ]]; then
BODY="$(printf '🚫 Takeover label applied but NOT engaged: this PR carries `%s`, which wins over takeover — the loop will not manage it. Remove `%s` to engage.\n\n<details>\n<summary>中文说明</summary>\n\n🚫 已打接管标签但未生效:本 PR 带有 `%s`,其优先级高于接管,循环不会介入。移除 `%s` 后才会接管。\n\n</details>\n\n<!-- takeover-ack skip-blocked -->' "${SKIP_LABEL}" "${SKIP_LABEL}" "${SKIP_LABEL}" "${SKIP_LABEL}")"
elif [[ "${ACK}" == 'engaged' ]]; then
BODY="$(printf '🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. Remove the `%s` label (or comment `%s stop`) to release.\n\n<details>\n<summary>中文说明</summary>\n\n🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。移除 `%s` 标签(或评论 `%s stop`)即可释放。\n\n</details>\n\n<!-- takeover-ack engaged -->' "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}")"
elif [[ "${PR_AUTHOR_LIVE}" == "${AUTOFIX_BOT}" && "${HAS_SKIP}" == "true" ]]; then
BODY="$(printf '👋 Takeover mode ended. This bot-authored PR also carries `%s`, which opts it out of standard bot management entirely — nothing will engage it until that label is removed.\n\n<details>\n<summary>中文说明</summary>\n\n👋 接管模式结束。本 bot 创建的 PR 同时带有 `%s`,已完全退出常规 bot 管理 —— 移除该标签前不会有任何介入。\n\n</details>\n\n<!-- takeover-ack released -->' "${SKIP_LABEL}" "${SKIP_LABEL}")"
elif [[ "${PR_AUTHOR_LIVE}" == "${AUTOFIX_BOT}" ]]; then
BODY="$(printf '👋 Takeover mode ended: the raised round cap no longer applies. This is a bot-authored PR, so STANDARD bot management continues under the strict cap (apply `%s` to opt it out entirely). Re-apply `%s` (or comment `%s`) for the raised cap again.\n\n<details>\n<summary>中文说明</summary>\n\n👋 接管模式结束:提升的轮次上限不再适用。这是 bot 创建的 PR,常规 bot 管理仍将继续(严格上限;如需完全退出请打 `%s`)。重新打上 `%s` 标签(或评论 `%s`)可恢复提升上限。\n\n</details>\n\n<!-- takeover-ack released -->' "${SKIP_LABEL}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${SKIP_LABEL}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}")"
else
BODY="$(printf '👋 Takeover released: the autofix loop will no longer engage this PR (an in-flight round, if any, completes its bounded work). Re-apply `%s` (or comment `%s`) to re-engage.\n\n<details>\n<summary>中文说明</summary>\n\n👋 已释放:autofix 循环不再介入此 PR(在飞的一轮如有,将完成其有界工作)。重新打上 `%s` 标签(或评论 `%s`)即可再次接管。\n\n</details>\n\n<!-- takeover-ack released -->' "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}")"
fi
gh pr comment "${PR}" --repo "${REPO}" --body "${BODY}"
# ===========================================================================
# REVIEW PHASE (scan) — find one autofix PR with new, unaddressed feedback
# (or a base conflict) and emit it as a matrix. Cheap: GitHub API only —
# its single write is the identity-verified, once-per-window cap notice.
# ===========================================================================
review-scan:
needs: 'route'
if: |-
${{ needs.route.outputs.do_review == 'true' }}
runs-on: 'ubuntu-latest'
timeout-minutes: 15
# A forced scan can write the same status comment as review-address.
# Share its per-PR lock so neither writer can erase the other's state.
# Keep the predicate as narrow as the job's own `if:` — concurrency is
# evaluated BEFORE it, so without the do_review conjunct a dispatch with
# `phase: issue` + `pr_number: N` (route emits pr_number unconditionally)
# would park this skipped job in that PR's shared slot behind a 300-minute
# address round, stalling the issue phase that `needs` it.
concurrency:
group: "qwen-pr-head-write-${{ needs.route.outputs.do_review == 'true' && needs.route.outputs.pr_number || github.run_id }}"
cancel-in-progress: false
outputs:
targets: '${{ steps.scan.outputs.targets }}'
has_targets: '${{ steps.scan.outputs.has_targets }}'
env:
REPO: '${{ github.repository }}'
steps:
- name: 'Scan for PRs with new feedback'
id: 'scan'
env:
GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}'
FORCED_PR: '${{ needs.route.outputs.pr_number }}'
DRY_RUN: '${{ needs.route.outputs.dry_run }}'
EVENT_NAME: '${{ github.event_name }}'
run: |-
# Fleet visibility: every per-PR decision below also records a row so
# the run summary shows the WHOLE managed fleet in one table.
# Reconstructing this by hand (list bot PRs, regex each one's eval
# markers, cross-check checks and fork state) was the only way to see
# a stall, so stalls stayed invisible until someone went looking.
FLEET_FILE="$(mktemp)"
trap 'rm -f "${FLEET_FILE}"' EXIT
fleet_row() {
printf '%s\t%s\t%s\n' "$1" "$2" "$3" >> "${FLEET_FILE}"
}
WORKDIR="$(mktemp -d)"
read_forced_pr_meta() {
local attempt meta
for attempt in 1 2 3; do
if meta="$(gh pr view "${FORCED_PR}" --repo "${REPO}" \
--json number,state,author,headRefName,isCrossRepository,baseRefName,labels,maintainerCanModify 2> /dev/null)" \
&& jq -e 'type == "object"
and (.number | type == "number")
and (.state | type == "string")
and (.author.login | type == "string")
and (.headRefName | type == "string")
and (.baseRefName | type == "string")
and (.isCrossRepository | type == "boolean")
and (.labels | type == "array")
and (.maintainerCanModify | type == "boolean")' > /dev/null <<< "${meta}"; then
printf '%s' "${meta}"
return 0
fi
echo "::warning::Forced PR #${FORCED_PR} metadata lookup failed (attempt ${attempt}/3)" >&2
[[ "${attempt}" -lt 3 ]] && sleep "${attempt}"
done
return 1
}
# 'none' and HTTP 404 are DEFINITIVE answers, not lookup failures.
# GitHub returns 200 with permission 'none' for logins that exist but
# hold nothing here (bot-type logins such as dependabot[bot], and org
# logins), and 404 for logins that do not exist or are empty. Both
# mean "no write access" — the routine rejection this gate is for.
# Retrying them would burn 3 API calls plus back-off per candidate per
# scheduled tick, forever, and strand the caller on
# 'permission_lookup_failed': a red forced run (exit 1) whose blocked
# comment promises "a later scheduled scan will retry" — a retry that
# can never succeed — while the actionable "grant the fork author
# write access" guidance behind author_permission_* stays unreachable.
# Only genuinely transient answers (5xx, network, auth) retry.
read_live_permission() {
local login="$1" attempt permission err result=''
# An empty login can only 404; skip the call and answer terminally.
if [[ -z "${login}" ]]; then
printf 'none'
return 0
fi
err="$(mktemp)"
for attempt in 1 2 3; do
if permission="$(gh api "repos/${REPO}/collaborators/${login}/permission" --jq '.permission // ""' 2> "${err}")" \
&& [[ "${permission}" =~ ^(admin|maintain|write|triage|read|none)$ ]]; then
result="${permission}"
break
fi
if grep -q 'HTTP 404' "${err}"; then
result='none'
break
fi
# Surface gh's own diagnosis instead of discarding it: a rate
# limit, an expired PAT and a 5xx all look identical otherwise.
echo "::warning::Permission lookup failed for ${login} (attempt ${attempt}/3): $(tr '\n' ' ' < "${err}")" >&2
[[ "${attempt}" -lt 3 ]] && sleep "${attempt}"
done
rm -f "${err}"
[[ -n "${result}" ]] || return 1
printf '%s' "${result}"
}
forced_admission_reason() {
jq -r --arg ab "${AUTOFIX_BOT}" --arg take "${TAKEOVER_LABEL}" --arg skip "${SKIP_LABEL}" '
if (.state // "") != "OPEN" then "not_open"
elif (.baseRefName // "") != "main" then "wrong_base"
elif ([.labels[]?.name] | index($skip) != null) then "skip_label"
elif ((((.author.login // "") == $ab) or ([.labels[]?.name] | index($take) != null)) | not) then "unmanaged_author"
elif (.isCrossRepository == true) and (.maintainerCanModify != true) then "maintainer_edits_disabled"
elif (((.isCrossRepository == true) or (.isCrossRepository == false)) | not) then "cross_repo_state_missing"
else "eligible"
end'
}
report_forced_takeover_blocked() {
local reason="$1" actor status_ids status_id body attempt status_lookup_ok next_en next_zh err
[[ "${DRY_RUN}" == 'true' ]] && return 0
[[ "$(jq -r --arg ab "${AUTOFIX_BOT}" --arg take "${TAKEOVER_LABEL}" '
((.author.login // "") == $ab) or ([.labels[]?.name] | index($take) != null)
' <<< "${META}")" == 'true' ]] || return 0
case "${reason}" in
permission_lookup_failed|author_permission_*|maintainer_edits_disabled|cross_repo_state_missing) ;;
*) return 0 ;;
esac
actor=''
for attempt in 1 2 3; do
if actor="$(gh api user --jq '.login' 2> /dev/null)" && [[ -n "${actor}" ]]; then
break
fi
echo "::warning::PAT identity lookup failed (attempt ${attempt}/3)" >&2
[[ "${attempt}" -lt 3 ]] && sleep "${attempt}"
done
if [[ "${actor}" != "${AUTOFIX_BOT}" ]]; then
echo "::warning::Blocked takeover status skipped: PAT authenticates as '${actor:-unknown}'" >&2
return 1
fi
if [[ "${reason}" == 'maintainer_edits_disabled' ]]; then
next_en='Re-enable maintainer edits on the fork PR to resume takeover.'
next_zh='请在 fork PR 上重新允许 maintainer edits,以恢复 takeover。'
elif [[ "${reason}" == author_permission_* ]]; then
next_en='Grant the fork author write access, or remove the autofix/takeover label, to resume takeover.'
next_zh='请授予 fork 作者 write 权限,或移除 autofix/takeover 标签,以恢复 takeover。'
else
next_en='A later scheduled scan will retry without advancing the feedback watermark.'
next_zh='后续定时扫描会重试,本次不会推进反馈水位。'
fi
# Every other status writer resolves its run link from
# github.server_url / GITHUB_SERVER_URL. Hardcoding github.com here
# would make the one link this message exists to surface the only
# broken one on a GHES or proxied host.
body="$(printf '<!-- autofix-status -->\n\n⛔ **AutoFix blocked** — takeover admission stopped at `%s`, so no work was started. [View run](%s/%s/actions/runs/%s). %s\n\n<details>\n<summary>中文说明</summary>\n\n⛔ **AutoFix 已阻塞** —— takeover 准入停在 `%s`,因此本轮未开始处理。[查看运行](%s/%s/actions/runs/%s)。%s\n\n</details>' \
"${reason}" "${GITHUB_SERVER_URL}" "${REPO}" "${GITHUB_RUN_ID}" "${next_en}" \
"${reason}" "${GITHUB_SERVER_URL}" "${REPO}" "${GITHUB_RUN_ID}" "${next_zh}")"
status_ids=''
status_lookup_ok=false
err="$(mktemp)"
# Same filter as the sibling upsert in 'Post autofix status comment',
# including its two guards: `// ""` so a single comment with a null
# body cannot abort the whole program (jq exits 5, all three
# attempts fail, and the run reds out WITHOUT posting the very
# status it exists to post), and --arg so a repo-configured
# AUTOFIX_BOT_LOGIN containing " or \ is a mismatch instead of a jq
# parse error. Stays an inline id stream into `tail -1` — it never
# lands in a WORKDIR json file, so the WORKDIR page normalizer
# (add-with-empty-default) must NOT be applied here: it would wrap
# the id stream in an array and break the tail-1 consumer.
# pipefail is set LOCALLY here rather than relied on: this `if`
# must test gh's status, not jq's. A gh failure carrying an HTTP
# status prints the error body to stdout, so jq errors out and the
# retry fires — but a CONNECTION-level failure (TCP reset, TLS
# abort, DNS blip) leaves stdout EMPTY, and `jq -rs` then prints
# nothing and exits 0. Without pipefail that reads as success on
# nothing read: status_lookup_ok=true, the empty id takes the
# writer down the "no status comment yet" branch, and it posts a
# DUPLICATE ⛔ blocked comment beside the stale ✅ one — the exact
# two-status state this function exists to prevent — on a green
# run. `defaults.run.shell: bash` already gives every step in this
# file `-eo pipefail`, so this is redundant today; it is also the
# only guard that survives that default changing or this helper
# being lifted into a step that sets its own options.
for attempt in 1 2 3; do
if status_ids="$(set -o pipefail; gh api "repos/${REPO}/issues/${FORCED_PR}/comments" --paginate 2> "${err}" |
jq -rs --arg ab "${AUTOFIX_BOT}" --arg m '<!-- autofix-status -->' \
'.[][] | select((.user.login // "") == $ab) | select((.body // "") | contains($m)) | .id')"; then
status_lookup_ok=true
break
fi
# Surface gh's own diagnosis instead of discarding it, exactly as
# read_live_permission does: a rate limit, an expired PAT and a
# 5xx are indistinguishable from 'attempt 3/3' alone.
echo "::warning::Takeover status lookup failed for #${FORCED_PR} (attempt ${attempt}/3): $(tr '\n' ' ' < "${err}")" >&2
[[ "${attempt}" -lt 3 ]] && sleep "${attempt}"
done
rm -f "${err}"
if [[ "${status_lookup_ok}" != 'true' ]]; then
echo "::warning::Failed to read takeover status comments for #${FORCED_PR}" >&2
return 1
fi
status_id="$(tail -1 <<< "${status_ids}")"
if [[ -n "${status_id}" ]]; then
for attempt in 1 2 3; do
if gh api --method PATCH "repos/${REPO}/issues/comments/${status_id}" -f body="${body}" > /dev/null; then
return 0
fi
echo "::warning::Failed to update blocked takeover status for #${FORCED_PR} (attempt ${attempt}/3)" >&2
[[ "${attempt}" -lt 3 ]] && sleep "${attempt}"
done
else
for attempt in 1 2 3; do
if gh pr comment "${FORCED_PR}" --repo "${REPO}" --body "${body}" > /dev/null; then
return 0
fi
echo "::warning::Failed to post blocked takeover status for #${FORCED_PR} (attempt ${attempt}/3)" >&2
[[ "${attempt}" -lt 3 ]] && sleep "${attempt}"
done
fi
return 1
}
# Candidate PRs: open, same-repo, targeting main, and either
# authored by the dev-bot or opted in via TAKEOVER_LABEL. A PR
# carrying SKIP_LABEL is excluded everywhere — skip wins over
# takeover when both are present. A forced PR must still pass all
# these checks. NOTE `.isCrossRepository == false` (fail-closed on a
# missing field), never a `// true` default piped through `not`:
# jq's // treats false as empty, so that form is false for EVERY
# input and silently green-no-op'd all forced dispatches.
if [[ -n "${FORCED_PR}" ]]; then
if ! META="$(read_forced_pr_meta)"; then
echo "::error::Forced PR #${FORCED_PR} admission blocked: metadata_fetch_failed"
echo "targets=[]" >> "${GITHUB_OUTPUT}"
echo "has_targets=false" >> "${GITHUB_OUTPUT}"
exit 1
fi
# Same admission as the scheduled scan below. In-repo PRs fail
# CLOSED on a missing isCrossRepository field (`.isCrossRepository
# == false`, never a `// true | not` default — jq's // treats false
# as empty, so that form is false for EVERY input and silently
# green-no-op'd all forced dispatches). Fork PRs are admitted under
# the scan's OWN fork rules (allow-edits on; the live write+ author
# gate runs in the shell case just below, mirroring the scan's
# per-candidate permission call) so the real-time route's fork
# pickup is not silently discarded here.
ADMISSION_REASON="$(forced_admission_reason <<< "${META}")"
# Fork only: the author must hold write+ RIGHT NOW (the same
# live-privilege rule the scan applies per candidate and
# review-address re-checks before pushing). In-repo PRs are gated
# by author/label alone.
if [[ "${ADMISSION_REASON}" == 'eligible' && "$(jq -r '.isCrossRepository == true' <<< "${META}")" == 'true' ]]; then
FORK_AUTHOR="$(jq -r '.author.login // ""' <<< "${META}")"
if ! FPERM="$(read_live_permission "${FORK_AUTHOR}")"; then
ADMISSION_REASON='permission_lookup_failed'
report_forced_takeover_blocked "${ADMISSION_REASON}" \
|| echo "::error::Forced PR #${FORCED_PR} blocked status update failed"
echo "::error::Forced PR #${FORCED_PR} admission blocked: ${ADMISSION_REASON}"
echo "targets=[]" >> "${GITHUB_OUTPUT}"
echo "has_targets=false" >> "${GITHUB_OUTPUT}"
exit 1
fi
case "${FPERM}" in
admin|maintain|write)
echo "🌿 forced fork PR #${FORCED_PR} admitted (author ${FORK_AUTHOR}=${FPERM})"
;;
*)
ADMISSION_REASON="author_permission_${FPERM:-none}"
echo "🧭 forced fork PR #${FORCED_PR} rejected: ${ADMISSION_REASON}"
;;
esac
fi
if [[ "${ADMISSION_REASON}" != 'eligible' ]]; then
if ! report_forced_takeover_blocked "${ADMISSION_REASON}"; then
echo "::error::Forced PR #${FORCED_PR} blocked status update failed"
echo "targets=[]" >> "${GITHUB_OUTPUT}"
echo "has_targets=false" >> "${GITHUB_OUTPUT}"
exit 1
fi
echo "❌ Forced PR #${FORCED_PR} rejected: ${ADMISSION_REASON}"
echo "targets=[]" >> "${GITHUB_OUTPUT}"
echo "has_targets=false" >> "${GITHUB_OUTPUT}"
exit 0
fi
CANDIDATES="${FORCED_PR}"
else
gh pr list --repo "${REPO}" --state open --author "${AUTOFIX_BOT}" \
--base main \
--limit 100 --json number,headRefName,isCrossRepository,labels,author,maintainerCanModify,updatedAt > "${WORKDIR}/bot-prs.json"
gh pr list --repo "${REPO}" --state open --label "${TAKEOVER_LABEL}" \
--base main \
--limit 100 --json number,headRefName,isCrossRepository,labels,author,maintainerCanModify,updatedAt > "${WORKDIR}/takeover-prs.json"
# Skip-labeled PRs are excluded HERE, not only at the address
# gate: that gate discards without writing a marker, so the
# watermark never advances and an unfiltered scan would re-emit
# the PR (checkout, npm ci, build) every tick forever.
# Rotating start offset (changes every ~10 minutes): a fixed
# newest-first order plus the inspection budget would starve the
# oldest tail FOREVER once the pool exceeds the budget; rotation
# guarantees every candidate is reached within pool/budget scans.
ROT_OFF="$(( ($(date -u +%s) / 600) % 97 ))"
CANDIDATES="$(jq -rs --arg skip "${SKIP_LABEL}" --argjson off "${ROT_OFF}" \
'add
| unique_by(.number)
| sort_by(-.number)
| map(select(.isCrossRepository == false))
| map(select([.labels[]?.name] | index($skip) | not))
| (if length == 0 then . else (($off % length) as $o | .[$o:] + .[:$o]) end)
| .[]
| .number' \
"${WORKDIR}/bot-prs.json" "${WORKDIR}/takeover-prs.json")"
# FORK PRs are admitted per candidate: the author must hold write+
# RIGHT NOW (the same live-privilege rule as the comment command)
# and the PR must allow maintainer edits (or the bot cannot push).
# Two sources, unioned: takeover-LABELED forks (any eligible author,
# explicit opt-in) AND the bot's OWN forks (bot-prs.json is
# --author AUTOFIX_BOT) — a fork the bot itself opened is its own
# generated work, trust-equal to an in-repo bot PR, so it needs no
# label (autofix/skip still opts it out). Rare set — one permission
# call each; the write+ check below still gates every candidate.
# Appended after the rotated in-repo list: forks sit outside the
# anti-starvation rotation, which only bites once in-repo
# candidates alone exhaust the inspection budget.
while IFS=$'\t' read -r FPR FAUTHOR; do
[[ -z "${FPR}" ]] && continue
if ! FPERM="$(read_live_permission "${FAUTHOR}")"; then
echo "::warning::Fork takeover candidate #${FPR} blocked: permission_lookup_failed"
fleet_row "${FPR}" 'blocked' 'permission_lookup_failed'
continue
fi
case "${FPERM}" in
admin|maintain|write)
echo "🌿 fork takeover candidate #${FPR} admitted (author ${FAUTHOR}=${FPERM})"
CANDIDATES="${CANDIDATES} ${FPR}"
;;
*)
echo "🧭 fork takeover candidate #${FPR} skipped: author ${FAUTHOR} permission='${FPERM:-none}' below write"
fleet_row "${FPR}" 'blocked' "author_permission_${FPERM:-none}"
;;
esac
done < <(jq -rs --arg skip "${SKIP_LABEL}" '
add | unique_by(.number)
| .[] | select(.isCrossRepository == true)
| select(.maintainerCanModify == true)
| select([.labels[]?.name] | index($skip) | not)
| [(.number | tostring), (.author.login // "")] | @tsv' \
"${WORKDIR}/bot-prs.json" "${WORKDIR}/takeover-prs.json")
fi
# Pending-check staleness bound (invariant across candidate PRs, computed
# once): ignore a check stuck far past any legitimate runtime. The bound
# must sit ABOVE real check durations here — review-pr can take ~50m and
# a review-address JOB runs up to its 300-minute cap — so an active run
# keeps blocking and is never aged out mid-flight (which would enqueue
# the PR against a live check and double-process the feedback). 330 holds
# a 30-minute margin over that cap.
PENDING_STALE_MIN=330
PENDING_CUTOFF="$(date -u -d "${PENDING_STALE_MIN} minutes ago" +%Y-%m-%dT%H:%M:%SZ)"
# Repetition-guard cutoff for the stale-base update marker (invariant
# across candidate PRs, computed once — same reasoning as
# PENDING_CUTOFF above). A marker newer than this bounds re-updates
# to once per 2 hours (CI takes ~40 min; main moves ~13 min).
BASE_UPDATE_CUTOFF="$(date -u -d '120 minutes ago' +%Y-%m-%dT%H:%M:%SZ)"
# Base of the auto-update-stale-base decision below. A PR can be red
# purely because it merged a main that was BROKEN at the time and has
# since been FIXED — observed repeatedly (a web-shell TS break, an
# agent-registry test) stranding healthy PRs on a failure that has
# nothing to do with them. GitHub's "Update branch" merges current
# main in and re-runs CI, which clears it. We do that automatically
# only when the SAME failing check also passed for the PR that produced
# current main (MAIN_GREEN_CHECKS) — a necessary-but-NOT-sufficient
# signal, NOT proof that main is healthy.
#
# MAIN_GREEN_CHECKS is sourced from the last-merged PR's PRE-MERGE
# check-runs, which ran against that PR merged with main-as-of-then —
# never the tree now on main (ci.yml has no push trigger, so main's
# squash commits carry no check-runs to read). main breaks here by
# SEMANTIC CONFLICT: two PRs green apart but broken together. In exactly
# that state the last-merged PR is green, this signal reads green, and
# the update would merge a currently-broken main into a healthy PR. The
# signal also inherits the last PR's matrix shape (a SKIPPED platform
# job is absent, so a PR stranded on it is never unstuck — fail-safe,
# but non-deterministic). The blast radius stays recoverable, not zero:
# the merge (not rebase) is revertible, a marker bounds re-updates to
# once per 2h, and the CAS (expected_head_sha) rejects a concurrent
# push. A re-enabled merge queue would let us source this from a
# genuinely validated merged tree instead: ci.yml DOES have a
# merge_group trigger, so a merged tree's check-runs would land where
# we could read them.
#
# Fetch main's head and that check-name set ONCE per scan: resolve
# main's head to the PR that produced it and read check-runs from that
# PR's head SHA.
MAIN_HEAD="$(gh api "repos/${REPO}/commits/main" --jq '.sha' 2> /dev/null || echo '')"
MAIN_GREEN_CHECKS='[]'
if [[ -n "${MAIN_HEAD}" ]]; then
MAIN_PR_HEAD="$(gh api "repos/${REPO}/commits/${MAIN_HEAD}/pulls" \
--jq '.[0].head.sha // ""' 2> /dev/null || echo '')"
if [[ -n "${MAIN_PR_HEAD}" ]]; then
MAIN_GREEN_CHECKS="$(gh api --paginate "repos/${REPO}/commits/${MAIN_PR_HEAD}/check-runs" \
--jq '[.check_runs[] | select(.conclusion == "success") | .name]' 2> /dev/null \
| jq -c -s 'add // [] | unique')" || MAIN_GREEN_CHECKS='[]'
fi
fi
# PRs whose review-address is already RUNNING OR QUEUED in any live
# autofix run must not be re-targeted. Schedule/dispatch runs execute
# against main's SHA, so their matrix jobs never appear in the PR's
# statusCheckRollup — and a fanned-out matrix holds queued jobs well
# past a 10-minute tick, so without this the next scan re-emits the
# same PRs and the per-PR address groups accumulate duplicates that
# later replay stale watermarks. The status filter is SERVER-side: a
# client-side filter over the N newest runs loses a long-lived
# fanned-out run once cron traffic pushes it past the window, and
# its queued PRs silently stop looking busy. Filtered this way the
# limit applies to LIVE runs only (at most a handful), and one
# jobs-view per live run stays cheap.
BUSY_PRS=' '
while IFS= read -r LIVE_RUN; do
[[ -z "${LIVE_RUN}" ]] && continue
while IFS= read -r BUSY; do
[[ -n "${BUSY}" ]] && BUSY_PRS="${BUSY_PRS}${BUSY} "
done < <(gh run view "${LIVE_RUN}" --repo "${REPO}" --json jobs \
--jq '.jobs[] | select(.status != "completed") | .name | capture("^review-address \\((?<pr>[0-9]+),") | .pr' 2> /dev/null)
done < <(
for LIVE_STATUS in in_progress queued; do
# || true: one status query failing must not hide the other. A
# DOUBLE failure yields an empty set — deliberately fail-open:
# this skip is an optimization, and the address-side live-marker
# revalidation is the correctness gate. If that revalidation is
# ever removed, this read must become fail-closed instead.
gh run list --repo "${REPO}" --workflow qwen-autofix.yml \
--status "${LIVE_STATUS}" --limit 50 --json databaseId \
--jq '.[].databaseId' 2> /dev/null || true
done | sort -u
)
[[ "${BUSY_PRS}" != ' ' ]] && echo "🚧 address in flight/queued for PR(s):${BUSY_PRS}"
# Idle backoff, from the list's own updatedAt (no API call): a
# candidate with no activity for >24h is inspected on about one
# scan in four instead of every one. The pool doubled in two
# days (28 takeover PRs, 8 of them idle in "nothing new" state
# for 10+ hours), and every idle inspection costs a unit of the
# SHARED MAX_CANDIDATE_INSPECTIONS budget plus a slice of the
# serial API walk over the candidate list. The win is small: a
# few fewer gh round-trips per scan (~2-3 of the pool) and less
# rate-limit pressure. It does NOT recover the job's queue or
# startup latency, which dwarfed the walk in the #8002
# measurement that motivated this. Idle PRs never reach the
# 10-target budget (the "nothing new" branch continues before
# the TARGETS append), so that cap is NOT what this relieves.
# Safe because comments, reviews, labels, and pushes all bump
# updatedAt or route in real time; the two scan-only signals
# that do NOT bump it — a base conflict appearing when main
# moves, and still-red checks awaiting the redcheck marker —
# wait out the backoff on a PR nobody touched in a day, then
# self-correct (the eventual address run comments/pushes). The
# slot is keyed by PR number mod 4 against a 600s time quantum
# (same quantum as ROT_OFF), so each scan is an independent
# ~25% draw per idle PR — about one scan in four. This is NOT a
# bounded gap: the scheduled scan lands every ~40-70 min on
# this repo (not the */10 the cron implies), so the wait is
# geometric — measured median ~2h, p90 ~6h across 100 real
# scans. The forced-dispatch path never builds the list files,
# so a forced PR is always inspected (fail-open, like a PR
# missing from the set).
IDLE_PRS=' '
if [[ -f "${WORKDIR}/bot-prs.json" && -f "${WORKDIR}/takeover-prs.json" ]]; then
IDLE_CUTOFF="$(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ)"
IDLE_PRS=" $(jq -rs --arg cut "${IDLE_CUTOFF}" 'add | unique_by(.number)
| map(select((.updatedAt // "") != "" and .updatedAt < $cut) | .number | tostring)
| join(" ")' \
"${WORKDIR}/bot-prs.json" "${WORKDIR}/takeover-prs.json" 2> /dev/null || echo '') "
fi
IDLE_SLOT_NOW="$(( ($(date -u +%s) / 600) % 4 ))"
TARGETS='[]'
INSPECTED=0
for PR in ${CANDIDATES}; do
# The busy skip is free (in-memory set) — it must not consume
# inspection budget.
if [[ "${BUSY_PRS}" == *" ${PR} "* ]]; then
echo "⏳ #${PR}: review-address already in flight or queued — skipping"
fleet_row "${PR}" 'busy' 'address run in flight'
continue
fi
# The idle-backoff skip is free too (a bash substring test on
# the precomputed set, same idiom as the busy skip) — it must
# not consume inspection budget either.
if [[ "${IDLE_PRS}" == *" ${PR} "* && "$(( PR % 4 ))" != "${IDLE_SLOT_NOW}" ]]; then
echo "😴 #${PR}: idle >24h — deferring to its rotation slot (slot $(( PR % 4 )), current ${IDLE_SLOT_NOW}; inspected ~1 scan in 4)"
fleet_row "${PR}" 'idle-backoff' 'idle >24h; inspected ~1 scan in 4 (median ~2h, p90 ~6h)'
continue
fi
INSPECTED=$(( INSPECTED + 1 ))
if [[ "${INSPECTED}" -gt "${MAX_CANDIDATE_INSPECTIONS}" ]]; then
echo "🧮 candidate-inspection budget (${MAX_CANDIDATE_INSPECTIONS}) reached — deferring the rest to the next scan"
fleet_row '-' 'deferred' "candidate-inspection budget (${MAX_CANDIDATE_INSPECTIONS}) reached — remaining candidates deferred to next scan"
break
fi
# One PR fetch for the branch name, check rollup, creation time
# (the watermark floor below), and labels (the effective round
# cap) — avoids extra round-trips per candidate PR.
PR_META="$(gh pr view "${PR}" --repo "${REPO}" \
--json headRefName,headRefOid,statusCheckRollup,createdAt,labels,isCrossRepository,headRepositoryOwner,headRepository 2> /dev/null || echo '{}')"
HEAD_REPO_FULL="${REPO}"
if [[ "$(jq -r '.isCrossRepository // false' <<< "${PR_META}")" == "true" ]]; then
HR_OWNER="$(jq -r '.headRepositoryOwner.login // ""' <<< "${PR_META}")"
HR_NAME="$(jq -r '.headRepository.name // ""' <<< "${PR_META}")"
# Component-wise: a deleted fork yields owner XOR name empty,
# which a joined '/' test would wave through into a red fetch.
if [[ -z "${HR_OWNER}" || -z "${HR_NAME}" ]]; then
echo "⚠️ #${PR}: fork head repository unresolved (owner='${HR_OWNER}' name='${HR_NAME}') — skipping"
fleet_row "${PR}" 'skipped' "fork head unresolved (owner='${HR_OWNER}' name='${HR_NAME}')"
continue
fi
HEAD_REPO_FULL="${HR_OWNER}/${HR_NAME}"
fi
BRANCH="$(jq -r '.headRefName // ""' <<< "${PR_META}")"
HAS_TAKEOVER="$(jq -r --arg t "${TAKEOVER_LABEL}" '[.labels[]?.name] | index($t) != null' <<< "${PR_META}")"
# The candidate snapshot filtered skip once, but this per-PR fetch
# is FRESHER — a skip label applied mid-scan must still win (the
# address job re-checks it live once more before any mutation).
if [[ "$(jq -r --arg t "${SKIP_LABEL}" '[.labels[]?.name] | index($t) != null' <<< "${PR_META}")" == "true" ]]; then
echo "⏭️ #${PR}: ${SKIP_LABEL} label present (applied mid-scan) — skipping"
fleet_row "${PR}" 'skipped' "${SKIP_LABEL} label present"
continue
fi
EFF_MAX_ROUNDS="${MAX_ROUNDS}"
[[ "${HAS_TAKEOVER}" == "true" ]] && EFF_MAX_ROUNDS="${TAKEOVER_MAX_ROUNDS}"
if [[ -z "${BRANCH}" ]]; then
# Metadata fetch failed (transient API error / rate limit). Skip rather
# than fall through with an empty branch, which would make the address
# job fail at `git checkout -B "" origin/` and post a misleading "could
# not start evaluation" handoff. Retried on the next scan. (This also
# means CREATED_WM below is only reached with a populated PR_META.)
echo "⚠️ #${PR}: could not fetch PR metadata (API error); skipping until next scan"
fleet_row "${PR}" 'unknown' 'PR metadata unreadable (API error)'
continue
fi
# Extract issue number: autofix/issue-<N> → N; otherwise use PR number.
if [[ "${BRANCH}" == "${BRANCH_PREFIX}"* ]]; then
ISSUE="${BRANCH#"${BRANCH_PREFIX}"}"
else
ISSUE="${PR}"
fi
CHECKS_JSON="$(jq -c '.statusCheckRollup // []' <<< "${PR_META}")"
PR_HEAD_OID="$(jq -r '.headRefOid // ""' <<< "${PR_META}")"
# Auto-rerun a check that died on INFRASTRUCTURE, not the code (see
# INFRA_FAILURE_SIGNATURES). Only reached when the PR has a FAILED
# check; then, for each, we read its annotations and — if they carry
# a machine-death signature — rerun that run's failed jobs ONCE. The
# once is enforced by run_attempt: a run already retried to attempt 2
# and still infra-failing is persistent, so we stop and leave it. No
# marker needed; the attempt counter is the guard, and after a rerun
# the attempt increments so the next scan skips it. Any API failure
# here is fail-safe: it just means no rerun.
if [[ -n "${PR_HEAD_OID}" ]] && jq -e 'any(.[]; ((.conclusion // .state // "") | IN("FAILURE","FAILED","ERROR","TIMED_OUT","ACTION_REQUIRED")) and (((.workflowName // "") != "Qwen Autofix") or ((.name // "") | startswith("review-address"))))' <<< "${CHECKS_JSON}" > /dev/null 2>&1; then
RERAN_INFRA=false
# Failed check-runs on this head, with their run id and annotation
# count — fetched once. External statuses (no check-run) are absent
# here, which is fine: only Actions runs can be reran.
FAILED_CRS="$(gh api --paginate "repos/${REPO}/commits/${PR_HEAD_OID}/check-runs" \
--jq '.check_runs[] | select(.conclusion == "failure")
| select(((.output.annotations_count // 0) > 0))
| [(.id | tostring), (.details_url // ""), (.name // "")] | @tsv' 2> /dev/null | sort -u || true)"
while IFS=$'\t' read -r CR_ID DETAILS_URL CR_NAME; do
[[ -n "${CR_ID}" && "${DETAILS_URL}" == *"/actions/runs/"* ]] || continue
RUN_ID="${DETAILS_URL#*/actions/runs/}"
RUN_ID="${RUN_ID%%/*}"
[[ "${RUN_ID}" =~ ^[0-9]+$ ]] || continue
ANNS="$(gh api --paginate "repos/${REPO}/check-runs/${CR_ID}/annotations" \
--jq '[.[].message] | join("\n")' 2> /dev/null || true)"
grep -qiE "${INFRA_FAILURE_SIGNATURES}" <<< "${ANNS}" || continue
RUN_META="$(gh api "repos/${REPO}/actions/runs/${RUN_ID}" --jq '[(.run_attempt // 0), (.name // "")] | @tsv' 2> /dev/null || printf '0\t')"
ATTEMPT="${RUN_META%%$'\t'*}"
WF_NAME="${RUN_META#*$'\t'}"
# Mirror the gate's self-trigger guard: skip Qwen Autofix runs
# unless the check is a review-address job.
if [[ "${WF_NAME}" == "Qwen Autofix" && "${CR_NAME}" != review-address* ]]; then
continue
fi
if [[ "${ATTEMPT}" == '1' ]]; then
if gh api -X POST "repos/${REPO}/actions/runs/${RUN_ID}/rerun-failed-jobs" > /dev/null 2>&1; then
echo "♻️ #${PR}: a check died on infrastructure (run ${RUN_ID}, attempt 1) — reran its failed jobs; not the PR's code"
fleet_row "${PR}" 'infra-reran' "infra failure (run ${RUN_ID}) reran once"
RERAN_INFRA=true
break
fi
else
echo "⚠️ #${PR}: infra failure persisted after a rerun (run ${RUN_ID}, attempt ${ATTEMPT}) — leaving for a human"
fi
done <<< "${FAILED_CRS}"
[[ "${RERAN_INFRA}" == 'true' ]] && continue
fi
# startedAt is the only staleness clock: a check blocks only if it
# started within the bound; one with no startedAt (queued, not yet
# running) is not blocking (the next scan re-checks once it starts).
HAS_PENDING_CHECKS="$(jq -r --arg cut "${PENDING_CUTOFF}" \
--argjson nonblocking "${NON_BLOCKING_CHECKS}" '
[ .[]
| select((.status // .state // "") | IN("QUEUED", "IN_PROGRESS", "PENDING", "WAITING", "REQUESTED"))
| select(((.workflowName // "") != "Qwen Autofix") or (((.name // "") | startswith("review-address"))))
| select((.name // "") as $n | ($nonblocking | index($n)) == null)
| select((.startedAt // $cut) > $cut) ]
| length > 0
' <<< "${CHECKS_JSON}")"
if [[ "${HAS_PENDING_CHECKS}" == "true" ]]; then
echo "⏳ #${PR}: active checks in flight; skipping until they finish (only checks stuck >${PENDING_STALE_MIN}m past their start are treated as dead and ignored)"
fleet_row "${PR}" 'waiting' 'active checks in flight'
continue
fi
# Pre-first-eval floor: the PR's IMMUTABLE creation time. Feedback
# cannot predate the PR, and unlike the head commit date this never
# advances when the branch is synced with main ("Update branch"/base
# merge), so an early base-sync merge cannot bury a comment made before
# the first eval. If the metadata query failed (empty), fall back to an
# EMPTY floor — over-inclusive (evaluates all feedback once, then the
# first eval writes a marker) but never buries. NEVER fall back to the
# mutable head commit date: a base-sync HEAD would recreate the burial.
CREATED_WM="$(jq -r '.createdAt // ""' <<< "${PR_META}")"
# PAGINATION NOTE: gh >= v2.31.0 merges all pages of a REST array
# endpoint into ONE flat JSON array (cli/cli#7190), so the WORKDIR
# files below are single arrays on every hosted runner. The jq
# normalizer pipes are retained as cheap defense-in-depth:
# idempotent over a flat array, and they would also normalize the
# per-page "[…][…]" shape that older gh versions emitted.
gh api "repos/${REPO}/issues/${PR}/comments" --paginate \
| jq -s 'add // []' > "${WORKDIR}/ic.json"
# First-pickup engage ack: fork label events carry no secrets and
# manual labels may race the ack job, so a takeover PR with NO
# engage ack yet gets one here (identity-verified) — it is also
# the round-window anchor. ic.json is re-fetched so THIS scan
# already counts under the fresh key. ORDERING IS LOAD-BEARING:
# ic.json for THIS candidate is fetched just above — reading a
# previous candidate's file would mis-dedup (spurious re-ack →
# window reset every scan), and a missing file would kill the
# whole scan step under -eo pipefail. Dedup is author-filtered
# (a forged human marker must not suppress the real ack), and a
# label application NEWER than the latest bot ack means a fresh
# engagement — post a fresh ack so the round window and cap
# reset as documented (re-arm), which no ack job can do for
# forks.
NEED_ENGAGE_ACK='false'
if [[ "${HAS_TAKEOVER}" == "true" ]]; then
LAST_ENGAGE_ACK_TS="$(jq -rs --arg ab "${AUTOFIX_BOT}" '
add | [.[] | select((.user.login // "") == $ab)
| select(.body // "" | contains("<!-- takeover-ack engaged -->"))
| .created_at] | sort | last // ""' "${WORKDIR}/ic.json")"
gh api "repos/${REPO}/issues/${PR}/events" --paginate 2> /dev/null \
| jq -s 'add // []' > "${WORKDIR}/pr-events.json" \
|| echo '[]' > "${WORKDIR}/pr-events.json"
LAST_LABELED_TS="$(jq -rs --arg lb "${TAKEOVER_LABEL}" '
add | [.[] | select(.event == "labeled")
| select((.label.name // "") == $lb)
| .created_at] | sort | last // ""' "${WORKDIR}/pr-events.json")"
if [[ -z "${LAST_ENGAGE_ACK_TS}" ]]; then
NEED_ENGAGE_ACK='true'
# Grace windows keyed by WHO owns the missing ack, read from
# the label event's actor (pr-events.json is already here).
# A bot-applied label came from takeover-command, which posts
# the ack itself within seconds — fork or in-repo alike — so
# a SHORT grace covers the write's own latency and an
# ic.json snapshot taken between the label write and the ack
# landing; past it, the command's post failed and the next
# scheduled scan heals it (≤10 min), instead of waiting on
# a label event that may never arrive. A human-applied
# in-repo label is owned by the
# DEDICATED ack job, which needs job-spin-up time — the
# longer grace stands. A human-labeled fork has no other
# owner, so no grace: the scan posts right here.
LAST_LABELED_BY="$(jq -rs --arg lb "${TAKEOVER_LABEL}" '
add | [.[] | select(.event == "labeled")
| select((.label.name // "") == $lb)]
| sort_by(.created_at) | last | .actor.login // ""' "${WORKDIR}/pr-events.json")"
if [[ "${LAST_LABELED_BY}" == "${AUTOFIX_BOT}" ]]; then
if [[ -n "${LAST_LABELED_TS}" && "${LAST_LABELED_TS}" > "$(date -u -d '45 seconds ago' +%Y-%m-%dT%H:%M:%SZ)" ]]; then
echo "🧭 engage ack deferred for #${PR}: command-applied label <45s ago — the command's own ack is in flight"
NEED_ENGAGE_ACK='false'
fi
elif [[ "$(jq -r '.isCrossRepository // false' <<< "${PR_META}")" != "true" ]] \
&& [[ -n "${LAST_LABELED_TS}" && "${LAST_LABELED_TS}" > "$(date -u -d '3 minutes ago' +%Y-%m-%dT%H:%M:%SZ)" ]]; then
echo "🧭 engage ack deferred for #${PR}: in-repo label applied <3m ago — the ack job owns it"
NEED_ENGAGE_ACK='false'
fi
else
if [[ -n "${LAST_LABELED_TS}" && "${LAST_LABELED_TS}" > "${LAST_ENGAGE_ACK_TS}" ]]; then
NEED_ENGAGE_ACK='true'
fi
fi
fi
if [[ "${NEED_ENGAGE_ACK}" == "true" && "${DRY_RUN}" == "true" ]]; then
echo "🧪 DRY-RUN: would post engage ack on #${PR} (window key untouched)"
NEED_ENGAGE_ACK='false'
fi
if [[ "${NEED_ENGAGE_ACK}" == "true" ]]; then
if [[ -z "${SCAN_BOT_ACTOR:-}" ]]; then
SCAN_BOT_ACTOR="$(gh api user --jq '.login' 2> /dev/null || echo 'unknown')"
fi
if [[ "${SCAN_BOT_ACTOR}" == "${AUTOFIX_BOT}" ]]; then
if gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. Remove the `%s` label (or comment `%s stop`) to release.\n\n<details>\n<summary>中文说明</summary>\n\n🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。移除 `%s` 标签(或评论 `%s stop`)即可释放。\n\n</details>\n\n<!-- takeover-ack engaged -->' "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}")"; then
# Atomic re-fetch: ic.json already holds a successful full
# fetch from above; a truncated stream must not leave it 0
# bytes (jq -s fails only AFTER the redirect truncates) —
# MARKERS on an empty file exits 0 with '' and the round
# cap silently resets. Empty history must still never
# masquerade as "no markers", so swap in the fresh copy
# only when the whole pipeline succeeded.
if gh api "repos/${REPO}/issues/${PR}/comments" --paginate \
| jq -s 'add // []' > "${WORKDIR}/ic.next.json"; then
mv "${WORKDIR}/ic.next.json" "${WORKDIR}/ic.json"
else
echo "::warning::ic re-fetch after engage ack failed for #${PR}; window anchors on a later scan"
fi
else
echo "::warning::first-pickup engage ack failed for #${PR}; window anchors on a later scan"
fi
else
echo "::warning::engage ack skipped: PAT authenticates as '${SCAN_BOT_ACTOR}'"
fi
fi
# Eval markers the bot left after a previous evaluation carry the
# newest feedback timestamp it already considered, plus the round.
# Only our own comments are trusted, so a spoofed marker is ignored.
# NOTE: this regex is POSITIONAL — group .[0]=ts, .[2]=round — and must
# match the marker string emitted at every write site verbatim (search
# `autofix-eval ts=`: the push/report success, noop, and handoff steps).
# Inserting or reordering a field here or at any write site silently
# corrupts round tracking; keep the `ts= acted= round=` order in lockstep.
MARKERS="$(jq -c --arg ab "${AUTOFIX_BOT}" '
[ .[] | select((.user.login // "") == $ab) | . as $c | ($c.body // "")
| [ scan("<!-- autofix-eval ts=([^ ]+) acted=([^ ]+) round=([0-9]+)(?: win=([^ ]+))? -->") ] | .[]
| {ts: .[0], round: (.[2] | tonumber), win: (.[3] // "none"), at: ($c.created_at // "")} ]' "${WORKDIR}/ic.json")"
# The watermark is GLOBAL across all markers — feedback already
# evaluated stays evaluated, no matter how counting windows move.
# The terminal-handoff SENTINEL ts is excluded: it is a flag, not
# an evaluation time, and letting it into the watermark would
# filter all future feedback forever — making a re-arm after a
# terminal handoff dead on arrival (terminal skipping itself is
# round-based and window-scoped, so re-arm properly clears it).
REARM_AT="$(jq -r --arg ab "${AUTOFIX_BOT}" '
[ .[] | select((.user.login // "") == $ab)
| select((.body // "") | contains("<!-- autofix-rearm -->"))
| .created_at ] | max // ""' "${WORKDIR}/ic.json")"
# The head this PR's red checks were last reported against. Carried
# as its OWN marker inside the eval comment rather than a new field
# on autofix-eval, so none of the ts/acted/round parsers change —
# and the comment still matches the eval filter, so the agent never
# sees it as feedback.
RED_HEAD="$(jq -r --arg ab "${AUTOFIX_BOT}" '
[ .[] | select((.user.login // "") == $ab) | . as $c | ($c.body // "")
| [ scan("<!-- autofix-redcheck head=([0-9a-f]+) -->") ] | .[]
| {sha: .[0], at: ($c.created_at // "")} ]
| sort_by(.at) | last | .sha // ""' "${WORKDIR}/ic.json")"
# A maintainer '@qwen-code /retry' posts that marker to say
# "evaluate this feedback again": markers written BEFORE it stop
# holding the watermark down. This is the sanctioned exception to
# the global rule above, and it replaces the old recovery -
# deleting the bot's marker comment by hand, which erased the
# audit trail and needed raw API access to do at all.
EVAL_WM="$(jq -r --arg rearm "${REARM_AT}" '
map(select($rearm == "" or (.at > $rearm)))
| map(.ts) | map(select(. != "9999-12-31T23:59:59Z")) | max // ""' <<< "${MARKERS}")"
# ROUND counting is windowed by KEY EQUALITY, not timestamps: the
# current window key is the created_at of the latest
# '<!-- takeover-ack engaged -->' comment ('none' before any
# takeover), every marker records the key of the window it was
# produced in (win=…, legacy markers count as 'none'), and only
# markers of the CURRENT window count toward the cap. Timestamp
# windowing would race an in-flight address job selected before a
# re-arm: its marker lands AFTER the ack and would instantly
# re-cap the fresh window — key equality cannot. Within a window
# the highest round wins (a terminal handoff marker must make the
# scan skip regardless of order).
REARM_KEY="$(jq -r --arg ab "${AUTOFIX_BOT}" '
[ .[] | select((.user.login // "") == $ab)
| select(((.body // "") | contains("<!-- takeover-ack engaged -->"))
or ((.body // "") | contains("<!-- autofix-rearm -->")))
| .created_at ] | max // "none"' "${WORKDIR}/ic.json")"
ROUND="$(jq -r --arg key "${REARM_KEY}" 'map(select(.win == $key)) | map(.round) | max // 0' <<< "${MARKERS}")"
# Effective watermark = what the agent has actually evaluated (its last
# eval marker's newest-feedback timestamp), NOT the last push. A bot
# fix always writes a marker, so a real fix advances this; a base-sync
# "Merge branch 'main'" push (or any commit that did not evaluate
# feedback) does NOT, so it can never bury unaddressed maintainer
# comments under the watermark. Before the first evaluation there is no
# marker, so fall back to the PR creation floor.
if [[ -n "${EVAL_WM}" ]]; then
EFF_WM="${EVAL_WM}"
else
EFF_WM="${CREATED_WM}"
fi
if [[ "${ROUND}" -ge "${EFF_MAX_ROUNDS}" ]]; then
echo "🚧 #${PR}: hit the round cap (${ROUND}/${EFF_MAX_ROUNDS}) — leaving for a human"
fleet_row "${PR}" 'round-capped' "round ${ROUND}/${EFF_MAX_ROUNDS} - needs a human or @qwen-code /retry"
# A FORCED dispatch refused here answers OUT LOUD. Observed on
# #7836: the fleet shepherd detected a merge conflict, posted
# "dispatched the autofix loop to resolve it", and the dispatch
# died right here with only the log line above — the PR page
# showed a promise, the run showed green, and the conflict sat
# unhandled for hours. The shepherd also dedups per head SHA,
# and a capped PR gets no pushes, so its head never changes:
# silence here freezes conflict handling until a human notices
# by accident. Gate on workflow_dispatch — that is the explicit
# dispatch lever (the shepherd's `gh workflow run` or a human).
# FORCED_PR is ALSO set for every trusted pull_request_review
# (route emits pr_number for those), which is not an explicit
# dispatch: answering each one here spammed 7 refusals on
# #7836, so review submissions stay covered by the
# once-per-window pause notice below. No dedup on the dispatch
# itself: the shepherd sends at most one per head, and a human
# asking twice deserves two answers.
if [[ -n "${FORCED_PR}" && "${FORCED_PR}" == "${PR}" && "${EVENT_NAME}" == 'workflow_dispatch' ]]; then
if [[ "${DRY_RUN}" == "true" ]]; then
echo "🧪 DRY-RUN: would post cap-refused notice on #${PR}"
else
if [[ -z "${SCAN_BOT_ACTOR:-}" ]]; then
SCAN_BOT_ACTOR="$(gh api user --jq '.login' 2> /dev/null || echo 'unknown')"
fi
if [[ "${SCAN_BOT_ACTOR}" != "${AUTOFIX_BOT}" ]]; then
echo "::warning::cap-refused notice skipped: PAT authenticates as '${SCAN_BOT_ACTOR}', expected ${AUTOFIX_BOT}"
elif ! gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '⏸️ Dispatch refused: this PR has exhausted its automatic round cap (%s/%s), so the loop will not touch it — whatever triggered this dispatch (a merge conflict, new feedback) stays unhandled. Comment `%s` to re-arm a fresh window, or `%s` for the raised takeover cap; the next scheduled scan then picks it up.\n\n<details>\n<summary>中文说明</summary>\n\n⏸️ 已拒绝本次调度:本 PR 的自动轮次上限已用完(%s/%s),循环不会介入——触发本次调度的事项(合并冲突、新反馈)仍未处理。评论 `%s` 可重置计数窗口,或 `%s` 获得更高的接管上限;随后下一次定时扫描会接手。\n\n</details>\n\n<!-- takeover-cap-refused -->' "${ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}" "${ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}")"; then
echo "::warning::cap-refused notice failed for #${PR}"
fi
fi
fi
# A MANAGED PR pausing at its cap deserves a visible reminder —
# maintainers otherwise learn about it only from workflow logs.
# ALL managed PRs, not just takeover: the takeover-only gate
# left standard bot PRs capping in silence (#7836 hit 10/10
# with zero PR-visible notice), which is the root of the
# frozen-conflict chain above. Once per counting window:
# re-arming opens a fresh window and, if the cap is hit again,
# a fresh reminder. A failed post retries naturally on the
# next scan (marker still absent).
# Dedup boundary = the current window key; with no engage ack
# or re-arm yet (key 'none') fall back to LIFETIME dedup —
# created_at is never > 'none' lexically, which would flip
# this into posting every scan.
NOTICE_RT="${REARM_KEY}"
[[ "${NOTICE_RT}" == "none" ]] && NOTICE_RT=''
CAP_NOTICED="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg rt "${NOTICE_RT}" '
[ .[] | select((.user.login // "") == $ab)
| select((.body // "") | contains("<!-- takeover-cap-reached -->"))
| select((.created_at // "") > $rt) ] | length' "${WORKDIR}/ic.json")"
if [[ "${DRY_RUN}" == "true" ]]; then
echo "🧪 DRY-RUN: would post cap-paused notice on #${PR}"
elif [[ "${CAP_NOTICED}" == "0" ]]; then
# Consent may have moved since PR_META: skip wins everywhere,
# and a takeover notice additionally requires the label to
# still be present — a label removed (or skip added) moments
# ago must not receive a stale 'paused' notice.
LIVE_LABELS="$(gh pr view "${PR}" --repo "${REPO}" --json labels 2> /dev/null | jq -r '[.labels[]?.name] | join(" ")' || echo '')"
if [[ " ${LIVE_LABELS} " == *" ${SKIP_LABEL} "* ]] \
|| [[ "${HAS_TAKEOVER}" == "true" && " ${LIVE_LABELS} " != *" ${TAKEOVER_LABEL} "* ]]; then
echo "🧭 cap notice skipped: consent changed since the snapshot (labels: ${LIVE_LABELS:-unreadable})"
continue
fi
# Convention: verify the PAT identity before ANY write. A
# rotated PAT would post under a foreign login the dedup
# (which counts AUTOFIX_BOT comments only) can never see —
# reposting the notice every scan. Memoized per scan run.
if [[ -z "${SCAN_BOT_ACTOR:-}" ]]; then
SCAN_BOT_ACTOR="$(gh api user --jq '.login' 2> /dev/null || echo 'unknown')"
fi
if [[ "${SCAN_BOT_ACTOR}" != "${AUTOFIX_BOT}" ]]; then
echo "::warning::cap-paused notice skipped: PAT authenticates as '${SCAN_BOT_ACTOR}', expected ${AUTOFIX_BOT}"
else
if [[ "${HAS_TAKEOVER}" == "true" ]]; then
CAP_BODY="$(printf '⏸️ Takeover paused: this PR reached its round cap (%s/%s). Comment `%s` to re-arm a fresh window and continue management, or `%s stop` to release.\n\n<details>\n<summary>中文说明</summary>\n\n⏸️ 托管已暂停:本 PR 达到轮次上限(%s/%s)。评论 `%s` 可重新武装、开启新窗口继续托管;或评论 `%s stop` 释放。\n\n</details>\n\n<!-- takeover-cap-reached -->' "${ROUND}" "${EFF_MAX_ROUNDS}" "${TAKEOVER_COMMAND}" "${TAKEOVER_COMMAND}" "${ROUND}" "${EFF_MAX_ROUNDS}" "${TAKEOVER_COMMAND}" "${TAKEOVER_COMMAND}")"
else
CAP_BODY="$(printf '⏸️ AutoFix paused: this PR reached its automatic round cap (%s/%s) and the loop will not manage it further — new feedback and base conflicts stay unhandled. Comment `%s` to re-arm a fresh window under the same cap, or `%s` to take it over with the raised cap.\n\n<details>\n<summary>中文说明</summary>\n\n⏸️ AutoFix 已暂停:本 PR 达到自动轮次上限(%s/%s),循环不再管理——新反馈与 base 冲突将无人处理。评论 `%s` 可在同一上限下重置计数窗口,或评论 `%s` 以更高上限接管。\n\n</details>\n\n<!-- takeover-cap-reached -->' "${ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}" "${ROUND}" "${EFF_MAX_ROUNDS}" "${RETRY_COMMAND}" "${TAKEOVER_COMMAND}")"
fi
if ! gh pr comment "${PR}" --repo "${REPO}" --body "${CAP_BODY}"; then
echo "::warning::cap-paused notice failed for #${PR}; will retry next scan"
fi
fi
fi
continue
fi
# Auto-update a PR that is red ONLY because of a stale base (see the
# MAIN_GREEN_CHECKS rationale above). The gate: the failing check also
# passed for the PR that produced current main (a necessary-but-NOT-
# sufficient signal — NOT proof main is healthy), and the PR is behind
# or diverged, so it actually carries a stale base. Runs after the
# round cap and pending-checks gates but before the feedback logic,
# because a stuck-on-stale-base PR often has no NEW feedback at all (it
# just sits red), which is exactly #7490's case.
if [[ -n "${MAIN_HEAD}" && -n "${PR_HEAD_OID}" ]]; then
# STALE_BASE_REDS is pure jq over data already in memory
# (CHECKS_JSON, MAIN_GREEN_CHECKS) — free, and far more selective
# than the compare round-trip. Compute it FIRST and skip the network
# call entirely when there is no stale-base red to act on (the common
# case: a green PR, or one whose red check is also red on main).
# CANCELLED is deliberately omitted from the PR-side selector: a
# cancelled check is not evidence of a stale base. External commit
# statuses are also excluded: a StatusContext exposes .context, not
# .name/.workflowName, so it yields "" and select(. != "") drops it
# (conservative — only Actions check-runs are matched).
STALE_BASE_REDS="$(jq -c -n \
--argjson checks "${CHECKS_JSON}" --argjson green "${MAIN_GREEN_CHECKS}" '
[ $checks[]
| select((.conclusion // .state // "") | IN("FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED"))
| select((.workflowName // "") != "Qwen Autofix")
| (.name // .workflowName // "")
| select(. != "" and (. as $n | $green | index($n))) ]')" || STALE_BASE_REDS='[]'
if [[ "${STALE_BASE_REDS}" != '[]' ]]; then
# --jq '.status': the compare document is ~60KB; only the
# behind/diverged/ahead status is needed.
CMP_STATUS="$(gh api "repos/${REPO}/compare/${MAIN_HEAD}...${PR_HEAD_OID}" --jq '.status // ""' 2> /dev/null || echo '')"
if [[ "${CMP_STATUS}" == 'behind' || "${CMP_STATUS}" == 'diverged' ]]; then
# Repetition guard: a marker comment bounds re-updates to
# once per 2 hours (see BASE_UPDATE_CUTOFF, hoisted above the
# loop). Without this, a still-red PR would be re-updated on
# every scan after main advances.
BASE_UPDATE_RECENT="$(jq -r --arg ab "${AUTOFIX_BOT}" \
--arg cutoff "${BASE_UPDATE_CUTOFF}" '
[ .[] | select((.user.login // "") == $ab)
| select((.body // "") | contains("<!-- autofix-base-updated -->"))
| select((.created_at // "") > $cutoff) ] | length > 0' "${WORKDIR}/ic.json")"
if [[ "${BASE_UPDATE_RECENT}" != "true" ]]; then
RED_NAMES="$(jq -r 'join(", ")' <<< "${STALE_BASE_REDS}")"
if [[ "${DRY_RUN}" == "true" ]]; then
echo "🧪 DRY-RUN: would update stale base on #${PR} (red [${RED_NAMES}] green on main ${MAIN_HEAD:0:9})"
fleet_row "${PR}" 'dry-run-base' "would merge main (stale-base red [${RED_NAMES}])"
continue
fi
# Convention: verify the PAT identity before ANY write (same
# as the engage ack and cap notice above). update-branch AND
# its marker are writes; a rotated PAT would do both under a
# foreign login the dedup (which counts AUTOFIX_BOT comments
# only) can never see — re-updating every scan. Memoized.
if [[ -z "${SCAN_BOT_ACTOR:-}" ]]; then
SCAN_BOT_ACTOR="$(gh api user --jq '.login' 2> /dev/null || echo 'unknown')"
fi
if [[ "${SCAN_BOT_ACTOR}" != "${AUTOFIX_BOT}" ]]; then
echo "::warning::#${PR}: stale-base update skipped: PAT authenticates as '${SCAN_BOT_ACTOR}', expected ${AUTOFIX_BOT}"
fleet_row "${PR}" 'base-update-skipped' "stale-base red [${RED_NAMES}] but PAT identity '${SCAN_BOT_ACTOR}' != ${AUTOFIX_BOT}"
# expected_head_sha makes this a compare-and-swap: if the
# author pushed between our compare read and this call,
# GitHub rejects it rather than merging main into an
# unverified head.
elif UPDATE_ERR="$(gh api -X PUT "repos/${REPO}/pulls/${PR}/update-branch" -f expected_head_sha="${PR_HEAD_OID}" 2>&1 >/dev/null)"; then
echo "🔀 #${PR}: red check(s) [${RED_NAMES}] pass on current main ${MAIN_HEAD:0:9} — merged main in via update-branch; CI will re-run"
fleet_row "${PR}" 'base-updated' "stale-base red [${RED_NAMES}] — merged current main, CI re-running"
# The marker is the ONLY repetition guard for this mutating
# action; a failed post must be loud, not swallowed, so the
# dedup gap is visible (else the next scan re-updates).
gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '🔀 Base updated: red check(s) [%s] pass on current main — merged current main via update-branch; CI will re-run.\n\n<details>\n<summary>中文说明</summary>\n\n🔀 已更新 base:红色检查 [%s] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。\n\n</details>\n\n<!-- autofix-base-updated -->' "${RED_NAMES}" "${RED_NAMES}")" > /dev/null 2>&1 \
|| echo "::warning::#${PR}: base-updated marker post failed — the 2h repetition guard is NOT armed for this update"
continue
else
echo "⚠️ #${PR}: wanted to update the stale base (red [${RED_NAMES}] green on main) but update-branch failed: ${UPDATE_ERR:-unknown error}"
fleet_row "${PR}" 'base-update-failed' "stale-base red [${RED_NAMES}] but update-branch failed"
# A failed update (merge conflict, CAS rejection, or
# missing allow-edits) is a human problem, but the bot's
# review comments need not be deferred forever — fall
# through to feedback processing.
fi
fi
fi
fi
fi
N_FAILED_CHECKS="$(jq --arg wm "${EFF_WM}" '
[ .[]
| select((.conclusion // .state // "") | IN("FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED", "CANCELLED"))
| select(((.workflowName // "") != "Qwen Autofix") or (((.name // "") | startswith("review-address"))))
| select((.completedAt // .updatedAt // "") > $wm) ]
| length
' <<< "${CHECKS_JSON}")"
# A red check is a persistent STATE, not the instant it turned red.
# Counting only "failed since the watermark" made a still-failing PR
# invisible the moment the watermark passed the failure: measured on
# #6451 (3 reds, all completed 09:30-09:51, watermark 10:55),
# #7357 (red 07:59, watermark 09:18) and #7390 (red and watermark
# both 11:27:37, so a strict `>` hid it the instant it appeared) —
# all three sat red for hours while every scan logged "nothing new".
#
# So: a currently-red check counts as feedback until the head it ran
# against has been evaluated. The address job records the head it
# reported on; a PR whose recorded head still matches is left alone,
# which bounds this to ONE look per head instead of every scan.
# Empty LIVE_HEAD → N_RED_NOW stays 0: fail-closed (no head → cannot judge → do not act),
# unlike the recording side where an empty REPORT_HEAD keeps reds visible.
LIVE_HEAD="$(jq -r '.headRefOid // ""' <<< "${PR_META}")"
N_RED_NOW=0
if [[ -n "${LIVE_HEAD}" && "${RED_HEAD}" != "${LIVE_HEAD}" ]]; then
N_RED_NOW="$(jq '
[ .[]
| select((.conclusion // .state // "") | IN("FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED"))
| select(((.workflowName // "") != "Qwen Autofix") or (((.name // "") | startswith("review-address")))) ]
| length
' <<< "${CHECKS_JSON}")"
fi
gh api "repos/${REPO}/pulls/${PR}/reviews" --paginate \
| jq -s 'add // []' > "${WORKDIR}/rv.json"
gh api "repos/${REPO}/pulls/${PR}/comments" --paginate \
| jq -s 'add // []' > "${WORKDIR}/rc.json"
N_REVIEWS="$(jq --arg wm "${EFF_WM}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \
--argjson trust "${TRUSTED_ASSOC}" '
[ .[]
| select((.submitted_at // "") > $wm)
| select((.user.login // "") != $ab)
| select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb)
| select((.state // "") | IN("CHANGES_REQUESTED", "COMMENTED")) ] | length' \
"${WORKDIR}/rv.json")"
# Per AGENTS.md's review policy, Suggestion-level findings are
# actionable during the initial change-producing rounds; takeover
# extends that window. The scan still selects later feedback so
# prepare can record and watermark its deferral; only the address
# job filters what may drive code changes.
N_COMMENTS="$(jq --arg wm "${EFF_WM}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \
--argjson trust "${TRUSTED_ASSOC}" '
[ .[]
| select((.created_at // "") > $wm)
| select((.user.login // "") != $ab)
| select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb) ] | length' \
"${WORKDIR}/rc.json")"
# Issue-level PR comments are also actionable feedback. Exclude the
# bot's own eval markers, and known non-actionable bot comments
# (triage stages, coverage reports, legacy suggestion summaries,
# force-push reminders).
BOT_COMMENT_FILTER='<!-- (autofix-eval|autofix-rearm|autofix-base-updated|autofix-milestone|qwen-triage|qwen-review-suggestion-summary|pr-force-push|qwen-review-ack) '
# Command-style comments (@qwen-code /takeover, /triage, /review …)
# are INSTRUCTIONS to tooling, not review feedback on the diff:
# counting them as actionable burns a full agent cycle to post a
# no-action report about the command itself (and the takeover
# command would self-trigger such a round on every engagement).
COMMAND_FILTER='^\s*@qwen-code /'
N_ISSUE_COMMENTS="$(jq --arg wm "${EFF_WM}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \
--argjson trust "${TRUSTED_ASSOC}" --arg bf "${BOT_COMMENT_FILTER}" --arg cf "${COMMAND_FILTER}" '
[ .[]
| select((.created_at // "") > $wm)
| select((.user.login // "") != $ab)
| select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb)
| select((.body // "") | test($bf) | not)
| select((.body // "") | test($cf) | not) ] | length' \
"${WORKDIR}/ic.json")"
# mergeable: GitHub may report UNKNOWN until it recomputes; treat only
# an explicit CONFLICTING as a conflict so we never block on UNKNOWN.
MERGEABLE="$(gh pr view "${PR}" --repo "${REPO}" --json mergeable --jq '.mergeable' 2> /dev/null || echo 'UNKNOWN')"
HAS_CONFLICT='false'
if [[ "${MERGEABLE}" == "CONFLICTING" ]]; then HAS_CONFLICT='true'; fi
if [[ "${N_REVIEWS}" -eq 0 && "${N_COMMENTS}" -eq 0 && "${N_ISSUE_COMMENTS}" -eq 0 && "${N_FAILED_CHECKS}" -eq 0 && "${N_RED_NOW}" -eq 0 && "${HAS_CONFLICT}" != "true" ]]; then
echo "✅ #${PR}: nothing new since ${EFF_WM} (conflict=${HAS_CONFLICT})"
fleet_row "${PR}" 'idle' "nothing new since ${EFF_WM} (round ${ROUND}/${EFF_MAX_ROUNDS}, conflict=${HAS_CONFLICT})"
continue
fi
echo "🔎 #${PR}: ${N_REVIEWS} review(s) + ${N_COMMENTS} inline + ${N_ISSUE_COMMENTS} issue comment(s) + ${N_FAILED_CHECKS} failed check(s) new, ${N_RED_NOW} still-red on ${LIVE_HEAD:0:9}, conflict=${HAS_CONFLICT}, round=${ROUND}"
fleet_row "${PR}" 'SELECTED' "${N_REVIEWS} review + ${N_COMMENTS} inline + ${N_ISSUE_COMMENTS} comment + ${N_FAILED_CHECKS} failed-check new, ${N_RED_NOW} still-red (round ${ROUND}/${EFF_MAX_ROUNDS}, conflict=${HAS_CONFLICT})"
TARGETS="$(jq -c \
--arg pr "${PR}" --arg branch "${BRANCH}" --arg issue "${ISSUE}" \
--arg round "${ROUND}" --arg wm "${EFF_WM}" --arg mr "${EFF_MAX_ROUNDS}" \
--arg win "${REARM_KEY}" --arg hr "${HEAD_REPO_FULL}" \
'. + [{pr: $pr, branch: $branch, issue: $issue, round: $round, watermark: $wm, max_rounds: $mr, win: $win, head_repo: $hr}]' \
<<< "${TARGETS}")"
# Fan out: emit EVERY eligible PR up to the per-scan budget. The
# address matrix bounds simultaneity (max-parallel) and the per-PR
# concurrency groups plus the busy-PR skip above prevent duplicate
# same-PR runs, so one scan drains the whole backlog instead of
# serving a single newest-first target per tick (which starved
# older PRs for hours when cron ticks were sparse). The budget
# break bounds this loop's RUNTIME and API usage too — each
# candidate costs several serial API reads, so scanning past a
# full budget would spend hundreds of calls for nothing. Never a
# silent cap: the deferral is logged and the next scan picks up
# the remainder (their signals persist).
if [[ "$(jq 'length' <<< "${TARGETS}")" -ge "${MAX_TARGETS_PER_SCAN}" ]]; then
echo "⚠️ target budget (${MAX_TARGETS_PER_SCAN}) reached; deferring the remaining candidates to the next scan"
fleet_row '-' 'deferred' "target budget (${MAX_TARGETS_PER_SCAN}) reached — remaining candidates deferred to next scan"
break
fi
done
COUNT="$(jq 'length' <<< "${TARGETS}")"
echo "📋 ${COUNT} PR(s) to process"
{
echo "### 🤖 AutoFix fleet (${COUNT} selected this scan)"
echo
echo '| PR | State | Detail |'
echo '| --- | --- | --- |'
if [[ -s "${FLEET_FILE}" ]]; then
while IFS=$'\t' read -r f_pr f_state f_detail; do
f_detail="${f_detail//|/\\|}"
if [[ "${f_pr}" == '-' ]]; then
printf '| — | %s | %s |\n' "${f_state}" "${f_detail}"
else
printf '| #%s | %s | %s |\n' "${f_pr}" "${f_state}" "${f_detail}"
fi
done < "${FLEET_FILE}"
else
echo '| - | no managed PRs inspected | - |'
fi
} >> "${GITHUB_STEP_SUMMARY}"
echo "targets=${TARGETS}" >> "${GITHUB_OUTPUT}"
echo "has_targets=$([[ "${COUNT}" -gt 0 ]] && echo true || echo false)" >> "${GITHUB_OUTPUT}"
# ===========================================================================
# REVIEW PHASE (build) — compile the trusted-base CLI bundle ONCE per scan
# and fan it out to the address legs as an artifact. Each leg otherwise
# repeated the same base build: measured 3.5-5 minutes of npm ci + build +
# bundle per leg (6 legs ≈ 25 runner-minutes on one 2026-08 scan) before
# the agent could start. Gated on has_targets so an idle tick builds
# nothing — the issue phase runs only when there are no review targets, so
# gating on do_issue too would rebuild on every quiet scheduled tick.
# fetch-depth 1 suffices: nothing here needs history, and the legs (full
# fetch) always contain this commit as an ancestor of their base fetch.
# ===========================================================================
build-cli:
needs: ['route', 'review-scan']
if: |-
${{ needs.review-scan.outputs.has_targets == 'true' }}
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event_name != ''pull_request'' && github.event_name != ''pull_request_review'' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON(''["OWNER","MEMBER","COLLABORATOR"]''), github.event.pull_request.author_association))) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}'
timeout-minutes: 30
permissions:
contents: 'read'
outputs:
base_sha: '${{ steps.meta.outputs.base_sha }}'
steps:
# Self-hosted runners reuse the workspace; a prior containerised job
# can leave root-owned, read-only files anywhere in it. Restore
# ownership and write permission unconditionally before checkout.
- name: 'Restore workspace ownership'
run: |-
set -uo pipefail
RUNNER_UID="$(id -u)"
RUNNER_GID="$(id -g)"
if [ "$RUNNER_UID" != "0" ]; then
chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files"
fi
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files"
# Same sweep as the agent jobs: this checkout runs in a workspace
# other pool jobs have written, and a surviving filter.*.smudge or
# hook fires during it — in the job that builds the CLI bundle every
# review-address leg then executes with a PAT in env.
- name: 'Sanitize workspace git config'
run: |-
set -uo pipefail
# `.git` is a directory in a normal checkout but a gitlink file in
# a worktree; -e covers both, and a missing .git (first run) too.
if [ ! -e .git ]; then
echo "no prior workspace; nothing to sanitize"
exit 0
fi
# Worktree-scoped config FIRST: `extensions.worktreeConfig=true` is
# on the allowlist below (it carries no command itself), but it
# activates `.git/config.worktree` — a second config file that
# `git config --local` neither lists nor unsets, and that CAN carry
# core.hooksPath. Verified in qwen-triage: a prior run can set
# `--worktree core.hooksPath=/`, survive the sweep untouched, and
# make the hooks deletion below walk /. Delete the file outright,
# then drop the extension.
rm -f "$(git rev-parse --git-path config.worktree 2>/dev/null || echo /nonexistent)" 2>/dev/null || true
git config --local --unset-all extensions.worktreeConfig 2>/dev/null || true
# Rather than denylist each exec-vector family (which kept missing
# new ones), KEEP a known-safe allowlist and --unset-all everything
# else: this closes the whole class, including knobs not yet
# enumerated. The kept set is only plumbing that carries no command
# — repo format, remote, branch, fetch/gc/pack/index, safe.directory,
# extensions, and submodule url/active/branch (NOT
# submodule.*.update, which can be `!cmd`). actions/checkout
# re-establishes remote/auth afterward. `|| true` on the grep: no
# non-allowlisted keys (the steady state on an already-sanitized
# runner) means grep exits 1, which would kill the step exactly
# when there is nothing to clean.
git config --local --name-only --list 2>/dev/null \
| { grep -ivE '^(core\.(repositoryformatversion|bare|filemode|symlinks|ignorecase|precomposeunicode|logallrefupdates|worktree|hidedotfiles|protecthfs|protectntfs)|remote\.[^.]+\.(url|fetch|pushurl)|branch\.|extensions\.|gc\.|pack\.|fetch\.|index\.|safe\.|submodule\.[^.]+\.(url|active|branch))' || true; } \
| while IFS= read -r key; do git config --local --unset-all "$key" 2>/dev/null || true; done
# Belt and braces after the config scrub: only delete inside the
# repository's own git dir. A hooks path resolving anywhere else is
# unlinked, never swept — a recursive delete of a planted path is
# far worse than a stale hook on a runner the pool re-cleans.
# Resolve hooks with global/system config OUT of the way. Verified
# in qwen-triage: with a global core.hooksPath set, `git rev-parse
# --git-path hooks` returns that path, the guard below sees
# "outside the git dir", and a planted `.git/hooks` symlink
# survives untouched. Keep this resolution AFTER the sweep above.
GIT_DIR_ABS="$(git rev-parse --absolute-git-dir 2>/dev/null || echo '')"
HOOKS_DIR="$(GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git rev-parse --git-path hooks 2>/dev/null || echo .git/hooks)"
HOOKS_ABS="$(cd "$HOOKS_DIR" 2>/dev/null && pwd -P || echo '')"
if [ -n "$GIT_DIR_ABS" ] && [ -n "$HOOKS_ABS" ] && [ "${HOOKS_ABS#"$GIT_DIR_ABS"/}" != "$HOOKS_ABS" ]; then
# Match -type f OR -type l: a symlinked hook survives a bare
# `-type f` sweep and still fires on the next checkout.
find "$HOOKS_ABS" \( -type f -o -type l \) ! -name '*.sample' -delete 2>/dev/null || true
else
# Resolves outside the git dir (or not at all). Warning and
# walking away would leave a live hook directory that the next
# git command executes, so unlink the ENTRY without descending
# into it and put an empty hooks directory back.
RAW_HOOKS="$(GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git rev-parse --git-path hooks 2>/dev/null || echo .git/hooks)"
echo "::warning::hooks path did not resolve inside the git dir (${HOOKS_ABS:-unresolved}); unlinking it."
rm -f "$RAW_HOOKS" 2>/dev/null || echo "::warning::refusing to recursively delete planted hooks path '$RAW_HOOKS' (a hooksPath resolving to the git dir itself would otherwise wipe .git); leaving it to the pool re-clean."
mkdir -p "${GIT_DIR_ABS:-.git}/hooks" 2>/dev/null || true
git config --local --unset-all core.hooksPath 2>/dev/null || true
fi
- name: 'Checkout trusted base'
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: '${{ github.event.repository.default_branch }}'
fetch-depth: 1
persist-credentials: false
- name: 'Set up Node.js'
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22.x'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
# The npm-ci retry recipe and the 'Prepare Qwen Code CLI' shim below
# are duplicated in issue-autofix and review-address; the workflow
# contract tests pin every copy in lockstep — edit them together.
- name: 'Install dependencies and build'
env:
QWEN_SKIP_PREPARE: '1'
run: |-
for attempt in 1 2 3; do
if npm ci --prefer-offline --no-audit --progress=false; then
break
fi
if [[ "${attempt}" == "3" ]]; then
exit 1
fi
sleep $((attempt * 15))
done
npm run build
npm run bundle
# Producer-side sanity: a bundle that cannot even answer --version
# fails THIS job once, instead of every leg failing its restore or
# first qwen invocation minutes later and N times over.
- name: 'Prepare Qwen Code CLI'
run: |-
qwen_version="$(node -p "require('./package.json').version")"
echo "Using checked-out Qwen Code bundle ${qwen_version}"
qwen_bin="${RUNNER_TEMP}/qwen-bin"
mkdir -p "${qwen_bin}"
cat > "${qwen_bin}/qwen" <<'EOF'
#!/usr/bin/env bash
exec node "${GITHUB_WORKSPACE}/dist/cli.js" "$@"
EOF
chmod +x "${qwen_bin}/qwen"
echo "${qwen_bin}" >> "${GITHUB_PATH}"
PATH="${qwen_bin}:${PATH}"
qwen --version
# The repo-root dist/ plus packages/core/dist are shipped:
# copy_bundle_assets.js already gathers every runtime asset (chunks,
# vendor, web-shell, locales) under the root dist/, and the remaining
# packages/*/dist would triple the artifact size without ever being
# read by the legs — the verify gate's full `npm run build` wipes and
# rebuilds each package's dist from branch sources (build_package.js
# rms it first, so no staleness leaks through). packages/core/dist is
# the exception: the settings-schema check runs BEFORE any build (on
# every path, including no-action), and its generator — tsx run from
# the repo root, whose tsconfig has NO `paths` — imports cli sources
# that resolve '@qwen-code/qwen-code-core' through the workspace
# symlink to core's dist entry point. Without it the generator crashes
# with ERR_MODULE_NOT_FOUND and the gate misreports a deterministic
# "settings schema is stale" rejection. The i18n check needs no dist:
# it runs with cwd packages/cli, whose tsconfig `paths` map the
# specifier to core's sources instead.
- name: 'Upload CLI bundle'
id: 'meta'
run: |-
echo "base_sha=$(git rev-parse HEAD)" >> "${GITHUB_OUTPUT}"
tar -czf "${RUNNER_TEMP}/qwen-cli-dist.tar.gz" dist packages/core/dist
- name: 'Publish CLI bundle artifact'
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
with:
name: 'qwen-autofix-cli-dist'
path: '${{ runner.temp }}/qwen-cli-dist.tar.gz'
retention-days: 1
if-no-files-found: 'error'
# ===========================================================================
# REVIEW PHASE (address) — one job per PR: triage, address (incl. conflict
# resolution), verify, push, and report.
# ===========================================================================
review-address:
needs: ['route', 'review-scan', 'build-cli']
if: |-
${{ needs.review-scan.outputs.has_targets == 'true' }}
# Secret-bearing and executes PR code, but every target is live-gated to
# write+ (internal) authors at scan AND address time. That is an
# author-permission gate by design, not a head-repository gate: takeover
# engages maintainer fork PRs, and the pattern matches qwen-code-pr-review,
# whose ECS-routed review job also rides its upstream write+ check. The
# job therefore runs host-side (no `container:`): the branch code it
# executes is collaborator-authored — the same trust class ci.yml's
# pick_runner routes onto this pool — and persistent-workspace residue is
# scrubbed by the hygiene steps below. On pull_request /
# pull_request_review events the ECS route additionally needs a same-repo
# head or a write+ author (ci.yml's pick_runner form); issue_comment and
# the other triggers skip that clause and rely on the live write+ gates.
# Forks of this repo (and MAINTAINER_ECS_RUNNER_DISABLED) fall back to
# hosted. Docker availability on this pool is proven in-repo by
# qwen-triage's container jobs, which run on the same runner labels.
runs-on: '${{ (github.repository == ''QwenLM/qwen-code'' && vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && (github.event_name != ''pull_request'' && github.event_name != ''pull_request_review'' || github.event.pull_request.head.repo.full_name == github.repository || contains(fromJSON(''["OWNER","MEMBER","COLLABORATOR"]''), github.event.pull_request.author_association))) && fromJSON(''["self-hosted", "linux", "x64", "ecs-qwen"]'') || fromJSON(''["ubuntu-latest"]'') }}'
timeout-minutes: 300
permissions:
contents: 'read'
strategy:
fail-fast: false
# Simultaneity bound for the whole fleet — the ONLY place different PRs
# wait on each other (the per-scan target budget and the inspection
# budget are both far from binding at the current pool size).
# Measured at 3, on the scan that selected 7 PRs: the legs started
# 3-at-a-time and each new one began 3-4s after a slot freed, so the
# 7th PR waited 81 minutes for a slot it could have had immediately.
# 5 halves that tail while staying a real bound — the point of the cap
# is that a backlog cannot open an unbounded number of agent runs at
# once, not the specific number. The 300-minute job cap raises the
# worst-case hold to 5 runner-hours per slot (25 across the fleet) and
# holds the per-PR head-write concurrency group for the same window.
# The cap itself does not lengthen the queueing tail above; the raised
# 120-minute budget does, for the PRs that exhaust it.
max-parallel: 5
matrix:
target: '${{ fromJSON(needs.review-scan.outputs.targets) }}'
# Serialises every writer of THIS PR's head branch, across workflows.
# GitHub concurrency groups are repository-scoped, so sharing one name with
# qwen-code-pr-review.yml's resolve-pr job is what makes the two mutually
# exclusive — a per-workflow name only guards against itself.
# Without this, a `@qwen-code /resolve` and this job's own conflict path
# both merge the base branch and both push. Observed on #7355: /resolve
# pushed at 03:51, this job pushed at 04:05 and was rejected `fetch first`,
# discarding a full agent run and leaving no marker to show for it.
# Serialising is strictly better than racing: this job fetches the head by
# NAME at job start, so the second run reads the winner's result instead of
# a stale base — its work is usable and its push lands, rather than being
# rejected and thrown away. It may still spend an agent run: the
# address-time recheck re-verifies lifecycle and consent (state, labels,
# author, base, head branch) but not whether the conflict is still there.
# The prefix is a LITERAL on both sides: job-level `concurrency` cannot read
# the `env` context, so the two files cannot share a constant. A test pins
# them equal instead, because a rename in one file alone silently unlocks
# the race again with nothing failing.
concurrency:
group: 'qwen-pr-head-write-${{ matrix.target.pr }}'
cancel-in-progress: false
env:
REPO: '${{ github.repository }}'
WORKDIR: '/tmp/autofix-review-${{ matrix.target.pr }}'
PR: '${{ matrix.target.pr }}'
BRANCH: '${{ matrix.target.branch }}'
ISSUE: '${{ matrix.target.issue }}'
ROUND: '${{ matrix.target.round }}'
WATERMARK: '${{ matrix.target.watermark }}'
# The scan computed the effective cap from the takeover label
# (TAKEOVER_MAX_ROUNDS for managed PRs, the strict default otherwise);
# shadowing the workflow-level MAX_ROUNDS
# makes every round message, marker, and cap gate in this job use it.
MAX_ROUNDS: '${{ matrix.target.max_rounds }}'
# The round-counting window key this target was selected under; every
# marker this job writes records it, and prepare discards the job if a
# re-arm superseded the key while it sat queued.
WINDOW: '${{ matrix.target.win }}'
# owner/name of the PR's HEAD repository — equals REPO for in-repo
# branches; a fork for maintainer-fork takeover targets (pushable via
# 'Allow edits from maintainers').
HEAD_REPO: '${{ matrix.target.head_repo }}'
steps:
# SECURITY: checkout trusted base code first. The PR branch is checked
# out later in "Prepare branch and feedback" after the trusted CLI
# bundle (built once from this base in build-cli) is in place. Without
# this pin, pull_request_review events would check out the PR merge ref
# by default, letting PR-controlled code influence the secret-bearing
# address run. The ref is pinned to the SHA build-cli compiled — not
# the live default branch — so a mid-run base push can never leave a
# leg running a bundle built from DIFFERENT sources than its checkout.
# The SHA is validated fail-loud FIRST: actions/checkout resolves an
# empty ref to the event default — on pull_request_review triggers the
# PR merge ref — so a broken build-cli output must fail this leg
# instead of silently unpinning it.
- name: 'Validate bundle SHA'
env:
BASE_SHA: '${{ needs.build-cli.outputs.base_sha }}'
run: |-
if [[ ! "${BASE_SHA}" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::build-cli published no usable base_sha ('${BASE_SHA:-<empty>}') — refusing to fall back to the event-default ref."
exit 1
fi
# Self-hosted runners reuse the workspace; a prior containerised job
# can leave root-owned, read-only files anywhere in it. Restore
# ownership and write permission unconditionally before checkout.
- name: 'Restore workspace ownership'
run: |-
set -uo pipefail
RUNNER_UID="$(id -u)"
RUNNER_GID="$(id -g)"
if [ "$RUNNER_UID" != "0" ]; then
chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace ownership; checkout may fail on leftover root-owned files"
fi
chmod -R u+rwX "$GITHUB_WORKSPACE" 2>/dev/null || sudo -n chmod -R u+rwX "$GITHUB_WORKSPACE" || echo "::warning::could not restore workspace write permissions; checkout may fail on leftover read-only files"
# Self-hosted runners keep the workspace between runs, and other pool
# jobs execute human-authored code as the runner user, so a prior job
# can plant git exec knobs (core.fsmonitor, filter.*.smudge,
# diff.external, includeIf, hooks) in the local config that would fire
# inside THIS job's PAT-bearing git steps. It keeps
# a known-safe allowlist and unsets everything else, hardened against
# the worktree-config and global-hooksPath bypasses verified in
# qwen-triage on this pool. No-op on a fresh hosted runner.
- name: 'Sanitize workspace git config'
run: |-
set -uo pipefail
# `.git` is a directory in a normal checkout but a gitlink file in
# a worktree; -e covers both, and a missing .git (first run) too.
if [ ! -e .git ]; then
echo "no prior workspace; nothing to sanitize"
exit 0
fi
# Worktree-scoped config FIRST: `extensions.worktreeConfig=true` is
# on the allowlist below (it carries no command itself), but it
# activates `.git/config.worktree` — a second config file that
# `git config --local` neither lists nor unsets, and that CAN carry
# core.hooksPath. Verified in qwen-triage: a prior run can set
# `--worktree core.hooksPath=/`, survive the sweep untouched, and
# make the hooks deletion below walk /. Delete the file outright,
# then drop the extension.
rm -f "$(git rev-parse --git-path config.worktree 2>/dev/null || echo /nonexistent)" 2>/dev/null || true
git config --local --unset-all extensions.worktreeConfig 2>/dev/null || true
# Rather than denylist each exec-vector family (which kept missing
# new ones), KEEP a known-safe allowlist and --unset-all everything
# else: this closes the whole class, including knobs not yet
# enumerated. The kept set is only plumbing that carries no command
# — repo format, remote, branch, fetch/gc/pack/index, safe.directory,
# extensions, and submodule url/active/branch (NOT
# submodule.*.update, which can be `!cmd`). actions/checkout
# re-establishes remote/auth afterward. `|| true` on the grep: no
# non-allowlisted keys (the steady state on an already-sanitized
# runner) means grep exits 1, which would kill the step exactly
# when there is nothing to clean.
git config --local --name-only --list 2>/dev/null \
| { grep -ivE '^(core\.(repositoryformatversion|bare|filemode|symlinks|ignorecase|precomposeunicode|logallrefupdates|worktree|hidedotfiles|protecthfs|protectntfs)|remote\.[^.]+\.(url|fetch|pushurl)|branch\.|extensions\.|gc\.|pack\.|fetch\.|index\.|safe\.|submodule\.[^.]+\.(url|active|branch))' || true; } \
| while IFS= read -r key; do git config --local --unset-all "$key" 2>/dev/null || true; done
# Belt and braces after the config scrub: only delete inside the
# repository's own git dir. A hooks path resolving anywhere else is
# unlinked, never swept — a recursive delete of a planted path is
# far worse than a stale hook on a runner the pool re-cleans.
# Resolve hooks with global/system config OUT of the way. Verified
# in qwen-triage: with a global core.hooksPath set, `git rev-parse
# --git-path hooks` returns that path, the guard below sees
# "outside the git dir", and a planted `.git/hooks` symlink
# survives untouched. Keep this resolution AFTER the sweep above.
GIT_DIR_ABS="$(git rev-parse --absolute-git-dir 2>/dev/null || echo '')"
HOOKS_DIR="$(GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git rev-parse --git-path hooks 2>/dev/null || echo .git/hooks)"
HOOKS_ABS="$(cd "$HOOKS_DIR" 2>/dev/null && pwd -P || echo '')"
if [ -n "$GIT_DIR_ABS" ] && [ -n "$HOOKS_ABS" ] && [ "${HOOKS_ABS#"$GIT_DIR_ABS"/}" != "$HOOKS_ABS" ]; then
# Match -type f OR -type l: a symlinked hook survives a bare
# `-type f` sweep and still fires on the next checkout.
find "$HOOKS_ABS" \( -type f -o -type l \) ! -name '*.sample' -delete 2>/dev/null || true
else
# Resolves outside the git dir (or not at all). Warning and
# walking away would leave a live hook directory that the next
# git command executes, so unlink the ENTRY without descending
# into it and put an empty hooks directory back.
RAW_HOOKS="$(GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git rev-parse --git-path hooks 2>/dev/null || echo .git/hooks)"
echo "::warning::hooks path did not resolve inside the git dir (${HOOKS_ABS:-unresolved}); unlinking it."
rm -f "$RAW_HOOKS" 2>/dev/null || echo "::warning::refusing to recursively delete planted hooks path '$RAW_HOOKS' (a hooksPath resolving to the git dir itself would otherwise wipe .git); leaving it to the pool re-clean."
mkdir -p "${GIT_DIR_ABS:-.git}/hooks" 2>/dev/null || true
git config --local --unset-all core.hooksPath 2>/dev/null || true
fi
- name: 'Checkout trusted base'
uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3
with:
ref: '${{ needs.build-cli.outputs.base_sha }}'
fetch-depth: 0
persist-credentials: false
- name: 'Reset autofix workspace'
run: |-
rm -rf "${WORKDIR}"
# 0700: the dir holds agent transcripts and decision files; the
# sandbox container runs as this same user, so the tighter mode
# costs the job nothing. umask at creation, not mkdir-then-chmod —
# the chmod form leaves a world-readable window on this shared /tmp.
(umask 077; mkdir -p "${WORKDIR}")
# Age-sweep abandoned run-scoped dirs on this shared /tmp: a hard
# runner kill skips the always() teardown and run_id never repeats,
# so nothing else ever reclaims them.
find /tmp -maxdepth 1 -name 'autofix*' -mmin +1440 -exec rm -rf {} + 2>/dev/null || true
# The reused workspace's .git accumulates unreferenced objects
# across fetch runs on this persistent pool; prune them.
git -c gc.autoDetach=false gc --auto --prune=now --quiet 2>/dev/null || true
# Stage the schema gate script from the TRUSTED BASE checkout before
# "Prepare branch and feedback" switches the working tree to the PR
# branch. The verify gate must run the trusted copy: a PR branch that
# predates this script does not contain it (bash exits 127 and the gate
# dies without an outcome), and an in-branch copy would let branch code
# define its own gate.
- name: 'Stage trusted schema gate and agent runner'
run: |-
cp .github/scripts/check-settings-schema.sh "${RUNNER_TEMP}/check-settings-schema.sh"
cp .github/scripts/check-autofix-contracts.sh "${RUNNER_TEMP}/check-autofix-contracts.sh"
cp .github/scripts/resolve-owning-packages.sh "${RUNNER_TEMP}/resolve-owning-packages.sh"
cp .github/scripts/run-autofix-review-verification.sh "${RUNNER_TEMP}/run-autofix-review-verification.sh"
# The agent step runs AFTER prepare checks out the PR branch, so
# invoking the runner from the working tree would execute
# branch-controlled code on the host with the model key in env
# (takeover targets human-authored branches). Stage the runner AND
# its SKILL from the trusted base, MIRRORING the skill's on-disk
# layout (autofix/{SKILL.md,scripts/run-agent.mjs}) — run-agent.mjs
# resolves the skill as `<its dir>/../SKILL.md`, so a flat stage
# (RUNNER_TEMP/run-agent.mjs) points ../SKILL.md at RUNNER_TEMP/..
# and the agent crashes with ENOENT before reading any feedback.
# The mirror also means the model's instructions come from the
# trusted base, never the PR branch.
mkdir -p "${RUNNER_TEMP}/autofix-skill/scripts"
cp .qwen/skills/autofix/SKILL.md "${RUNNER_TEMP}/autofix-skill/SKILL.md"
cp .qwen/skills/autofix/scripts/run-agent.mjs "${RUNNER_TEMP}/autofix-skill/scripts/run-agent.mjs"
- name: 'Check runner environment'
env:
RUNNER_ENVIRONMENT: '${{ runner.environment }}'
RUNNER_NAME: '${{ runner.name }}'
run: |-
case "${RUNNER_ENVIRONMENT}" in
github-hosted|self-hosted) ;;
*)
echo "::error::Unsupported runner environment: ${RUNNER_ENVIRONMENT:-unset}."
exit 1
;;
esac
# The label routing pins ecs-qwen, but a mis-labelled registration
# must not silently claim a PAT-bearing 300-minute job — assert the
# pool by name on the self-hosted branch too.
if [[ "${RUNNER_ENVIRONMENT}" == 'self-hosted' ]]; then
case "${RUNNER_NAME}" in
ecs-qwen-*) ;;
*)
echo "::error::self-hosted runner '${RUNNER_NAME}' is not an ecs-qwen pool member; refusing to run here."
exit 1
;;
esac
fi
# Capability preflight for the persistent pool: this job's agent
# runs inside the docker sandbox, and a missing daemon otherwise
# surfaces only at 'Resolve sandbox image' — after npm ci/build
# has already burned tens of minutes. Fail in seconds instead.
# Hosted runners ship docker; the ECS pool's docker is proven by
# qwen-triage's container jobs on the same labels.
if ! docker info > /dev/null 2>&1; then
echo "::error::docker daemon is not reachable on this runner; the sandboxed agent cannot start."
exit 1
fi
- name: 'Set up Node.js'
uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0
with:
node-version: '22.x'
cache: 'npm'
cache-dependency-path: 'package-lock.json'
- name: 'Install tmux'
run: |-
if command -v tmux > /dev/null 2>&1; then
tmux -V
elif command -v sudo > /dev/null 2>&1 && command -v apt-get > /dev/null 2>&1; then
# sudo -n: a host without passwordless sudo must fail fast with a
# clear message, not die on a password prompt (the pr-review pool
# steps make the same assumption with `sudo -n ... || ::warning`).
sudo -n apt-get update -qq && sudo -n apt-get install -y -qq tmux || {
echo '::error::tmux is required on the autofix runner and passwordless install failed.'
exit 1
}
else
echo '::error::tmux is required on the autofix runner.'
exit 1
fi
# npm ci only — the build itself ran ONCE in build-cli. The legs still
# need node_modules: the agent builds/tests the PR branch, and the
# verify gate rebuilds from branch sources (wiping the restored
# packages/core/dist via build_package.js, so no base staleness leaks
# into branch verification). The retry recipe (and the shim below) is
# duplicated in issue-autofix and build-cli; the workflow contract
# tests pin every copy in lockstep — edit them together.
- name: 'Install dependencies'
env:
QWEN_SKIP_PREPARE: '1'
run: |-
for attempt in 1 2 3; do
if npm ci --prefer-offline --no-audit --progress=false; then
break
fi
if [[ "${attempt}" == "3" ]]; then
exit 1
fi
sleep $((attempt * 15))
done
git config core.hooksPath .husky
- name: 'Download CLI bundle'
uses: 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' # v8.0.1
with:
name: 'qwen-autofix-cli-dist'
path: '${{ runner.temp }}/cli-dist'
- name: 'Restore CLI bundle'
run: |-
tar -xzf "${RUNNER_TEMP}/cli-dist/qwen-cli-dist.tar.gz"
test -f dist/cli.js
# The verify gate's pre-build settings-schema check resolves
# '@qwen-code/qwen-code-core' through the workspace symlink to
# this entry point (the root tsconfig has no `paths`; the i18n
# check instead resolves core to sources via the packages/cli
# `paths` map); a bundle without it makes the schema generator
# crash with ERR_MODULE_NOT_FOUND.
test -f packages/core/dist/index.js
- name: 'Prepare Qwen Code CLI'
run: |-
qwen_version="$(node -p "require('./package.json').version")"
echo "Using checked-out Qwen Code bundle ${qwen_version}"
qwen_bin="${RUNNER_TEMP}/qwen-bin"
mkdir -p "${qwen_bin}"
cat > "${qwen_bin}/qwen" <<'EOF'
#!/usr/bin/env bash
exec node "${GITHUB_WORKSPACE}/dist/cli.js" "$@"
EOF
chmod +x "${qwen_bin}/qwen"
echo "${qwen_bin}" >> "${GITHUB_PATH}"
PATH="${qwen_bin}:${PATH}"
qwen --version
- name: 'Resolve sandbox image'
run: |-
node .github/scripts/resolve-sandbox-image.mjs \
"$(node -p "require('./package.json').config.sandboxImageUri")"
- name: 'Prepare branch and feedback'
id: 'prepare'
env:
GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}'
run: |-
mkdir -p "${WORKDIR}"
# ---- address-time eligibility recheck ---------------------------
# Fan-out can hold this job queued for hours behind max-parallel,
# and the matrix snapshot cannot see lifecycle changes: a PR
# closed or merged while queued must not get a secret-bearing
# agent run, a branch push, or a comment; an author/base/head
# change must not be processed against stale assumptions. Re-fetch
# and require the same shape the scan selected. A failed fetch is
# UNKNOWN and discards too (fail closed — the next scan re-emits a
# still-valid target).
PR_LIVE="$(gh pr view "${PR}" --repo "${REPO}" \
--json state,author,isCrossRepository,baseRefName,headRefName,labels,maintainerCanModify,headRepositoryOwner,headRepository 2> /dev/null || echo '{}')"
LIVE_STATE="$(jq -r '.state // ""' <<< "${PR_LIVE}")"
LIVE_AUTHOR="$(jq -r '.author.login // ""' <<< "${PR_LIVE}")"
LIVE_XREPO="$(jq -r 'if has("isCrossRepository") then .isCrossRepository else true end' <<< "${PR_LIVE}")"
LIVE_CAN_MODIFY="$(jq -r '.maintainerCanModify // false' <<< "${PR_LIVE}")"
LIVE_BASE="$(jq -r '.baseRefName // ""' <<< "${PR_LIVE}")"
LIVE_BRANCH="$(jq -r '.headRefName // ""' <<< "${PR_LIVE}")"
# Engagement labels are re-read LIVE too: a takeover label grants a
# human-authored PR the author exemption, and a skip label applied
# while this job sat queued withdraws consent — both must hold at
# the moment the secret-bearing run starts, not at scan time.
LIVE_TAKEOVER="$(jq -r --arg t "${TAKEOVER_LABEL}" '[.labels[]?.name] | index($t) != null' <<< "${PR_LIVE}")"
LIVE_SKIP="$(jq -r --arg t "${SKIP_LABEL}" '[.labels[]?.name] | index($t) != null' <<< "${PR_LIVE}")"
INELIGIBLE=''
if [[ "${PR_LIVE}" == '{}' ]]; then INELIGIBLE='metadata fetch failed (API error) — fail-closed'
elif [[ "${LIVE_STATE}" != "OPEN" ]]; then INELIGIBLE="state='${LIVE_STATE:-unknown}'"
elif [[ "${LIVE_SKIP}" == "true" ]]; then INELIGIBLE="${SKIP_LABEL} label present"
elif [[ "${LIVE_AUTHOR}" != "${AUTOFIX_BOT}" && "${LIVE_TAKEOVER}" != "true" ]]; then INELIGIBLE="author='${LIVE_AUTHOR:-unknown}' without ${TAKEOVER_LABEL}"
elif [[ "${LIVE_BASE}" != "main" ]]; then INELIGIBLE="base='${LIVE_BASE:-unknown}'"
elif [[ "${LIVE_BRANCH}" != "${BRANCH}" ]]; then INELIGIBLE="head branch is now '${LIVE_BRANCH:-unknown}'"
# Base/branch invariants sit ABOVE the fork chain: the last fork
# elif ends the ladder for eligible forks, so anything below it
# would be unreachable for exactly the PR class we fetch and push.
elif [[ "${LIVE_XREPO}" != "false" && "${LIVE_TAKEOVER}" != "true" && "${LIVE_AUTHOR}" != "${AUTOFIX_BOT}" ]]; then INELIGIBLE='fork head without takeover (a non-bot fork needs the label)'
elif [[ "${LIVE_XREPO}" != "false" && "${LIVE_CAN_MODIFY}" != "true" ]]; then INELIGIBLE='fork head without maintainer-edit access'
elif [[ "${LIVE_XREPO}" != "false" ]]; then
# Live author-privilege re-check for fork targets: an author who
# lost write+ since the scan must not get a secret-bearing run.
FORK_AUTHOR_PERM="$(gh api "repos/${REPO}/collaborators/${LIVE_AUTHOR}/permission" --jq '.permission // ""' 2> /dev/null || echo '')"
case "${FORK_AUTHOR_PERM}" in
admin|maintain|write) : ;;
*) INELIGIBLE="fork author '${LIVE_AUTHOR}' permission='${FORK_AUTHOR_PERM:-none}' below write" ;;
esac
# The fetch source and token-push target must still be the LIVE
# head repository — a fork renamed or transferred since the scan
# would route both to a stale path. Fail-closed: moved or
# unresolved discards; the next scan re-admits the fresh path.
if [[ -z "${INELIGIBLE}" ]]; then
LIVE_HR_OWNER="$(jq -r '.headRepositoryOwner.login // ""' <<< "${PR_LIVE}")"
LIVE_HR_NAME="$(jq -r '.headRepository.name // ""' <<< "${PR_LIVE}")"
if [[ -z "${LIVE_HR_OWNER}" || -z "${LIVE_HR_NAME}" || "${LIVE_HR_OWNER}/${LIVE_HR_NAME}" != "${HEAD_REPO:-${REPO}}" ]]; then
INELIGIBLE="fork head repository moved or unresolved (live='${LIVE_HR_OWNER}/${LIVE_HR_NAME}' target='${HEAD_REPO:-${REPO}}')"
fi
fi
fi
if [[ -n "${INELIGIBLE}" ]]; then
echo "🫥 target no longer eligible (${INELIGIBLE}) — discarding without action or marker"
{
echo "stale=true"
echo "conflict=false"
echo "newest=${WATERMARK}"
echo "effective_round=${ROUND}"
} >> "${GITHUB_OUTPUT}"
exit 0
fi
echo 'Restoring tracked build output before switching to the PR branch.'
git status --short
git restore --source=HEAD --staged --worktree .
# This step's env carries the PAT, and checkout fires post-checkout
# hooks — sever hooks here exactly like the push steps do; the
# agent step (no PAT, sandboxed tools) re-points hooksPath at
# .husky itself so ITS commits still get checked.
git config core.hooksPath /dev/null
if [[ "${HEAD_REPO:-${REPO}}" != "${REPO}" ]]; then
# Maintainer-fork target: the branch does not exist on origin —
# fetch it (data only; hooks are severed) from the fork.
if ! git fetch "https://github.com/${HEAD_REPO}.git" "refs/heads/${BRANCH}"; then
echo "🫥 fork fetch failed for ${HEAD_REPO} (${BRANCH}) — discarding without action or marker"
{
echo "stale=true"
echo "conflict=false"
echo "newest=${WATERMARK}"
echo "effective_round=${ROUND}"
} >> "${GITHUB_OUTPUT}"
exit 0
fi
git checkout -B "${BRANCH}" FETCH_HEAD
# Create the remote-tracking ref so the verification gate's
# `git diff --quiet "origin/${BRANCH}...${BRANCH}"` works for
# fork PRs the same way it does for same-repo branches.
git update-ref "refs/remotes/origin/${BRANCH}" "${BRANCH}"
# Allow-edits pushes ride the classic-PAT grant — GITHUB_TOKEN
# and fine-grained PATs are documented as NOT receiving it.
# Prove push access NOW, before an agent round is spent, instead
# of 403ing at the report step after the work is done.
if ! git -c credential."https://github.com".helper='!f(){ echo username=x-access-token; echo "password=${GITHUB_TOKEN}"; };f' \
push --no-verify --dry-run "https://github.com/${HEAD_REPO}.git" HEAD:"${BRANCH}" > /dev/null 2>&1; then
echo "🫥 fork push preflight failed for ${HEAD_REPO} (allow-edits grant or PAT type) — discarding without action or marker"
{
echo "stale=true"
echo "conflict=false"
echo "newest=${WATERMARK}"
echo "effective_round=${ROUND}"
} >> "${GITHUB_OUTPUT}"
exit 0
fi
else
git checkout -B "${BRANCH}" "origin/${BRANCH}"
fi
# The exact SHA the agent will evaluate. Captured HERE, before any
# agent mutation, so the marker records what was actually looked at —
# not the report-time remote head, which can move during the run.
CHECKED_OUT_HEAD="$(git rev-parse HEAD)"
echo "checked_out_head=${CHECKED_OUT_HEAD}" >> "${GITHUB_OUTPUT}"
# Does the branch conflict with base? merge-tree computes the merge
# without touching the tree; exit 1 means conflicts. UNKNOWN/errors are
# treated as no-conflict so we never block on a transient state.
CONFLICT='false'
if git merge-tree --write-tree origin/main HEAD > /dev/null 2>&1; then
CONFLICT='false'
elif [[ "$?" == "1" ]]; then
CONFLICT='true'
fi
echo "conflict=${CONFLICT}" >> "${GITHUB_OUTPUT}"
echo "🔀 Conflict with base: ${CONFLICT}"
# Same flat-array normalization as the scan step (see its PAGINATION
# NOTE). NEWEST / LIVE_NEW below bind these files POSITIONALLY
# (.[0]..[3]), so each must remain a single well-formed array.
gh api "repos/${REPO}/pulls/${PR}/reviews" --paginate \
| jq -s 'add // []' > "${WORKDIR}/rv.json"
gh api "repos/${REPO}/pulls/${PR}/comments" --paginate \
| jq -s 'add // []' > "${WORKDIR}/rc.json"
gh api "repos/${REPO}/issues/${PR}/comments" --paginate \
| jq -s 'add // []' > "${WORKDIR}/ic.json"
gh pr view "${PR}" --repo "${REPO}" \
--json statusCheckRollup --jq '.statusCheckRollup // []' > "${WORKDIR}/checks.json" \
2> /dev/null || echo '[]' > "${WORKDIR}/checks.json"
# Newest actionable feedback timestamp — stamped into the eval marker so
# the next scan knows everything up to here has been considered.
# Includes reviews, inline review comments, and issue-level PR comments
# (excluding the bot's own eval markers).
NEWEST="$(jq -rs \
--arg wm "${WATERMARK}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \
--argjson trust "${TRUSTED_ASSOC}" '
(.[0] | map(select((.submitted_at // "") > $wm)
| select((.user.login // "") != $ab)
| select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb)
| select((.state // "") | IN("CHANGES_REQUESTED", "COMMENTED")) | .submitted_at))
+ (.[1] | map(select((.created_at // "") > $wm)
| select((.user.login // "") != $ab)
| select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb) | .created_at))
+ (.[2] | map(select((.created_at // "") > $wm)
| select((.user.login // "") != $ab)
| select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb)
| select((.body // "") | test("<!-- (autofix-eval|autofix-rearm|qwen-triage|qwen-review-suggestion-summary|pr-force-push|qwen-review-ack) ") | not)
| select((.body // "") | test("^\\s*@qwen-code /") | not) | .created_at))
+ (.[3] | map(select((.conclusion // .state // "") | IN("FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED", "CANCELLED"))
| select(((.workflowName // "") != "Qwen Autofix") or (((.name // "") | startswith("review-address"))))
| select((.completedAt // .updatedAt // "") > $wm)
| (.completedAt // .updatedAt // "")))
| max // ""' "${WORKDIR}/rv.json" "${WORKDIR}/rc.json" "${WORKDIR}/ic.json" "${WORKDIR}/checks.json")"
[[ -z "${NEWEST}" ]] && NEWEST="${WATERMARK}"
echo "newest=${NEWEST}" >> "${GITHUB_OUTPUT}"
# Live-watermark revalidation: two near-simultaneous triggers for the
# SAME PR can both pass their (per-target, route-level) gates and both
# scan before either has emitted a matrix job, so both emit this PR
# with the same stale watermark. The per-PR address concurrency group
# QUEUES the duplicate rather than discarding it — but that queueing
# is exactly what makes this check sound: address jobs for one PR run
# strictly one at a time, so by the time the duplicate runs here, the
# first job's eval marker is posted and visible. Three duplicate
# signatures: (a) a sibling evaluated through a NEWER live ts than
# our matrix watermark; (b) a conflict-only sibling resolved and
# marked at the SAME ts — with no newer feedback its marker keeps
# ts=watermark while its ROUND advances past ours (ours is the max
# round observed at scan time); (c) a no-op sibling judged THIS exact
# head (its redcheck marker matches CHECKED_OUT_HEAD) while keeping
# BOTH ts and round unchanged — neither (a) nor (b) fires, but
# re-running would post a duplicate report for the same head. Either
# way, if there is no live conflict left and nothing newer than the
# live watermark, this run is a stale duplicate and discards itself.
STALE='false'
LIVE_MARKS="$(jq -r --arg ab "${AUTOFIX_BOT}" '
[ .[] | select((.user.login // "") == $ab) | . as $c | ($c.body // "")
| [ scan("<!-- autofix-eval ts=([^ ]+) acted=([^ ]+) round=([0-9]+)(?: win=([^ ]+))? -->") ] | .[]
| {ts: .[0], round: (.[2] | tonumber), win: (.[3] // "none"), at: ($c.created_at // "")} ]' "${WORKDIR}/ic.json")"
LIVE_REARM_AT="$(jq -r --arg ab "${AUTOFIX_BOT}" '
[ .[] | select((.user.login // "") == $ab)
| select((.body // "") | contains("<!-- autofix-rearm -->"))
| .created_at ] | max // ""' "${WORKDIR}/ic.json")"
# Mirrors the scan: a /retry marker releases the watermark it holds.
LIVE_EVAL_WM="$(jq -r --arg rearm "${LIVE_REARM_AT}" '
map(select($rearm == "" or (.at > $rearm)))
| map(.ts) | map(select(. != "9999-12-31T23:59:59Z")) | max // ""' <<< "${LIVE_MARKS}")"
# Round counting mirrors the scan: keyed to the CURRENT window (the
# latest engage ack's created_at, 'none' before any takeover), so
# pre-re-arm markers can neither trip the cap nor inflate the live
# round here.
LIVE_REARM_KEY="$(jq -r --arg ab "${AUTOFIX_BOT}" '
[ .[] | select((.user.login // "") == $ab)
| select(((.body // "") | contains("<!-- takeover-ack engaged -->"))
or ((.body // "") | contains("<!-- autofix-rearm -->")))
| .created_at ] | max // "none"' "${WORKDIR}/ic.json")"
LIVE_MAX_ROUND="$(jq -r --arg key "${LIVE_REARM_KEY}" 'map(select(.win == $key)) | map(.round) | max // 0' <<< "${LIVE_MARKS}")"
# The head a sibling last judged, mirrored from the scan's RED_HEAD
# parse. A no-op sibling records this marker while leaving BOTH ts and
# round UNCHANGED — so the watermark/round triggers below never fire,
# yet a second same-watermark target for the same head would otherwise
# run the agent again and post a duplicate report for that head.
LIVE_RED_HEAD="$(jq -r --arg ab "${AUTOFIX_BOT}" '
[ .[] | select((.user.login // "") == $ab) | . as $c | ($c.body // "")
| [ scan("<!-- autofix-redcheck head=([0-9a-f]+) -->") ] | .[]
| {sha: .[0], at: ($c.created_at // "")} ]
| sort_by(.at) | last | .sha // ""' "${WORKDIR}/ic.json")"
# A re-arm SUPERSEDES every job selected under the old window key:
# the maintainer asked for a fresh start, so a queued old-window job
# must discard instead of finishing and stamping an old-sequence
# marker into the new window. Conflicts stay actionable.
if [[ "${STALE}" != "true" && "${WINDOW:-none}" != "${LIVE_REARM_KEY}" && "${CONFLICT}" != "true" ]]; then
STALE='true'
echo "🫥 window superseded: selected under key '${WINDOW:-none}' but the live window is '${LIVE_REARM_KEY}' (re-armed while queued) — discarding without action or marker"
fi
if [[ -n "${LIVE_EVAL_WM}" && "${CONFLICT}" != "true" ]] \
&& { [[ "${LIVE_EVAL_WM}" > "${WATERMARK}" ]] || [[ "${LIVE_MAX_ROUND}" -gt "${ROUND}" ]] \
|| { [[ -n "${LIVE_RED_HEAD}" ]] && [[ "${LIVE_RED_HEAD}" == "${CHECKED_OUT_HEAD}" ]]; }; }; then
LIVE_NEW="$(jq -rs \
--arg wm "${LIVE_EVAL_WM}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \
--argjson trust "${TRUSTED_ASSOC}" '
(.[0] | map(select((.submitted_at // "") > $wm)
| select((.user.login // "") != $ab)
| select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb)
| select((.state // "") | IN("CHANGES_REQUESTED", "COMMENTED"))) | length)
+ (.[1] | map(select((.created_at // "") > $wm)
| select((.user.login // "") != $ab)
| select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb)) | length)
+ (.[2] | map(select((.created_at // "") > $wm)
| select((.user.login // "") != $ab)
| select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb)
| select((.body // "") | test("<!-- (autofix-eval|autofix-rearm|qwen-triage|qwen-review-suggestion-summary|pr-force-push|qwen-review-ack) ") | not)
| select((.body // "") | test("^\\s*@qwen-code /") | not)) | length)
+ (.[3] | map(select((.conclusion // .state // "") | IN("FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED", "CANCELLED"))
| select(((.workflowName // "") != "Qwen Autofix") or (((.name // "") | startswith("review-address"))))
| select((.completedAt // .updatedAt // "") > $wm)) | length)' \
"${WORKDIR}/rv.json" "${WORKDIR}/rc.json" "${WORKDIR}/ic.json" "${WORKDIR}/checks.json")"
if [[ "${LIVE_NEW}" == "0" ]]; then
STALE='true'
echo "🫥 stale duplicate target: a sibling run already evaluated through ${LIVE_EVAL_WM} (round ${LIVE_MAX_ROUND}) and nothing is newer — discarding without action or marker"
fi
fi
# A NON-stale run must still not replay a sibling's work: if live
# markers advanced past the matrix snapshot, adopt the live
# watermark (so the renderers below list only feedback the fleet
# has not yet evaluated) and the live round (so the next marker
# continues the live sequence instead of double-writing a round).
if [[ "${STALE}" != "true" && -n "${LIVE_EVAL_WM}" && "${LIVE_EVAL_WM}" > "${WATERMARK}" && "${LIVE_EVAL_WM}" != "9999-12-31T23:59:59Z" ]]; then
echo "⏩ adopting live watermark ${LIVE_EVAL_WM} (matrix had ${WATERMARK})"
WATERMARK="${LIVE_EVAL_WM}"
fi
if [[ "${STALE}" != "true" && "${LIVE_MAX_ROUND}" -gt "${ROUND}" ]]; then
echo "⏩ adopting live round ${LIVE_MAX_ROUND} (matrix had ${ROUND})"
ROUND="${LIVE_MAX_ROUND}"
fi
# The live round may already sit at the hard cap (a sibling consumed
# the final round after this target was emitted). Running would do
# round MAX+1 work and write a second capped marker, concealing the
# cap — the scan itself skips capped PRs before even looking at
# conflicts, so mirror that and discard.
if [[ "${STALE}" != "true" && "${ROUND}" -ge "${MAX_ROUNDS}" ]]; then
STALE='true'
echo "⛔ live round ${ROUND} already at MAX_ROUNDS (${MAX_ROUNDS}) — discarding without action or marker"
fi
CRITICAL_ONLY='false'
if [[ "${ROUND}" -ge "${CRITICAL_ONLY_AFTER_ROUND}" ]]; then
CRITICAL_ONLY='true'
fi
# Which trusted humans have exhausted their per-window regular
# feedback budget (see CRITICAL_ONLY_HUMAN_BATCHES). A batch is
# COUNTED only when a Critical-only round actually consumed it:
# feedback items are bucketed into the (prev marker ts, marker ts]
# span that evaluated them, spans are kept only for markers that
# ran in Critical-only territory (acted rounds numbered past the
# threshold, no-change rounds at it), and an author needs >= K
# distinct consumed spans to land here. Fresh, not-yet-evaluated
# feedback never counts against its own author, and everything is
# window-scoped so a /retry resets the budget with the window.
# Only feedback the deferred renderer below would actually defer is
# counted: Critical-tagged items, Request changes / APPROVED reviews,
# and inline comments rooted at a Critical comment or attached to a
# Request changes review are never deferrable, so they must not burn
# an author's budget — the item filter mirrors those predicates.
OVER_BUDGET_AUTHORS='[]'
if [[ "${CRITICAL_ONLY}" == "true" ]]; then
OVER_BUDGET_AUTHORS="$(jq -n \
--arg key "${LIVE_REARM_KEY}" --arg ab "${AUTOFIX_BOT}" --arg rb "${REVIEW_BOT}" \
--argjson trust "${TRUSTED_ASSOC}" \
--argjson critical_after "${CRITICAL_ONLY_AFTER_ROUND}" \
--argjson k "${CRITICAL_ONLY_HUMAN_BATCHES}" \
--slurpfile rv "${WORKDIR}/rv.json" --slurpfile rc "${WORKDIR}/rc.json" --slurpfile ic "${WORKDIR}/ic.json" '
([ ($ic | add)[] | select((.user.login // "") == $ab) | . as $c | ($c.body // "")
| [ scan("<!-- autofix-eval ts=([^ ]+) acted=([^ ]+) round=([0-9]+)(?: win=([^ ]+))? -->") ] | .[]
| {ts: .[0], acted: .[1], round: (.[2] | tonumber), win: (.[3] // "none"), at: ($c.created_at // "")} ]
| map(select(.win == $key) | select(.ts != "9999-12-31T23:59:59Z"))
| sort_by(.at)) as $ms
| ([ range(0; ($ms | length)) as $i
| ($ms[$i]
| select((.acted == "true" and .round > $critical_after) or (.acted == "false" and .round >= $critical_after))
| {lo: (if $i == 0 then "" else ($ms[$i - 1].ts) end), hi: .ts}) ]) as $spans
| ($rv | add) as $reviews
| ($rc | add) as $comments
| ([ $reviews[]
| select((.state // "") == "COMMENTED")
| select(((.body // "") | contains("**[Critical]**")) | not)
| {at: (.submitted_at // ""), login: (.user.login // ""), assoc: (.author_association // "")} ]
+ [ $comments[]
| select((
((.body // "") | contains("**[Critical]**"))
or ((.in_reply_to_id // null) as $root
| $root != null
and any($comments[]; .id == $root and ((.body // "") | contains("**[Critical]**"))))
or ((.pull_request_review_id // null) as $review
| $review != null
and any($reviews[]; .id == $review and ((.state // "") == "CHANGES_REQUESTED")))
) | not)
| {at: (.created_at // ""), login: (.user.login // ""), assoc: (.author_association // "")} ]
+ [ ($ic | add)[]
| select((.body // "") | test("<!-- (autofix-eval|autofix-rearm|qwen-triage|qwen-review-suggestion-summary|pr-force-push|qwen-review-ack) ") | not)
| select((.body // "") | test("^\\s*@qwen-code /") | not)
| select(((.body // "") | contains("**[Critical]**")) | not)
| {at: (.created_at // ""), login: (.user.login // ""), assoc: (.author_association // "")} ])
| map(select(.login != "" and .login != $ab and .login != $rb) | select(.assoc | IN($trust[])))
| [ .[] | . as $c
| ([ $spans[] | select($c.at > .lo and $c.at <= .hi) ] | .[0] // empty)
| {login: $c.login, span: .hi} ]
| group_by(.login)
| map(select((map(.span) | unique | length) >= $k) | .[0].login)
' || echo '[]')"
[[ -z "${OVER_BUDGET_AUTHORS}" ]] && OVER_BUDGET_AUTHORS='[]'
[[ "${OVER_BUDGET_AUTHORS}" != '[]' ]] && echo "🚦 regular-feedback budget exhausted this window for: $(jq -r 'join(", ")' <<< "${OVER_BUDGET_AUTHORS}")"
fi
echo "stale=${STALE}" >> "${GITHUB_OUTPUT}"
echo "effective_round=${ROUND}" >> "${GITHUB_OUTPUT}"
rm -f "${WORKDIR}/deferred-feedback.md"
if [[ "${CRITICAL_ONLY}" == "true" ]]; then
PR_URL="https://github.com/${REPO}/pull/${PR}"
{
echo '## Deferred non-Critical feedback'
echo
echo "Critical-only mode is active after ${CRITICAL_ONLY_AFTER_ROUND} change-producing rounds. The workflow excluded the non-Critical feedback below from this round's actionable sections; the items remain open for human follow-up. Maintainer feedback is deferred only after its author has used ${CRITICAL_ONLY_HUMAN_BATCHES} regular feedback batches in this window's Critical-only tail; authors at that budget, if any, are named below. (\`@qwen-code /retry\` starts a fresh counting window.)"
echo
jq -r --arg wm "${WATERMARK}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \
--arg pr_url "${PR_URL}" --argjson over "${OVER_BUDGET_AUTHORS}" '
.[]
| select((.submitted_at // "") > $wm)
| select((.user.login // "") != $ab)
| select(((.user.login // "") == $rb) or ((.user.login // "") | IN($over[])))
| select((.state // "") == "COMMENTED")
| select(((.body // "") | contains("**[Critical]**")) | not)
| "- Review by @\(.user.login): \(.html_url // $pr_url)"' \
"${WORKDIR}/rv.json"
jq -rs --arg wm "${WATERMARK}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \
--arg pr_url "${PR_URL}" --argjson over "${OVER_BUDGET_AUTHORS}" \
--slurpfile reviews "${WORKDIR}/rv.json" '
add as $comments
| ($reviews | add) as $reviews
| $comments[]
| select((.created_at // "") > $wm)
| select((.user.login // "") != $ab)
| select(((.user.login // "") == $rb) or ((.user.login // "") | IN($over[])))
| select((
((.body // "") | contains("**[Critical]**"))
or ((.in_reply_to_id // null) as $root
| $root != null
and any($comments[];
.id == $root
and ((.body // "") | contains("**[Critical]**"))))
or ((.pull_request_review_id // null) as $review
| $review != null
and any($reviews[];
.id == $review
and ((.state // "") == "CHANGES_REQUESTED")))
) | not)
| "- Inline rc:\(.id) \(.path // "?"):\(.line // "?"): \(.html_url // $pr_url)"' \
"${WORKDIR}/rc.json"
jq -r --arg wm "${WATERMARK}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \
--arg pr_url "${PR_URL}" --argjson over "${OVER_BUDGET_AUTHORS}" '
.[]
| select((.created_at // "") > $wm)
| select((.user.login // "") != $ab)
| select(((.user.login // "") == $rb) or ((.user.login // "") | IN($over[])))
| select((.body // "") | test("<!-- (autofix-eval|autofix-rearm|qwen-triage|qwen-review-suggestion-summary|pr-force-push|qwen-review-ack) ") | not)
| select((.body // "") | test("^\\s*@qwen-code /") | not)
| select(((.body // "") | contains("**[Critical]**")) | not)
| "- PR comment by @\(.user.login): \(.html_url // $pr_url)"' \
"${WORKDIR}/ic.json"
if [[ "${OVER_BUDGET_AUTHORS}" != '[]' ]]; then
echo
jq -r '.[] | "- @" + . + " is at this window'"'"'s regular-feedback budget — to continue: tag **[Critical]**, submit a Request changes review, or comment `@qwen-code /retry` for a fresh window. / @" + . + " 本窗口常规反馈预算已用完——继续请标 **[Critical]**、提交 Request changes、或评论 `@qwen-code /retry` 开新窗口。"' <<< "${OVER_BUDGET_AUTHORS}"
fi
echo
echo '<details>'
echo '<summary>中文说明</summary>'
echo
echo "完成 ${CRITICAL_ONLY_AFTER_ROUND} 个产生改动的轮次后进入仅处理 Critical 的模式。本轮可执行区域已排除下方非 Critical 反馈;这些条目保持开放,留待人工跟进。维护者反馈仅在其本人于本窗口 Critical-only 阶段已使用 ${CRITICAL_ONLY_HUMAN_BATCHES} 批常规反馈预算后才会延后;达到预算的作者(如有)在下方点名。(评论 \`@qwen-code /retry\` 可开启新的计数窗口。)"
echo
echo '</details>'
} > "${WORKDIR}/deferred-feedback.md"
fi
# Render the actionable feedback into one prompt-ready file.
{
ISSUE_REF=""
[[ "${ISSUE}" != "${PR}" ]] && ISSUE_REF=" (issue #${ISSUE})"
echo "# Review feedback to triage on PR #${PR}${ISSUE_REF}"
echo
echo "Only feedback newer than the last evaluation (${WATERMARK}) from"
echo "trusted maintainers or the automated reviewer is listed."
echo
echo "## Reviews"
jq -r --arg wm "${WATERMARK}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \
--argjson critical_only "${CRITICAL_ONLY}" --argjson trust "${TRUSTED_ASSOC}" \
--argjson over "${OVER_BUDGET_AUTHORS}" '
.[]
| select((.submitted_at // "") > $wm)
| select((.user.login // "") != $ab)
| select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb)
| select((.state // "") | IN("CHANGES_REQUESTED", "COMMENTED"))
| select(($critical_only | not)
or (((.user.login // "") != $rb) and (((.user.login // "") | IN($over[])) | not))
or (.state // "") == "CHANGES_REQUESTED"
or ((.body // "") | contains("**[Critical]**")))
| "- [\(.state)] @\(.user.login): \(.body // "" | gsub("\r"; ""))"' \
"${WORKDIR}/rv.json"
echo
echo "## Inline comments"
jq -rs --arg wm "${WATERMARK}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \
--argjson critical_only "${CRITICAL_ONLY}" --argjson trust "${TRUSTED_ASSOC}" \
--argjson over "${OVER_BUDGET_AUTHORS}" \
--slurpfile reviews "${WORKDIR}/rv.json" '
add as $comments
| ($reviews | add) as $reviews
| $comments[]
| select((.created_at // "") > $wm)
| select((.user.login // "") != $ab)
| select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb)
| select(($critical_only | not)
or (((.user.login // "") != $rb) and (((.user.login // "") | IN($over[])) | not))
or ((.body // "") | contains("**[Critical]**"))
or ((.in_reply_to_id // null) as $root
| $root != null
and any($comments[];
.id == $root
and ((.body // "") | contains("**[Critical]**"))))
or ((.pull_request_review_id // null) as $review
| $review != null
and any($reviews[];
.id == $review
and ((.state // "") == "CHANGES_REQUESTED"))))
| "- [rc:\(.id)] \(.path // "?"):\(.line // "?") @\(.user.login): \(.body // "" | gsub("\r"; ""))"' \
"${WORKDIR}/rc.json"
echo
echo "## Issue-level comments"
jq -r --arg wm "${WATERMARK}" --arg rb "${REVIEW_BOT}" --arg ab "${AUTOFIX_BOT}" \
--argjson critical_only "${CRITICAL_ONLY}" --argjson trust "${TRUSTED_ASSOC}" \
--argjson over "${OVER_BUDGET_AUTHORS}" '
.[]
| select((.created_at // "") > $wm)
| select((.user.login // "") != $ab)
| select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb)
| select((.body // "") | test("<!-- (autofix-eval|autofix-rearm|qwen-triage|qwen-review-suggestion-summary|pr-force-push|qwen-review-ack) ") | not)
| select((.body // "") | test("^\\s*@qwen-code /") | not)
| select(($critical_only | not)
or (((.user.login // "") != $rb) and (((.user.login // "") | IN($over[])) | not))
or ((.body // "") | contains("**[Critical]**")))
| "- @\(.user.login): \(.body // "" | gsub("\r"; ""))"' \
"${WORKDIR}/ic.json"
if [[ -s "${WORKDIR}/deferred-feedback.md" ]]; then
echo
cat "${WORKDIR}/deferred-feedback.md"
fi
echo
echo "## Failed checks"
jq -r --arg wm "${WATERMARK}" '
.[]
| select((.conclusion // .state // "") | IN("FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED", "CANCELLED"))
| select(((.workflowName // "") != "Qwen Autofix") or (((.name // "") | startswith("review-address"))))
| select((.completedAt // .updatedAt // "") > $wm)
| "- \(((.name // .workflowName) // "external check") | gsub("[^A-Za-z0-9 _./()-]"; "") | .[0:80]): \(.conclusion // .state // "?")"' \
"${WORKDIR}/checks.json"
# Checks that turned red BEFORE the watermark and are STILL red.
# The section above only shows checks that failed AFTER the
# watermark, so a persistent red (the exact case the scan's
# N_RED_NOW gate selects) would leave the agent with an empty
# "Failed checks" section and no check name to reproduce.
echo
echo "## Still-red checks (persisting from before the last evaluation)"
jq -r --arg wm "${WATERMARK}" '
.[]
| select((.conclusion // .state // "") | IN("FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED"))
| select(((.workflowName // "") != "Qwen Autofix") or (((.name // "") | startswith("review-address"))))
| select((.completedAt // .updatedAt // "") <= $wm)
| "- \(((.name // .workflowName) // "external check") | gsub("[^A-Za-z0-9 _./()-]"; "") | .[0:80]): \(.conclusion // .state // "?")"' \
"${WORKDIR}/checks.json"
# If the LAST round ended in a gate rejection, show the agent WHY.
# It is otherwise invisible on the retry: the reason lives in the
# bot's own handoff comment, which the feedback filter above
# (correctly) excludes, so the agent would re-read only the original
# review points and re-make the same mistake - #7208 was handed to a
# human over a two-character TS4111 fix its own compiler output had
# already spelled out.
LAST_REJECTION="$(jq -r --arg ab "${AUTOFIX_BOT}" '
[ .[] | select((.user.login // "") == $ab)
| select((.body // "") | contains("<!-- autofix-eval ts=")) ]
| sort_by(.created_at) | last | .body // ""' "${WORKDIR}/ic.json" \
| sed -n '/<!-- autofix-gate-rejection-start -->/,/<!-- autofix-gate-rejection-end -->/p' \
| sed '1d;$d')"
if [[ -n "${LAST_REJECTION}" ]]; then
echo
echo '## Your previous attempt was REJECTED by the verification gate'
echo
printf '%s\n' "${LAST_REJECTION}"
fi
# Time-budget exhaustions SINCE THE LAST SUCCESSFUL ROUND mean
# the standard address-everything prompt is not converging at
# this budget: re-running it unchanged just walks into the same
# wall (#7929 burned three 50-minute timeouts that way, #7846
# two — each a full agent run with nothing pushed). From the
# second attempt on, tell the agent to narrow. Counted since
# the last pushed/no-change round, NOT cumulatively: a push
# falsifies "not converging" and resets the count, so a recovered
# PR stops seeing the warning; until a round pushes or no-ops it
# fires on every failing round (gate rejections included) —
# correctly, since nothing has converged yet. (The
# BREAKER in the report step stays cumulative — a push does not
# make the next timeout cheaper in budget terms.) Window-scoped
# like every other census (LIVE_REARM_KEY is the live window),
# so a re-arm clears it. The needle matches the emitted
# headline verbatim: first lines can embed provider error text
# (API_ERROR_DETAIL), so a loose phrase could count a model
# error message as a timeout.
PRIOR_TIMEOUTS="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg key "${LIVE_REARM_KEY}" '
[ .[] | select((.user.login // "") == $ab)
| select((.body // "") | contains("<!-- autofix-eval "))
| select(((.body // "") | contains("win=" + $key + " -->"))
or ($key == "none" and (((.body // "") | contains("win=")) | not)))
] | sort_by(.created_at)
| map((.body | gsub("\r"; "") | split("\n")[0]))
| (map(test("Addressed the latest review feedback|no changes needed")) | rindex(true) // -1) as $lastok
| [ .[($lastok + 1):][] | select(contains("AutoFix ran out of time before finishing")) ] | length' "${WORKDIR}/ic.json" 2> /dev/null || true)"
if [[ "${PRIOR_TIMEOUTS}" -ge 1 ]]; then
echo
echo '## Budget warning: previous round(s) ran out of time'
echo
echo "${PRIOR_TIMEOUTS} round(s) since the last successful round exhausted the agent time budget before finishing anything."
fi
} > "${WORKDIR}/feedback.md"
echo '--- feedback.md ---'
cat "${WORKDIR}/feedback.md"
# The agent below runs for up to 130 minutes and the verification gate adds
# more, but nothing reaches the PR thread until "Push and report" at the
# very end: a maintainer who just engaged takeover sees silence and cannot
# tell a working round from a stuck one. The agent's output already
# streams live to the Actions log, so publish that link up front.
# Upserted by marker so one status comment per PR is EDITED each round
# (edits notify nobody) rather than stacking a new comment against a
# 100-round cap. Runs after prepare so a revalidated-away stale duplicate
# never announces a round it will not run. Best-effort: a status post that
# fails warns and continues — it must never cost the round.
- name: 'Post autofix status comment'
id: 'post_status'
if: |-
${{ steps.prepare.outputs.stale != 'true' && needs.route.outputs.dry_run != 'true' }}
env:
GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}'
EFFECTIVE_ROUND: '${{ steps.prepare.outputs.effective_round }}'
RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
run: |-
set -uo pipefail
MARKER='<!-- autofix-status -->'
ROUND_DISPLAY="${EFFECTIVE_ROUND:-${ROUND}}"
# ROUND counts rounds already DONE; every other message numbers the
# round being performed (the report posts ROUND + 1). Match it, or
# the same round carries two different numbers in one thread.
if [[ "${ROUND_DISPLAY}" =~ ^[0-9]+$ ]]; then
ROUND_DISPLAY="$((ROUND_DISPLAY + 1))"
fi
BODY="$(printf '%s\n\n🔄 **AutoFix is working on this PR** — round %s/%s. [Watch live progress](%s); this round posts its report here when it finishes.\n\n<details>\n<summary>中文说明</summary>\n\n🔄 **AutoFix 正在处理此 PR** —— 第 %s/%s 轮。[查看实时进度](%s);本轮结束后会在此发布报告。\n\n</details>' \
"${MARKER}" "${ROUND_DISPLAY}" "${MAX_ROUNDS}" "${RUN_URL}" \
"${ROUND_DISPLAY}" "${MAX_ROUNDS}" "${RUN_URL}")"
STATUS_ID="$(gh api "repos/${REPO}/issues/${PR}/comments" --paginate |
jq -rs --arg m "${MARKER}" --arg ab "${AUTOFIX_BOT}" \
'[ .[][] | select((.user.login // "") == $ab)
| select((.body // "") | contains($m)) ] | last | .id // empty')" ||
STATUS_ID=''
if [[ -n "${STATUS_ID}" ]]; then
gh api --method PATCH "repos/${REPO}/issues/comments/${STATUS_ID}" \
-f body="${BODY}" > /dev/null ||
echo "::warning::Failed to update the autofix status comment on PR #${PR}; continuing."
else
STATUS_ID="$(gh api "repos/${REPO}/issues/${PR}/comments" \
-f body="${BODY}" --jq '.id')" ||
{
STATUS_ID=''
echo "::warning::Failed to post the autofix status comment on PR #${PR}; continuing."
}
fi
# Hand the id to the finalize step so it does not repeat this scan.
echo "comment_id=${STATUS_ID}" >> "${GITHUB_OUTPUT}"
- name: 'Triage and address'
id: 'address'
# Skipped entirely for a stale duplicate target (see the live-watermark
# revalidation in prepare) — no agent run, no marker, no comment.
if: |-
${{ steps.prepare.outputs.stale != 'true' }}
# Bound the agent below the job timeout so a runaway agent fails THIS
# step (not the whole job), leaving the always() verify and report
# steps time to run and post a handoff. A job-level timeout would
# cancel those steps too and leave the loop silent.
#
# This step timeout is the BACKSTOP for a runaway that ignores the
# agent's own timer; QWEN_TIMEOUT_MS below is the real budget.
# Invariant: budget <= backstop - margin, where the margin covers
# the internal kill path (SIGTERM, 10s grace, SIGKILL, marker write).
#
# Measured on run 30646547838:
#
# setup (12 steps, ends at 'Post autofix status comment') 5-7m
# Triage and address #8005 round 9 50m03s (its own timer)
# #8211 12m45s
# Verification gate #8211 22m48s
# push + report + finalize 3-4s
#
# Setup runs in EARLIER steps, so it never competes with the agent
# for this cap. Worst-case budget:
#
# setup 7
# Triage and address 130 (120 budget + 10 margin)
# Verification gate 60 (2.6x the measured 22m48s)
# Repair 20
# Repair verification 60
# report 3
# -------------------------------
# worst case 280 => job timeout 300, and the job runs
# on ubuntu-latest, whose own ceiling
# is 360.
timeout-minutes: 130
env:
PR: '${{ env.PR }}'
ISSUE: '${{ env.ISSUE }}'
OPENAI_API_KEY: '${{ secrets.AUTOFIX_OPENAI_API_KEY }}'
OPENAI_BASE_URL: '${{ secrets.AUTOFIX_OPENAI_BASE_URL || secrets.OPENAI_BASE_URL }}'
OPENAI_MODEL: '${{ vars.QWEN_AUTOFIX_MODEL || vars.QWEN_PR_REVIEW_MODEL }}'
NO_PROXY: '127.0.0.1,localhost,::1'
QWEN_HOME: '${{ runner.temp }}/qwen-autofix-review-home'
# The primary attempt's real budget: 120m, with a 10-minute margin
# under the 130-minute step backstop above. The margin covers the
# internal kill path (SIGTERM, 10s grace, SIGKILL, marker write);
# if the step cap fires first, `agent-timeout` is never written and
# the report step misclassifies the round as a crash.
# QWEN_AUTOFIX_TIMEOUT_MS can only LOWER the fallback without a code
# change: the run block clamps it to the 7,200,000 ms ceiling
# (BUDGET_CAP_MS, the fallback itself), so raising the budget still
# requires editing this default, BUDGET_CAP_MS, and the step backstop,
# while a misconfigured variable degrades to a warning, not a misreport.
QWEN_TIMEOUT_MS: '${{ vars.QWEN_AUTOFIX_TIMEOUT_MS || 7200000 }}'
CONFLICT: '${{ steps.prepare.outputs.conflict }}'
BASE: 'main'
SETTINGS_JSON: |-
{
"maxSessionTurns": 400,
"coreTools": [
"read_file",
"read_many_files",
"glob",
"search_file_content",
"write_file",
"run_shell_command(cat)",
"run_shell_command(git add)",
"run_shell_command(git checkout)",
"run_shell_command(git commit)",
"run_shell_command(git diff)",
"run_shell_command(git log)",
"run_shell_command(git merge)",
"run_shell_command(git status)",
"run_shell_command(ls)",
"run_shell_command(mkdir)",
"run_shell_command(npm run build)",
"run_shell_command(npm run typecheck)",
"run_shell_command(npm run lint)",
"run_shell_command(npx vitest)",
"run_shell_command(npm run generate:settings-schema)",
"run_shell_command(pwd)"
],
"tools": {
"sandbox": "docker"
}
}
run: |-
rm -rf "${QWEN_HOME}"
mkdir -p .qwen "${QWEN_HOME}"
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
echo '::error::AUTOFIX_OPENAI_API_KEY secret is required for Qwen Autofix.'
exit 1
fi
printf '%s\n' "${SETTINGS_JSON}" > .qwen/settings.json
rm -f "${WORKDIR}/failure.md"
# Prepare severed hooks for its PAT-bearing git ops; THIS step
# holds no PAT, so the branch's own hooks may check the agent's
# commits again. HONEST LIMIT: the model key (OPENAI_API_KEY) IS
# forwarded into the docker sandbox by the CLI, and the agent's
# job is to build/test the branch — so on a taken-over
# human-authored PR, branch-controlled scripts can read that key.
# This is an accepted, explicit consequence of takeover
# (triage+-gated, in-repo branches only, whose authors are
# write-capable collaborators); keep AUTOFIX_OPENAI_API_KEY a
# low-privilege, quota-bounded, rotatable key.
git config core.hooksPath .husky
# Clamp the override to the budget ceiling: a repo variable past
# 7,200,000 ms (120m) would arm the timer past the 130-minute step
# backstop, the cap would fire first, and the round would be
# misreported as a crash. Malformed values fall back to the same
# ceiling (run-agent.mjs's own || handles the empty/NaN case).
# The {1,8} width bound keeps 10# inside int64: a 19+ digit value
# wraps negative in (( )) and slips past the comparison unclamped.
# 10# forces base-10: a zero-padded value is octal in (( )) and would
# error past the guard the same way.
# A FLOOR, not just a ceiling — and the floor guards the likelier
# mistake. Every comment here, the PR body and the operator message
# all speak in MINUTES; this one variable wants MILLISECONDS. A
# maintainer told to "raise the agent time budget" who sets
# QWEN_AUTOFIX_TIMEOUT_MS=120 arms a 120 ms timer: every round
# SIGTERMs instantly, writes agent-timeout, and reports "ran out of
# time (timeout (120ms))" until TIMEOUT_WINDOW_CAP trips and AutoFix
# stops on the PR — advising the human to raise the budget they just
# raised, with no ::warning:: anywhere in that loop. 60000 rejects
# every minutes-shaped value (1..999) and every 0/000, which the
# bare regex admitted while the message claimed positivity.
BUDGET_CAP_MS=7200000
BUDGET_FLOOR_MS=60000
if [[ ! "${QWEN_TIMEOUT_MS}" =~ ^[0-9]{1,8}$ ]] ||
(( 10#${QWEN_TIMEOUT_MS} < BUDGET_FLOOR_MS )) ||
(( 10#${QWEN_TIMEOUT_MS} > BUDGET_CAP_MS )); then
echo "::warning::QWEN_TIMEOUT_MS=${QWEN_TIMEOUT_MS} is not an integer of MILLISECONDS in [${BUDGET_FLOOR_MS}, ${BUDGET_CAP_MS}] (120 means 120ms, not 120 minutes); clamping to ${BUDGET_CAP_MS}"
QWEN_TIMEOUT_MS="${BUDGET_CAP_MS}"
fi
export QWEN_TIMEOUT_MS
# Trusted staged copy in the mirrored layout — resolves
# ../SKILL.md to the trusted staged SKILL, never the PR branch's.
node "${RUNNER_TEMP}/autofix-skill/scripts/run-agent.mjs" \
--mode address-review \
--pr "${PR}" \
--issue "${ISSUE}" \
--workdir "${WORKDIR}" \
--conflict "${CONFLICT}" \
--base "${BASE}"
- name: 'Verification gate'
id: 'verify'
if: |-
${{ always() && steps.prepare.outputs.stale != 'true' }}
continue-on-error: true
# Unbounded until now, and the largest consumer in the job (22m48s
# measured on #8211). Left unbounded it eats the job timeout, and a
# JOB timeout cancels the always() reporters — the silent round this
# design exists to prevent. Bounded here it degrades to the ordinary
# verification-failure path instead: continue-on-error keeps the job
# alive, 'Finalize verification' sees an empty outcome, falls through
# its case to exit 1, and the always() report step posts.
timeout-minutes: 60
run: |-
bash "${RUNNER_TEMP}/run-autofix-review-verification.sh"
- name: 'Repair deterministic rejection'
id: 'repair'
if: |-
${{ always() && steps.verify.outputs.retryable == 'true' }}
timeout-minutes: 20
env:
PR: '${{ env.PR }}'
ISSUE: '${{ env.ISSUE }}'
OPENAI_API_KEY: '${{ secrets.AUTOFIX_OPENAI_API_KEY }}'
OPENAI_BASE_URL: '${{ secrets.AUTOFIX_OPENAI_BASE_URL || secrets.OPENAI_BASE_URL }}'
OPENAI_MODEL: '${{ vars.QWEN_AUTOFIX_MODEL || vars.QWEN_PR_REVIEW_MODEL }}'
NO_PROXY: '127.0.0.1,localhost,::1'
QWEN_HOME: '${{ runner.temp }}/qwen-autofix-review-home'
QWEN_TIMEOUT_MS: '1080000'
CONFLICT: '${{ steps.prepare.outputs.conflict }}'
BASE: 'main'
SETTINGS_JSON: |-
{
"maxSessionTurns": 400,
"coreTools": [
"read_file",
"read_many_files",
"glob",
"search_file_content",
"write_file",
"run_shell_command(cat)",
"run_shell_command(git add)",
"run_shell_command(git checkout)",
"run_shell_command(git commit)",
"run_shell_command(git diff)",
"run_shell_command(git log)",
"run_shell_command(git merge)",
"run_shell_command(git status)",
"run_shell_command(ls)",
"run_shell_command(mkdir)",
"run_shell_command(npm run build)",
"run_shell_command(npm run typecheck)",
"run_shell_command(npm run lint)",
"run_shell_command(npx vitest)",
"run_shell_command(npm run generate:settings-schema)",
"run_shell_command(pwd)"
],
"tools": {
"sandbox": "docker"
}
}
run: |-
echo "attempted=true" >> "${GITHUB_OUTPUT}"
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
echo '::error::AUTOFIX_OPENAI_API_KEY secret is required for Qwen Autofix.'
exit 1
fi
if [[ ! -s "${WORKDIR}/gate-rejection.md" ]]; then
echo '::error::Retryable verification rejection has no gate-rejection.md.'
exit 1
fi
{
cat "${WORKDIR}/feedback.md"
if [[ -s "${WORKDIR}/address-summary.md" ]]; then
echo
echo '## Previous attempt summary'
cat "${WORKDIR}/address-summary.md"
fi
echo
echo '## Same-run verification repair'
echo
echo 'The previous commit was rejected by deterministic verification.'
echo
cat "${WORKDIR}/gate-rejection.md"
} > "${WORKDIR}/feedback.retry.md"
mv "${WORKDIR}/feedback.retry.md" "${WORKDIR}/feedback.md"
rm -f \
"${WORKDIR}/address-summary.md" \
"${WORKDIR}/no-action.md" \
"${WORKDIR}/failure.md" \
"${WORKDIR}/handoff.md" \
"${WORKDIR}/gate-output.log" \
"${WORKDIR}/gate-rejection.md" \
"${WORKDIR}/agent-api-error" \
"${WORKDIR}/agent-api-error-kind" \
"${WORKDIR}/agent-timeout" \
"${WORKDIR}/resolved-comments.txt" \
"${WORKDIR}/comment-replies.json"
rm -rf "${QWEN_HOME}"
mkdir -p .qwen "${QWEN_HOME}"
printf '%s\n' "${SETTINGS_JSON}" > .qwen/settings.json
git config core.hooksPath .husky
node "${RUNNER_TEMP}/autofix-skill/scripts/run-agent.mjs" \
--mode address-review \
--pr "${PR}" \
--issue "${ISSUE}" \
--workdir "${WORKDIR}" \
--conflict "${CONFLICT}" \
--base "${BASE}"
- name: 'Repair verification gate'
id: 'verify_repair'
if: |-
${{ always() && steps.repair.outputs.attempted == 'true' }}
continue-on-error: true
# Same bound as the first pass, for the same reason.
timeout-minutes: 60
run: |-
bash "${RUNNER_TEMP}/run-autofix-review-verification.sh"
- name: 'Finalize verification'
id: 'final_verify'
if: |-
${{ always() && steps.prepare.outputs.stale != 'true' }}
env:
FIRST_OUTCOME: '${{ steps.verify.outputs.outcome }}'
FIRST_COMMITTED: '${{ steps.verify.outputs.committed }}'
FIRST_VERIFIED_HEAD: '${{ steps.verify.outputs.verified_head }}'
REPAIR_ATTEMPTED: '${{ steps.repair.outputs.attempted }}'
REPAIR_OUTCOME: '${{ steps.verify_repair.outputs.outcome }}'
REPAIR_COMMITTED: '${{ steps.verify_repair.outputs.committed }}'
REPAIR_VERIFIED_HEAD: '${{ steps.verify_repair.outputs.verified_head }}'
run: |-
OUTCOME="${FIRST_OUTCOME}"
COMMITTED="${FIRST_COMMITTED}"
VERIFIED_HEAD="${FIRST_VERIFIED_HEAD}"
if [[ "${REPAIR_ATTEMPTED}" == 'true' ]]; then
OUTCOME="${REPAIR_OUTCOME}"
COMMITTED="${REPAIR_COMMITTED:-${FIRST_COMMITTED}}"
VERIFIED_HEAD="${REPAIR_VERIFIED_HEAD}"
fi
echo "outcome=${OUTCOME}" >> "${GITHUB_OUTPUT}"
if [[ -n "${COMMITTED}" ]]; then
echo "committed=${COMMITTED}" >> "${GITHUB_OUTPUT}"
fi
if [[ -n "${VERIFIED_HEAD}" ]]; then
echo "verified_head=${VERIFIED_HEAD}" >> "${GITHUB_OUTPUT}"
fi
case "${OUTCOME}" in
fixed|noop) ;;
*) exit 1 ;;
esac
- name: 'Show run artifacts'
if: |-
${{ always() }}
run: |-
if git rev-parse --verify "${BRANCH}" > /dev/null 2>&1; then
git diff "origin/main...${BRANCH}" > "${WORKDIR}/pr.diff" || true
fi
for f in feedback.md address-summary.md no-action.md failure.md handoff.md gate-rejection.md agent-api-error agent-api-error-kind agent-timeout resolved-comments.txt comment-replies.json pr.diff; do
if [[ -f "${WORKDIR}/${f}" ]]; then
echo "=============== ${f} ==============="
cat "${WORKDIR}/${f}"
echo
fi
done
- name: 'Upload run artifacts'
if: |-
${{ always() }}
uses: 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' # v7.0.1
with:
name: 'autofix-review-pr-${{ matrix.target.pr }}'
path: '${{ env.WORKDIR }}/'
if-no-files-found: 'ignore'
- name: 'Push and report'
if: |-
${{ always() && needs.route.outputs.dry_run != 'true' && (steps.final_verify.outputs.outcome == 'fixed' || steps.final_verify.outputs.outcome == 'noop') }}
env:
# CI_DEV_BOT_PAT (the qwen-code-dev-bot PAT) pushes the branch and
# posts the report as qwen-code-dev-bot, the same identity that opened
# the PR. The default GITHUB_TOKEN cannot do either on a bot-owned PR
# in a way that re-triggers CI.
GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}'
OUTCOME: '${{ steps.final_verify.outputs.outcome }}'
CONFLICT: '${{ steps.prepare.outputs.conflict }}'
NEWEST: '${{ steps.prepare.outputs.newest }}'
EFFECTIVE_ROUND: '${{ steps.prepare.outputs.effective_round }}'
# Surfaced in the report footer for diagnosis + attribution; a repo
# variable (not a secret), already the agent's OPENAI_MODEL.
MODEL: '${{ vars.QWEN_AUTOFIX_MODEL || vars.QWEN_PR_REVIEW_MODEL }}'
CHECKED_OUT_HEAD: '${{ steps.prepare.outputs.checked_out_head }}'
VERIFIED_HEAD: '${{ steps.final_verify.outputs.verified_head }}'
run: |-
# The head the agent actually evaluated — captured in prepare before
# any mutation, not the report-time remote head (which can move
# during the run). Empty when prepare exited early, which matches
# no marker and keeps reds visible — fail-open.
REPORT_HEAD="${CHECKED_OUT_HEAD}"
# Prepare may have adopted a sibling's live round; the matrix value
# would double-write that round's marker.
ROUND="${EFFECTIVE_ROUND:-${ROUND}}"
MODEL_DISPLAY="${MODEL:-default}"
if [[ -z "${GITHUB_TOKEN}" ]]; then
echo '::error::CI_DEV_BOT_PAT is required to push and report as qwen-code-dev-bot.'
exit 1
fi
api_error_file="$(mktemp)"
if ! bot_actor="$(GH_TOKEN="${GITHUB_TOKEN}" gh api user --jq '.login' 2>"${api_error_file}")"; then
api_error="$(tr '\r\n' ' ' < "${api_error_file}")"
rm -f "${api_error_file}"
echo "::error::Failed to verify CI_DEV_BOT_PAT identity with gh api user: ${api_error:-unknown error}."
exit 1
fi
rm -f "${api_error_file}"
echo "CI_DEV_BOT_PAT authenticates as ${bot_actor}"
if [[ "${bot_actor}" != "${AUTOFIX_BOT}" ]]; then
echo "::error::CI_DEV_BOT_PAT authenticates as ${bot_actor}; expected ${AUTOFIX_BOT}."
exit 1
fi
if [[ "${OUTCOME}" == "fixed" ]]; then
NEXT_ROUND="$(( ROUND + 1 ))"
git config --local --unset-all http.https://github.com/.extraheader || true
# This step carries the PAT; the branch carries PR-controlled
# .husky hooks (hooksPath was pointed there so the AGENT's
# commits get checked). A pre-push hook would execute that code
# with the PAT in env — sever hooks entirely before pushing.
git config core.hooksPath /dev/null
# Authenticate push/fetch with a one-shot, host-scoped credential
# helper via a git_auth wrapper (see Publish PR) — nothing lands
# in .git/config, argv holds only the ${GITHUB_TOKEN} reference.
git_auth() { git -c credential."https://github.com".helper='!f(){ echo username=x-access-token; echo "password=${GITHUB_TOKEN}"; };f' "$@"; }
if [[ "${HEAD_REPO:-${REPO}}" != "${REPO}" ]]; then
# Push back to the FORK branch via allow-edits (PAT has push
# rights on the upstream, which GitHub extends to the fork's
# PR branch when the author ticked the box).
PUSH_URL="https://github.com/${HEAD_REPO}.git"
else
PUSH_URL="https://github.com/${REPO}.git"
fi
# Salvage a race-lost push instead of discarding the run. The
# per-PR head-write concurrency group serialises THIS repo's
# workflows, but it cannot stop the PR author (or anything on the
# fork side) pushing during the agent's ~120-minute window. The
# stated budget widened it from ~50m, so a race-lost push is that
# much likelier and the retry loop below stays bounded at 3 merges.
# Observed twice in one day (#7983, #7985): a one-shot push died
# `fetch first` and a full verified agent run was thrown away.
# On rejection, fetch the moved head and MERGE it into the local
# line (merge, not rebase: the agent's own conflict-resolution
# rounds create merge commits, and a rebase would flatten them
# and can silently re-introduce the conflicts it resolved). The
# merge result descends from the remote head, so the retried push
# is a fast-forward. A genuine content conflict aborts and falls
# through to the existing failure path — same as today.
PUSH_RACE_MERGED='false'
for push_attempt in 1 2 3; do
if git_auth push --no-verify "${PUSH_URL}" HEAD:"${BRANCH}"; then
break
fi
if [[ "${push_attempt}" == 3 ]]; then
echo "::error::push rejected ${push_attempt} times; giving up"
exit 1
fi
echo "⚠️ push rejected (attempt ${push_attempt}) — branch moved during the run; merging the new head and retrying"
if ! git_auth fetch "${PUSH_URL}" "refs/heads/${BRANCH}"; then
echo "::error::could not fetch the moved head (attempt ${push_attempt}) — cannot salvage this push"
exit 1
fi
# The disclosure flag keys on HEAD actually advancing: a push
# can fail transiently (upload timeout, 503) with the branch
# unmoved, and the merge then no-ops "Already up to date" —
# flagging that would tell the reviewer to re-check mid-run
# commits that never existed.
PRE_MERGE_HEAD="$(git rev-parse HEAD)"
if ! git -c user.name="${AUTOFIX_BOT}" \
-c user.email="${AUTOFIX_BOT}@users.noreply.github.com" \
merge --no-edit FETCH_HEAD; then
git merge --abort || true
echo "::error::the commits pushed during the run conflict with this fix — handing off instead of overwriting either side"
exit 1
fi
if [[ "$(git rev-parse HEAD)" != "${PRE_MERGE_HEAD}" ]]; then
PUSH_RACE_MERGED='true'
fi
done
CAN_RESOLVE_THREADS='false'
if [[ -s "${WORKDIR}/resolved-comments.txt" ]]; then
LOCAL_PUSHED_HEAD="$(git rev-parse HEAD)"
if [[ "${PUSH_RACE_MERGED}" == 'true' ]]; then
echo "::warning::skipping review-thread resolution because the pushed head includes commits merged after deterministic verification"
elif [[ -z "${VERIFIED_HEAD}" || "${LOCAL_PUSHED_HEAD}" != "${VERIFIED_HEAD}" ]]; then
echo "::warning::skipping review-thread resolution because the pushed head is not the exact deterministically verified commit"
elif LIVE_PR_HEAD="$(gh pr view "${PR}" --repo "${REPO}" --json headRefOid --jq '.headRefOid // ""' 2> /dev/null)" &&
[[ -n "${LIVE_PR_HEAD}" && "${LIVE_PR_HEAD}" == "${VERIFIED_HEAD}" ]]; then
CAN_RESOLVE_THREADS='true'
else
echo "::warning::skipping review-thread resolution because the live PR head could not be proven equal to the deterministically verified commit"
fi
fi
# Resolve the review threads whose findings the agent actually
# IMPLEMENTED, so a human re-reviewing sees only what is still open
# instead of re-reading every thread to work out what was handled.
# The agent cannot do this itself - its sandbox carries no token -
# so it records the inline-comment ids it implemented and this step,
# which already holds the PAT, maps each to its thread. Findings it
# DECLINED or deferred are deliberately left open. Best-effort
# throughout: a resolve failure must never fail a good push.
# Both this resolve block and the reply block below map an
# inline-comment id to its review thread, so the threads are
# fetched once here and shared. Hoisted above both so a round that
# only replies (no resolved-comments.txt) still has them.
# first-100 page cap: a comment in a thread past this page is not
# mapped, and each block falls back to the id as given.
if [[ -s "${WORKDIR}/resolved-comments.txt" || -s "${WORKDIR}/comment-replies.json" ]]; then
THREADS_RAW="$(gh api graphql -f owner="${REPO%%/*}" -f name="${REPO##*/}" -F pr="${PR}" -f query='
query($owner:String!,$name:String!,$pr:Int!){
repository(owner:$owner,name:$name){
pullRequest(number:$pr){
reviewThreads(first:100){nodes{id isResolved comments(first:100){nodes{databaseId}}} pageInfo{hasNextPage}}
}
}
}' --jq '(.data.repository.pullRequest.reviewThreads // {nodes:[]})' 2> /dev/null || echo '{"nodes":[]}')"
THREADS_JSON="$(jq '.nodes' <<< "${THREADS_RAW}")"
if [[ "$(jq -r '.pageInfo.hasNextPage // false' <<< "${THREADS_RAW}")" == "true" ]]; then
echo "::warning::PR has more than 100 review threads; threads past the first page will not be resolved or answered in-thread"
fi
fi
if [[ "${CAN_RESOLVE_THREADS}" == 'true' ]]; then
CONFIRMED_RESOLVED_N=0
read_thread_guard() {
gh api graphql -f owner="${REPO%%/*}" -f name="${REPO##*/}" -F pr="${PR}" -f threadId="${1}" -f query='
query($owner:String!,$name:String!,$pr:Int!,$threadId:ID!){
repository(owner:$owner,name:$name){pullRequest(number:$pr){headRefOid}}
node(id:$threadId){... on PullRequestReviewThread{isResolved}}
}' --jq '[.data.repository.pullRequest.headRefOid // "", .data.node.isResolved] | @tsv'
}
while IFS= read -r rc_id || [[ -n "${rc_id}" ]]; do
rc_id="${rc_id%$'\r'}"
rc_id="${rc_id#rc:}"
[[ "${rc_id}" =~ ^[0-9]+$ ]] || continue
thread_id="$(jq -r --argjson id "${rc_id}" \
'map(select(.isResolved | not)
| select(any(.comments.nodes[]; .databaseId == $id)))
| .[0].id // ""' <<< "${THREADS_JSON}")"
if [[ -z "${thread_id}" ]]; then
echo "::warning::comment ${rc_id} matched no open review thread"
continue
fi
if ! IFS=$'\t' read -r LIVE_PR_HEAD THREAD_IS_RESOLVED < <(read_thread_guard "${thread_id}" 2> /dev/null) ||
[[ -z "${LIVE_PR_HEAD}" || "${LIVE_PR_HEAD}" != "${VERIFIED_HEAD}" ]]; then
echo "::warning::stopping review-thread resolution because the live PR head moved before resolving comment ${rc_id}"
break
elif [[ "${THREAD_IS_RESOLVED}" == 'true' ]]; then
echo "::warning::comment ${rc_id} was resolved by another actor before this round could resolve it"
continue
elif [[ "${THREAD_IS_RESOLVED}" != 'false' ]]; then
echo "::warning::stopping review-thread resolution because the state of comment ${rc_id} could not be proven"
break
fi
RESOLVE_SUCCEEDED='false'
if gh api graphql -f threadId="${thread_id}" -f query='
mutation($threadId:ID!){
resolveReviewThread(input:{threadId:$threadId}){thread{isResolved}}
}' > /dev/null 2>&1; then
RESOLVE_SUCCEEDED='true'
fi
POST_GUARD_OK='false'
if IFS=$'\t' read -r LIVE_PR_HEAD THREAD_IS_RESOLVED < <(read_thread_guard "${thread_id}" 2> /dev/null); then
POST_GUARD_OK='true'
fi
if [[ "${POST_GUARD_OK}" == 'true' && "${LIVE_PR_HEAD}" == "${VERIFIED_HEAD}" && "${THREAD_IS_RESOLVED}" == 'true' ]]; then
if [[ "${RESOLVE_SUCCEEDED}" != 'true' ]]; then
echo "::warning::comment ${rc_id} is resolved after an unsuccessful mutation command; another actor or a lost response may be responsible"
fi
CONFIRMED_RESOLVED_N=$(( CONFIRMED_RESOLVED_N + 1 ))
elif [[ "${POST_GUARD_OK}" == 'true' && "${LIVE_PR_HEAD}" == "${VERIFIED_HEAD}" && "${THREAD_IS_RESOLVED}" == 'false' && "${RESOLVE_SUCCEEDED}" == 'false' ]]; then
echo "::warning::could not resolve the review thread for comment ${rc_id}"
else
echo "::warning::the live PR head or thread state could not be proven after resolving comment ${rc_id}; stopping review-thread resolution"
break
fi
done < "${WORKDIR}/resolved-comments.txt"
echo "🧵 confirmed ${CONFIRMED_RESOLVED_N} selected review thread(s) resolved while the verified head remained live"
fi
# The mirror of the resolve above: a finding the agent did NOT
# resolve keeps its thread open, and this answers it IN that thread.
# Without it the reason sits only in the round summary, so the
# reviewer who opens the still-open thread sees silence and cannot
# tell their finding was read. Same neutralisation as the summary
# body — a reply is model output posted verbatim under the bot
# identity, so it could otherwise smuggle a forged control marker.
# Best-effort: a reply failure must never fail a good push.
if [[ -s "${WORKDIR}/comment-replies.json" ]] &&
jq -e 'type == "array"' "${WORKDIR}/comment-replies.json" > /dev/null 2>&1; then
REPLIED_N=0
while IFS=$'\t' read -r rc_id reply_b64; do
[[ "${rc_id}" =~ ^[0-9]+$ && -n "${reply_b64}" ]] || continue
# A finding cannot be both resolved and replied to; the resolve
# block above already closed anything in resolved-comments.txt,
# so skip it here rather than answer a thread we just resolved.
# Match tolerates the rc: prefix and a trailing CR, as the
# resolve block's own parsing does.
if [[ -f "${WORKDIR}/resolved-comments.txt" ]] &&
tr -d '\r' < "${WORKDIR}/resolved-comments.txt" |
grep -qxE "(rc:)?${rc_id}"; then
continue
fi
REPLY_BODY="$(base64 -d <<< "${reply_b64}" | sed 's/<!--/<!\\-\\-/g')"
[[ -n "${REPLY_BODY}" ]] || continue
# GitHub rejects a reply aimed at another reply ("Replies to
# replies are not supported"), and rc_id can itself be a reply
# id — the feedback step lists every review comment, replies
# included. Map to the thread's top-level comment (the one
# valid target), falling back to rc_id when the thread is past
# the first-100 page cap and so absent from THREADS_JSON.
root_id="$(jq -r --argjson id "${rc_id}" \
'map(select(any(.comments.nodes[]; .databaseId == $id)))
| .[0].comments.nodes[0].databaseId // $id' <<< "${THREADS_JSON}")"
if gh api "repos/${REPO}/pulls/${PR}/comments/${root_id}/replies" \
-f body="${REPLY_BODY}" > /dev/null 2>&1; then
REPLIED_N=$(( REPLIED_N + 1 ))
else
echo "::warning::could not reply to review comment ${rc_id}"
fi
done < <(jq -r '.[] | select(.id != null and .body != null)
| [(.id | tostring), (.body | @base64)] | @tsv' \
"${WORKDIR}/comment-replies.json" 2> /dev/null || true)
echo "🧵 replied on ${REPLIED_N} thread(s) the agent left open"
fi
{
echo "🤖 Addressed the latest review feedback (round ${NEXT_ROUND}/${MAX_ROUNDS}). What changed, and what I pushed back on: · 已处理最新评审反馈(第 ${NEXT_ROUND}/${MAX_ROUNDS} 轮)。改动内容与我反驳保留之处如下:"
echo
# Neutralize the comment-opening token itself: model output
# posted verbatim under the bot identity could smuggle a forged
# control marker ('<!-- autofix-eval …') the scanners would
# trust. Token-breaking is LINE-INDEPENDENT — a strip like
# 's/<!--[^>]*-->//' misses a marker whose --> sits on another
# line, and jq scan() matches across newlines. The backslashes
# render away in markdown, so the visible text is unchanged.
sed 's/<!--/<!\\-\\-/g' "${WORKDIR}/address-summary.md"
if [[ -s "${WORKDIR}/deferred-feedback.md" ]]; then
echo
sed 's/<!--/<!\\-\\-/g' "${WORKDIR}/deferred-feedback.md"
fi
echo
echo "Base-conflict check · 基分支冲突检查: $([[ "${CONFLICT}" == "true" ]] && echo 'conflicted with main — resolved in this push. · 与 main 有冲突——已在本次推送中解决。' || echo 'no conflict with main. · 与 main 无冲突。')"
if [[ "${PUSH_RACE_MERGED}" == 'true' ]]; then
echo
echo "⚠️ The branch received new commits while this round ran; they were merged into this push, but this round's verification predates that merge — re-check anything that landed mid-run. · 本轮运行期间分支收到了新的提交;本次推送已将其合并,但本轮验证在合并之前完成——请复查运行期间落地的改动。"
fi
echo
echo "Re-review when you have a moment. After round ${MAX_ROUNDS} this bot stops and leaves the PR for a human. · 有空请复审;第 ${MAX_ROUNDS} 轮后本 bot 停止并将 PR 交给人工。"
echo
echo "---"
echo "🧠 Handled by **Qwen Code** · model/模型 \`${MODEL_DISPLAY}\`"
echo
echo "<!-- autofix-eval ts=${NEWEST} acted=true round=${NEXT_ROUND} win=${WINDOW:-none} -->"
echo "<!-- autofix-redcheck head=${REPORT_HEAD} -->"
} > "${WORKDIR}/report.md"
STATUS="pushed (round ${NEXT_ROUND}/${MAX_ROUNDS})"
else
# noop: evaluated, nothing worth doing. Report once and advance the
# watermark so the next scan does not re-evaluate the same feedback.
{
echo "🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下:"
echo
sed 's/<!--/<!\\-\\-/g' "${WORKDIR}/no-action.md"
if [[ -s "${WORKDIR}/deferred-feedback.md" ]]; then
echo
sed 's/<!--/<!\\-\\-/g' "${WORKDIR}/deferred-feedback.md"
fi
echo
echo "Base-conflict check · 基分支冲突检查: $([[ "${CONFLICT}" == "true" ]] && echo 'conflicts with main (no review fix needed, but a rebase/merge is required before merge). · 与 main 有冲突(无需评审修复,但合并前需 rebase/merge)。' || echo 'no conflict with main. · 与 main 无冲突。')"
echo
echo "---"
echo "🧠 Handled by **Qwen Code** · model/模型 \`${MODEL_DISPLAY}\`"
echo
echo "<!-- autofix-eval ts=${NEWEST} acted=false round=${ROUND} win=${WINDOW:-none} -->"
echo "<!-- autofix-redcheck head=${REPORT_HEAD} -->"
} > "${WORKDIR}/report.md"
STATUS="no action needed"
fi
gh pr comment "${PR}" --repo "${REPO}" --body-file "${WORKDIR}/report.md"
# Takeover milestone digest — roughly every 10 rounds. The takeover
# cap (100) bounds runaway but says nothing about when a human
# should step in: #7469 ground to round 12 over 7 days with the
# only "this is burning budget" signal buried in Actions logs.
# Once 10+ rounds accumulate since the last digest, surface a
# window-scoped census on the PR so the maintainer who engaged it
# can decide: keep going, split the PR, or release. A SEPARATE
# comment with its OWN marker and WITHOUT the autofix-eval marker:
# every census (round, consec, watermark) selects on autofix-eval,
# so this comment is invisible to all of them, and the feedback
# filters drop bot comments, so the agent never sees it either.
# Best-effort: a digest failure must never fail a good push.
if [[ "${OUTCOME}" == "fixed" && "${MAX_ROUNDS}" == "${TAKEOVER_MAX_ROUNDS}" ]] \
&& [[ "${NEXT_ROUND}" -ge 10 && -f "${WORKDIR}/ic.json" ]]; then
# Crossing trigger, not an equality test: failure rounds also
# advance the round counter, so `push@9, crash@10, push@11`
# would skip an exact %10 check forever — and a failure-heavy
# PR is the very PR the digest exists for. Post on the first
# PUSHED round once 10+ rounds have accumulated since the last
# digest in THIS window (or since the window opened).
MS_LAST="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg win "${WINDOW:-none}" '
[ .[] | select((.user.login // "") == $ab) | (.body // "")
| [ scan("<!-- autofix-milestone round=([0-9]+) win=([^ ]+) -->") ] | .[]
| select(.[1] == $win) | (.[0] | tonumber) ]
| max // 0' "${WORKDIR}/ic.json" 2> /dev/null || echo 0)"
if [[ "$(( NEXT_ROUND - MS_LAST ))" -ge 10 ]]; then
WIN_HEADS="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg win "${WINDOW:-none}" '
[.[] | select((.user.login // "") == $ab)
| select((.body // "") | contains("<!-- autofix-eval "))
| select(
((.body // "") | contains("win=" + $win + " -->"))
or ($win == "none" and (((.body // "") | contains("win=")) | not)))]
| sort_by(.created_at) | .[]
| (.body | gsub("\r"; "") | split("\n")[0])' "${WORKDIR}/ic.json" 2> /dev/null || true)"
if [[ -z "${WIN_HEADS}" ]]; then
# Reaching round 10+ with zero window markers means the
# parse failed (prior markers must exist to be here) — a
# fabricated all-zero census is worse than no digest.
echo "::warning::milestone census found no window markers on #${PR}; skipping the digest"
else
N_PUSHED="$(grep -c 'Addressed the latest review feedback' <<< "${WIN_HEADS}" || true)"
# This round's own marker was posted just above but ic.json
# predates it — count it in by hand.
N_PUSHED=$(( N_PUSHED + 1 ))
N_NOOP="$(grep -c 'no changes needed' <<< "${WIN_HEADS}" || true)"
# Needle matches the emitted headline verbatim — first
# lines can embed provider error text.
N_TIMEOUT="$(grep -c 'AutoFix ran out of time before finishing' <<< "${WIN_HEADS}" || true)"
# Both wordings of the gate-rejection handoff, past and
# present — the census must not silently zero when the
# headline is reworded.
N_REJECTED="$(grep -cE 'Could not (address the latest feedback|produce a passing fix)' <<< "${WIN_HEADS}" || true)"
# Every other outcome (crash, model error, gate error,
# infra) lands in a residual bucket: a window that burned
# 80% of its budget on crashes must be the LOUDEST line in
# the digest, not four zeros quieter than a healthy one.
N_TOTAL=$(( $(grep -c . <<< "${WIN_HEADS}" || true) + 1 ))
N_OTHER=$(( N_TOTAL - N_PUSHED - N_NOOP - N_TIMEOUT - N_REJECTED ))
(( N_OTHER < 0 )) && N_OTHER=0
# Base updates carry their own marker with no win= field;
# their window is recovered by timestamp (the window key IS
# the engage ack's created_at — 'none' means count all,
# and the header says so).
N_BASE="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg win "${WINDOW:-none}" '
[.[] | select((.user.login // "") == $ab)
| select((.body // "") | contains("<!-- autofix-base-updated -->"))
| select($win == "none" or ((.created_at // "") > $win))]
| length' "${WORKDIR}/ic.json" 2> /dev/null || echo 0)"
WIN_DESC='in the current window'
WIN_DESC_ZH='当前窗口'
if [[ "${WINDOW:-none}" == 'none' ]]; then
WIN_DESC='since the PR opened (no counting window yet)'
WIN_DESC_ZH='自 PR 创建以来(尚无计数窗口)'
fi
if gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '📊 Takeover milestone — round %s/%s, %s. Census: %s pushed fix(es), %s no-change review(s), %s timeout(s), %s rejected attempt(s), %s other round(s) (crash / model error / gate error / infra), %s base update(s).\n\nThis many rounds deserves a human look. Options: keep going (fine — nothing changes), split or reduce the PR if rounds keep accumulating, or release takeover (remove the `%s` label or comment `%s stop`). Management continues unchanged unless you act.\n\n<details>\n<summary>中文说明</summary>\n\n📊 接管里程碑 —— 第 %s/%s 轮(%s)。统计:推送修复 %s 次、审阅无需改动 %s 次、超时 %s 次、验证拒绝 %s 次、其他轮次(崩溃/模型错误/门错误/infra)%s 次、base 更新 %s 次。\n\n轮次到这个量值得人工看一眼。可选:继续(无需操作);若轮次持续累积,考虑拆分或缩减 PR;或释放接管(移除 `%s` 标签或评论 `%s stop`)。不操作则托管照常继续。\n\n</details>\n\n<!-- autofix-milestone round=%s win=%s -->' "${NEXT_ROUND}" "${MAX_ROUNDS}" "${WIN_DESC}" "${N_PUSHED}" "${N_NOOP}" "${N_TIMEOUT}" "${N_REJECTED}" "${N_OTHER}" "${N_BASE}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${NEXT_ROUND}" "${MAX_ROUNDS}" "${WIN_DESC_ZH}" "${N_PUSHED}" "${N_NOOP}" "${N_TIMEOUT}" "${N_REJECTED}" "${N_OTHER}" "${N_BASE}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${NEXT_ROUND}" "${WINDOW:-none}")"; then
echo "📊 milestone digest posted on #${PR} (round ${NEXT_ROUND})"
else
echo "::warning::milestone digest failed to post on PR #${PR}; the round report above already landed"
fi
fi
fi
fi
{
ISSUE_REF=""
[[ "${ISSUE}" != "${PR}" ]] && ISSUE_REF=" (issue #${ISSUE})"
echo "### PR #${PR}${ISSUE_REF} — ${STATUS}"
echo "- Base conflict: ${CONFLICT}"
echo
if [[ "${OUTCOME}" == "fixed" ]]; then
cat "${WORKDIR}/address-summary.md"
else
cat "${WORKDIR}/no-action.md"
fi
} >> "${GITHUB_STEP_SUMMARY}"
echo "💬 PR #${PR}: ${STATUS}"
- name: 'Report dry-run / failure'
if: |-
${{ always() && (needs.route.outputs.dry_run == 'true' || failure() || cancelled()) }}
env:
OUTCOME: '${{ steps.final_verify.outputs.outcome }}'
COMMITTED: '${{ steps.final_verify.outputs.committed }}'
CONFLICT: '${{ steps.prepare.outputs.conflict }}'
DRY_RUN: '${{ needs.route.outputs.dry_run }}'
GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}'
NEWEST: '${{ steps.prepare.outputs.newest }}'
JOB_STATUS: '${{ job.status }}'
# 'skipped' when an EARLIER step failed before Prepare ran (base
# install/build) — an infra/base failure, not the agent.
# 'success'/'failure' when Prepare itself ran. Distinguishes a
# transient pre-agent crash (retry) from a genuine agent crash.
PREPARE_OUTCOME: '${{ steps.prepare.outcome }}'
STALE: '${{ steps.prepare.outputs.stale }}'
EFFECTIVE_ROUND: '${{ steps.prepare.outputs.effective_round }}'
MODEL: '${{ vars.QWEN_AUTOFIX_MODEL || vars.QWEN_PR_REVIEW_MODEL }}'
CHECKED_OUT_HEAD: '${{ steps.prepare.outputs.checked_out_head }}'
run: |-
# The head the agent actually evaluated — captured in prepare before
# any mutation, not the report-time remote head (which can move
# during the run). Empty when prepare exited early, which matches
# no marker and keeps reds visible — fail-open.
REPORT_HEAD="${CHECKED_OUT_HEAD}"
ROUND="${EFFECTIVE_ROUND:-${ROUND}}"
MODEL_DISPLAY="${MODEL:-default}"
SUFFIX=''
[[ "${DRY_RUN}" == "true" ]] && SUFFIX=' (dry-run, nothing pushed)'
{
ISSUE_REF=""
[[ "${ISSUE}" != "${PR}" ]] && ISSUE_REF=" (issue #${ISSUE})"
echo "### PR #${PR}${ISSUE_REF} — outcome=${OUTCOME:-unknown}${SUFFIX}"
echo "- Base conflict: ${CONFLICT:-unknown}"
echo
for f in address-summary.md no-action.md failure.md handoff.md; do
if [[ -s "${WORKDIR}/${f}" ]]; then
echo "**${f}:**"
cat "${WORKDIR}/${f}"
echo
fi
done
} >> "${GITHUB_STEP_SUMMARY}"
# Leave a visible handoff + eval marker when the address did NOT publish a
# result — a verify failure, or an agent/infra crash or timeout before the
# verify gate ran. Without it the loop goes SILENT (no comment, no marker)
# and the next scan re-targets the same feedback forever.
#
# SUPPRESS entirely once "Push and report" already handled this run
# (OUTCOME fixed or noop). That step is also always()-gated and runs even
# if a LATER always() step (e.g. artifact upload) fails the job; without
# this guard, such a late failure would flip JOB_STATUS to failure and
# post a contradictory acted=false handoff on top of the published fix.
# (A genuine push failure leaves OUTCOME=fixed but writes no marker, so
# the next scan simply retries — it does not need a handoff here.)
#
# SUPPRESS likewise for a stale-discarded run: it did no work, so a
# late always()-step failure (e.g. artifact upload) must not turn a
# deliberate no-comment/no-marker discard into a handoff that
# consumes a round.
POST_HANDOFF=false
if [[ "${DRY_RUN}" != "true" && "${STALE:-}" != "true" && -n "${GITHUB_TOKEN:-}" && "${OUTCOME:-unknown}" != "fixed" && "${OUTCOME:-unknown}" != "noop" ]]; then
if [[ "${OUTCOME:-unknown}" == "failed" || "${JOB_STATUS:-}" != "success" ]]; then
POST_HANDOFF=true
fi
fi
if [[ "${POST_HANDOFF}" == "true" ]]; then
api_error_file="$(mktemp)"
if ! bot_actor="$(GH_TOKEN="${GITHUB_TOKEN}" gh api user --jq '.login' 2>"${api_error_file}")"; then
api_error="$(tr '\r\n' ' ' < "${api_error_file}")"
rm -f "${api_error_file}"
echo "::error::Failed to verify CI_DEV_BOT_PAT identity with gh api user: ${api_error:-unknown error}."
exit 1
fi
rm -f "${api_error_file}"
echo "CI_DEV_BOT_PAT authenticates as ${bot_actor}"
if [[ "${bot_actor}" != "${AUTOFIX_BOT}" ]]; then
echo "::error::CI_DEV_BOT_PAT authenticates as ${bot_actor}; expected ${AUTOFIX_BOT}."
exit 1
fi
# Attach the most actionable agent output. failure.md first (its
# diagnosis; run-agent.mjs wraps it in a generic handoff.md, so prefer
# failure.md). Then the agent's SUCCESS outputs: on the OUTCOME=failed
# path where the agent committed a fix but a post-agent verify gate then
# failed (most notably the schema-freshness gate), only
# address-summary.md/no-action.md exist and "Push and report" is
# skipped, so this handoff is their only route to the PR — otherwise the
# comment would wrongly say "crashed or timed out". Generic notice only
# if none exist.
DETAIL_FILE=''
for f in failure.md handoff.md address-summary.md no-action.md; do
if [[ -s "${WORKDIR}/${f}" ]]; then DETAIL_FILE="${WORKDIR}/${f}"; break; fi
done
# run-agent.mjs drops this marker when qwen died on a model-side
# [API Error: 4xx/5xx] (access denied, quota, a 5xx). The agent never
# evaluated the feedback, so this is treated as a retry (sentinel ts,
# no watermark advance) below — a model access/quota blip must not
# strand the PR the way a real evaluated handoff would.
API_ERROR_DETAIL=''
API_ERROR_KIND=''
if [[ -s "${WORKDIR}/agent-api-error" ]]; then
# First line only, comment-opener escaped (agent stdout can echo
# external PR-comment text and the marker regex spans '<!-- ... -->'
# happily), and capped so a long span can't bloat the headline.
# `cut -c` counts BYTES, so the cap can split a multi-byte
# character - and the classifier deliberately matches CJK renders,
# so a >200-byte Chinese error is a supported input, not a
# hypothetical. iconv -c drops the dangling bytes so the headline
# stays valid UTF-8; it EXITS 1 when it discards one, which under
# this step's `set -eo pipefail` would abort before the marker and
# the gh pr comment - hence the `|| true`, same as the sibling
# publish site below.
API_ERROR_DETAIL="$(head -n 1 "${WORKDIR}/agent-api-error" | sed 's/<!--/<!\\-\\-/g' | cut -c1-200 | iconv -f utf-8 -t utf-8 -c || true)"
fi
if [[ -s "${WORKDIR}/agent-api-error-kind" ]]; then
API_ERROR_KIND="$(head -n 1 "${WORKDIR}/agent-api-error-kind" | tr -cd 'a-z')"
fi
# A timeout means the agent evaluated NOTHING before its budget ran
# out (run-agent.mjs writes this signal). Routed to retry, not an
# evaluated advance — same as a pre-verdict crash. Bounded by the
# round cap and the consecutive-failure cap, so a PR that keeps
# timing out still stops; a one-off (usually followed by a good
# round) recovers on the next scan instead of stranding its feedback.
AGENT_TIMEOUT=''
if [[ -s "${WORKDIR}/agent-timeout" ]]; then
AGENT_TIMEOUT="$(head -n 1 "${WORKDIR}/agent-timeout" | cut -c1-120 | iconv -f utf-8 -t utf-8 -c || true)"
fi
# If feedback was actually read (prepare ran), stamp its newest ts so
# the watermark advances and the same feedback is not re-selected next
# scan. If the crash happened before prepare, NEWEST is empty and the
# watermark cannot advance — mark the round terminal (MAX_ROUNDS) so the
# scan's max-round guard skips this PR instead of re-handing-off every
# tick, without pretending the unread feedback was evaluated. The final
# sentinel guards a cascading API failure that left WATERMARK empty too:
# an empty ts= would not match the scan's `ts=([^ ]+)` regex, so the
# terminal marker would be ignored and the PR re-handed-off. A far-future
# ISO-8601 date is used (not a bare word) so it is both non-empty AND
# sorts above any real timestamp in EVAL_WM's max, belt-and-suspenders
# with the terminal round.
# The gate declares its verdict explicitly (failed / noop / fixed).
# An EMPTY outcome on a non-success job means it died BEFORE reaching
# one - its own crash (a gate bug, an infra blip, a resolver error),
# not a judgement on the agent's work. That must retry like any other
# pre-verdict crash instead of advancing the watermark: the
# nested-package ENOENT that stranded #7329/#7336 looked exactly like
# a rejection, so a fix the agent had already written was discarded
# and the PR sat idle until a human deleted the marker by hand.
GATE_CRASHED=false
if [[ -z "${OUTCOME}" && "${JOB_STATUS:-}" != 'success' ]]; then
GATE_CRASHED=true
fi
MARK_TS="${NEWEST:-${WATERMARK:-9999-12-31T23:59:59Z}}"
if [[ -n "${NEWEST:-}" ]]; then
MARK_ROUND="$(( ROUND + 1 ))"
if [[ -z "${DETAIL_FILE}" || -n "${API_ERROR_DETAIL}" || -n "${AGENT_TIMEOUT}" || "${GATE_CRASHED}" == 'true' ]]; then
# Prepare ran (NEWEST is set) but no verdict was reached. Ways
# that happens, and in ALL of them the agent evaluated NOTHING:
# it produced no output at all (crashed before any verdict — a
# staged runner that fails to boot), it died on a model
# [API Error] (access/quota/5xx/transport), it TIMED OUT before
# finishing, or the gate crashed after the agent wrote its
# summary. So the watermark
# must NOT advance past this feedback: an advance makes the next
# scan see "nothing new" and never retry, stranding the PR on a
# transient failure (an infra blip, a quota reset minutes away, a
# model-access grant, a base-image bug fixed minutes later).
# Stamp the sentinel ts (excluded from EVAL_WM) so the feedback
# stays live and the next scan retries; the incremented round
# still bounds retries before a terminal handoff, so a PERSISTENT
# failure cannot loop forever.
MARK_TS='9999-12-31T23:59:59Z'
# Cause-aware wording, most specific first — a model error and a
# gate crash each name the operator fix, while a bare no-output
# crash points at a human. (The API clause runs first as
# defense-in-depth: today run-agent writes failure.md on the
# API-death path and the gate converts that to an explicit
# outcome=failed, so GATE_CRASHED is false — but if the gate
# ever changes, a model blip must not be reported as a gate
# problem.) No Run log here — the report block below appends
# it (avoid a duplicate).
if [[ -n "${API_ERROR_DETAIL}" ]]; then
CAUSE="could not reach the model — ${API_ERROR_DETAIL}"
LAST_FIX="a maintainer should check the autofix model key/access, then re-arm"
elif [[ -n "${AGENT_TIMEOUT}" ]]; then
# A timeout evaluated nothing, so the feedback is unaddressed
# and stays live for the retry. On a big / heavily-reviewed PR
# this is usually a one-off; the last automatic attempt names
# the real fix (split the PR or raise the budget).
CAUSE="ran out of time before finishing (${AGENT_TIMEOUT})"
LAST_FIX="a human should split the PR or raise the agent time budget and its step backstop, then re-arm"
elif [[ -z "${DETAIL_FILE}" ]]; then
CAUSE="crashed before it could evaluate the feedback"
LAST_FIX="a human should take over this PR"
else
# A gate crash points the maintainer at the gate logs: the
# agent's commit is discarded with the runner, but the
# feedback watermark is preserved, so the retry re-attempts
# the same feedback rather than skipping it.
CAUSE="hit a verification-gate error before reaching a verdict"
LAST_FIX="a maintainer should check the gate logs, then re-arm"
fi
# Budget retries by CAUSE. A transient 429/5xx self-heals, so
# it keeps the full round budget. An auth/access error that
# only a maintainer can fix never self-heals, and every attempt
# costs an agent run AND a PR comment - on a takeover PR that
# is ~100 comments over ~17h before the useful message lands.
CAUSE_MAX="${MAX_ROUNDS}"
if [[ "${API_ERROR_KIND}" == 'auth' && "${API_AUTH_MAX_ROUNDS}" -lt "${MAX_ROUNDS}" ]]; then
CAUSE_MAX="${API_AUTH_MAX_ROUNDS}"
fi
# Only promise a retry when one will actually happen: at the
# cap the next scan's round gate skips the PR, and the
# cap-reached notice is takeover-only, so the final attempt
# must say so itself. Clamp the displayed numerator:
# MARK_ROUND counts ALL rounds in the window, so earlier real
# attempts can push it past CAUSE_MAX — "attempt 4/3" reads
# as a bug.
DISPLAY_ROUND=$(( MARK_ROUND < CAUSE_MAX ? MARK_ROUND : CAUSE_MAX ))
if [[ "${MARK_ROUND}" -lt "${CAUSE_MAX}" ]]; then
HEADLINE="🤖 AutoFix ${CAUSE} (attempt ${DISPLAY_ROUND}/${CAUSE_MAX}) — it will retry on the next scan."
else
HEADLINE="🤖 AutoFix ${CAUSE} (attempt ${DISPLAY_ROUND}/${CAUSE_MAX}) — this was the last automatic attempt; ${LAST_FIX}."
# Stamp the terminal round so the scan's max-round gate skips
# this PR instead of re-handing-off every tick; the sentinel
# ts keeps the feedback live for a re-arm once the key is
# fixed. (No-op when CAUSE_MAX is already MAX_ROUNDS.)
MARK_ROUND="${MAX_ROUNDS}"
fi
else
# The gate ran and rejected the agent's fix (a build/test
# failure). Before handing to a human, check whether the PR is
# merely BEHIND main: a build that fails on something main
# already changed — e.g. #7471's update-notifier, removed by
# #7515, left its import unresolved on a stale branch — is a
# stale-base failure, NOT the fix. If behind, merge main in and
# retry: the next round builds against current main. After the
# update the PR is current, so a genuine fix-failure next round
# is no longer "behind" and falls through to the handoff below —
# which self-limits this to ONE base-update. update-branch is a
# CAS on the checked-out head; any API failure is fail-safe (fall
# through to the handoff). This is the agent-gate sibling of the
# scan's stale-base auto-update, which only sees PR status
# checks, never the gate's own build.
STALE_BASE_RETRY=false
MAIN_HEAD_R="$(gh api "repos/${REPO}/commits/${DEFAULT_BRANCH:-main}" --jq '.sha' 2> /dev/null || echo '')"
if [[ -n "${MAIN_HEAD_R}" && -n "${REPORT_HEAD}" ]]; then
CMP_R="$(gh api "repos/${REPO}/compare/${MAIN_HEAD_R}...${REPORT_HEAD}" --jq '.status' 2> /dev/null || echo '')"
if [[ "${CMP_R}" == 'behind' || "${CMP_R}" == 'diverged' ]] \
&& gh api -X PUT "repos/${REPO}/pulls/${PR}/update-branch" -f expected_head_sha="${REPORT_HEAD}" > /dev/null 2>&1; then
STALE_BASE_RETRY=true
fi
fi
if [[ "${STALE_BASE_RETRY}" == 'true' ]]; then
# Feedback stays live (sentinel ts) so the retry re-reads it
# against the freshly-merged base; the incremented round still
# bounds it, and the consecutive-failure exemption below keeps
# this not-the-PR's-fault round from counting toward the cap.
MARK_TS='9999-12-31T23:59:59Z'
HEADLINE="🤖 AutoFix updated a stale base — the fix did not pass verification, but this PR was behind \`${DEFAULT_BRANCH:-main}\`, so it merged current main in via update-branch and will retry on the next scan. A stale base (a dependency or symbol main already changed) can fail the build without being the fix's fault; if it still fails once current, it hands off to a human."
else
# Say what actually happens next. The old "A human should
# take over this PR" read as a full release, but the loop
# is NOT done with the PR: this feedback's watermark
# advances (no automatic retry of THIS item), while
# management continues for new feedback and base conflicts
# — #7929 posted the old wording and then kept pushing
# rounds, which read as a contradiction.
# Name the gate ONLY when it actually ran: this branch is
# reached for every outcome=failed verdict, but reject_fix
# is the sole writer of gate-rejection.md — the failure.md /
# dirty-tree / unchanged-branch / missing-summary paths made
# no gate decision, so a blanket clause would repeat the very
# wording-doesn't-match-behaviour bug this PR fixes.
GATE_CLAUSE=''
[[ -s "${WORKDIR}/gate-rejection.md" ]] && GATE_CLAUSE=' — the verification gate rejected the attempt'
HEADLINE="🤖 Could not produce a passing fix for this feedback (round ${MARK_ROUND}/${MAX_ROUNDS})${GATE_CLAUSE}. This item now needs a human; the loop stays engaged and still picks up new feedback and base conflicts, but will not retry this item on its own."
fi
fi
elif [[ "${PREPARE_OUTCOME}" != 'success' && "${PREPARE_OUTCOME}" != 'failure' ]]; then
# NEWEST is empty because Prepare never RAN TO A VERDICT — an
# earlier step failed or the job stopped before the agent started:
# installing/building the trusted base, node setup. That
# is infra or a broken base, NOT the agent, and it is usually
# transient (a base build fixed minutes later, an ENOSPC runner).
# Match on "not a real Prepare run" rather than 'skipped' alone, so
# this also covers a CANCELLED job (outcome 'cancelled') and a job
# that stopped before Prepare even entered the step context
# (outcome ''): a concurrency/manual cancel is not the agent's
# fault either, and 'cancelled' is a DISTINCT value from 'skipped'
# — matching only 'skipped' would send a cancel to the terminal
# branch below. Terminal here is wrong: a web-shell TS break on
# main failed the base build across a whole scan batch and stranded
# SIX healthy PRs terminally, including ones at round 11. Retry
# instead — sentinel ts keeps the feedback live — but still
# increment the round so a PERSISTENTLY broken base is bounded and
# cannot loop forever.
MARK_ROUND="$(( ROUND + 1 ))"
MARK_TS='9999-12-31T23:59:59Z'
if [[ "${MARK_ROUND}" -lt "${MAX_ROUNDS}" ]]; then
HEADLINE="🤖 AutoFix could not start — a setup step failed (or the run was cancelled) before the agent ran, so no fix was attempted. This is normally a transient infra issue, a broken base build, or a cancelled run — not this PR. It will retry on the next scan."
else
MARK_ROUND="${MAX_ROUNDS}"
HEADLINE="🤖 AutoFix could not start — reached the round cap (${MAX_ROUNDS}) because a setup step (base install/build) kept failing before the agent ran. This is infra or a persistently broken base, not this PR. Fix the base build, then comment \`${RETRY_COMMAND}\` to re-arm."
fi
else
# Prepare RAN (outcome success/failure) but produced no feedback to
# read — prepare itself crashed or timed out before emitting a
# verdict. Mark
# terminal so the scan skips (it can't advance the watermark
# without a read); do NOT imply MAX_ROUNDS attempts were made when
# zero rounds happened. The headline states the real recovery
# (delete the marker) rather than promise a re-trigger the
# max-round guard would ignore.
MARK_ROUND="${MAX_ROUNDS}"
HEADLINE="🤖 AutoFix could not start evaluation — it crashed or timed out before reading the feedback, so no fix was attempted. This PR is now marked terminal and future scans (including forced dispatch) will skip it. To recover: delete this bot's terminal \`autofix-eval\` marker comment, then re-trigger if the failure looked transient."
fi
# Consecutive-failure circuit breaker, distinct from the round cap.
# Reaching this step at all means this round did NOT push (the push
# and no-op paths report from "Push and report"), so this round is a
# failure. Count how many failures precede it WITHOUT a break: walk
# the bot's prior eval markers in API order (oldest-first, pinned
# by sort_by so a stray reorder cannot corrupt the streak) and
# reset the streak at each push ("Addressed the latest review
# feedback"), deliberate no-op ("no changes needed"), or pre-agent
# infra-failure marker ("AutoFix could not start"). After the
# full walk, CONSEC_FAIL holds failures since the last progress
# point plus one for this round. If the unbroken
# streak (this round included) reaches the cap, stop retrying even
# under takeover: a PR that fails this many times running is stuck
# on something a re-run at the same budget will not fix (observed on
# #6723: 7 straight failures, 3 timeouts + 4 gate rejections). Only
# overrides a would-be RETRY — a round already terminal for another
# reason keeps its own headline.
# Transient model errors (429/5xx) are exempt: the CAUSE_MAX logic
# above deliberately gives them the full round budget because they
# self-heal once the provider recovers. Letting the breaker override
# that would mark every in-flight PR terminal at once during a
# provider outage — the failures are not the PR's fault and DO
# self-heal. Auth errors are NOT exempt (they never self-heal).
# Pre-agent infra failures (skipped/cancelled/empty Prepare outcome)
# are exempt for the same reason: a broken base build or a runner
# crash is not the PR's fault, self-heals, and hits the whole scan
# batch at once — the exact scenario the retry path above exists to
# prevent. The round cap + sentinel-ts /retry recovery already
# bounds a persistently broken base. A stale-base retry (the gate
# rejected the fix but the PR was behind main, so the base was just
# updated) is exempt for the same reason — it is not the PR's fault
# and self-limits to one round (after the update the PR is current).
if [[ "${MARK_ROUND}" != "${MAX_ROUNDS}" ]] && [[ "${PREPARE_OUTCOME}" == 'success' || "${PREPARE_OUTCOME}" == 'failure' ]] && [[ "${STALE_BASE_RETRY:-false}" != 'true' ]] && { [[ -z "${API_ERROR_DETAIL}" ]] || [[ "${API_ERROR_KIND}" == 'auth' ]]; }; then
CONSEC_FAIL=1
if [[ -f "${WORKDIR}/ic.json" ]]; then
COMMENTS_JSON="$(cat "${WORKDIR}/ic.json")"
else
# Truncated stream: jq -s fails and || true yields '' where
# the old code kept partial JSON — PRIOR_HEADS empties and
# duplicate-report suppression weakens for one round (a
# redundant comment at worst, never a cap reset); a total gh
# failure now yields '[]' instead of the old ''.
COMMENTS_JSON="$(gh api "repos/${REPO}/issues/${PR}/comments" --paginate 2> /dev/null | jq -s 'add // []' || true)"
fi
PRIOR_HEADS="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg win "${WINDOW:-none}" '
[.[] | select((.user.login // "") == $ab)
| select((.body // "") | contains("<!-- autofix-eval "))
| select(
((.body // "") | contains("win=" + $win + " -->"))
or ($win == "none" and (((.body // "") | contains("win=")) | not)))]
| sort_by(.created_at) | .[]
| (.body | gsub("\r"; "") | split("\n")[0])' <<< "${COMMENTS_JSON}" 2> /dev/null || true)"
while IFS= read -r H; do
[[ -n "${H}" ]] || continue
if [[ "${H}" == *"Addressed the latest review feedback"* || "${H}" == *"no changes needed"* || "${H}" == *"AutoFix could not start —"* || "${H}" == *"updated a stale base"* ]]; then
CONSEC_FAIL=1
else
CONSEC_FAIL=$(( CONSEC_FAIL + 1 ))
fi
done <<< "${PRIOR_HEADS}"
if [[ "${CONSEC_FAIL}" -ge "${CONSECUTIVE_FAILURE_CAP}" ]]; then
MARK_ROUND="${MAX_ROUNDS}"
HEADLINE="🤖 AutoFix stopped after ${CONSEC_FAIL} consecutive rounds that failed to push anything (timeouts and/or gate rejections). Retrying at the same per-round budget is not converging — this usually means the PR is too large or conflicts with a fast-moving \`main\`. A human should rebase, split, or reduce it, then comment \`${RETRY_COMMAND}\` to re-arm. Until then future scans will skip this PR."
fi
# CUMULATIVE timeout breaker — the sibling of the consecutive
# one above, for the failure shape it cannot see: timeouts
# interleaved with pushed rounds. A push resets CONSEC_FAIL,
# but it does not make the next timeout cheaper — each burns a
# full agent budget with nothing to show (observed on #7929:
# three timeouts with successes in between; #7846 twice). The
# census reuses PRIOR_HEADS, so it is window-scoped exactly
# like the consecutive one and a re-arm clears it. Only
# overrides a would-be RETRY: a round already terminal keeps
# its own headline (the consecutive breaker included).
if [[ "${MARK_ROUND}" != "${MAX_ROUNDS}" ]]; then
# Needle matches the emitted headline verbatim — first lines
# can embed provider error text (API_ERROR_DETAIL puts up to
# 200 bytes of it on the same line), so a loose phrase could
# count a model error message as a timeout.
TIMEOUT_N="$(grep -c 'AutoFix ran out of time before finishing' <<< "${PRIOR_HEADS}" || true)"
if [[ -n "${AGENT_TIMEOUT:-}" ]]; then
TIMEOUT_N=$(( TIMEOUT_N + 1 ))
fi
if [[ "${TIMEOUT_N}" -ge "${TIMEOUT_WINDOW_CAP}" ]]; then
MARK_ROUND="${MAX_ROUNDS}"
# The headline states what the census MEASURED — the
# window's cumulative count — not "stopped after N
# timeouts": the round that trips this can itself have
# failed differently (a gate rejection landing on a window
# that already carries the cap — the exact rollout state
# of #7929/#7846).
HEADLINE="🤖 AutoFix stopped: this counting window now contains ${TIMEOUT_N} time-budget exhaustions (pushed rounds in between included; this round itself may have failed differently). That is ${TIMEOUT_N} full agent runs that pushed nothing. A human should split or reduce the PR (or raise the agent time budget AND its step backstop together), then comment \`${RETRY_COMMAND}\` to re-arm. Until then future scans will skip this PR."
fi
fi
fi
{
echo "${HEADLINE}"
echo
if [[ -n "${DETAIL_FILE}" ]]; then
if [[ "${COMMITTED}" == "true" ]]; then
# The agent committed (verify recorded committed=true before
# any gate could fail), but every path that reaches this
# handoff skipped "Push and report" — nothing landed on the
# branch. Say so before the agent's address-summary.md, which
# can read like a success and cite that now-discarded commit
# SHA. Keyed on committed, NOT outcome=failed: the abort/no-op
# paths (failure.md, dirty tree, unchanged branch, missing
# summary) made no commit and keep the neutral framing below.
echo "⚠️ This change was NOT pushed — any commit referenced below was made only in the runner workspace and has been discarded. What the agent reported:"
else
echo "**What I found before stopping:**"
fi
# -c drops any partial multi-byte sequence a byte-level head -c may
# have split, so the comment body stays valid UTF-8. iconv -c still
# EXITS 1 when it discards a byte, which under this shell's
# `set -eo pipefail` would abort the step and skip the marker + gh
# pr comment below — the exact silent stall this block prevents — so
# `|| true` keeps the (already-emitted) cleaned text and continues.
head -c 1500 "${DETAIL_FILE}" | iconv -f utf-8 -t utf-8 -c | sed 's/<!--/<!\\-\\-/g' || true
else
echo "AutoFix failed before producing a verified commit (the run crashed or timed out before it could explain why)."
fi
# Carry the gate's own rejection reason. Without it the comment
# shows only the agent's optimistic summary, so neither a human
# nor the NEXT round can see why the change was refused - the
# agent then re-reads the original review feedback and repeats
# the same mistake. Delimited so `Prepare branch and feedback`
# can lift it back out on the retry.
if [[ -s "${WORKDIR}/gate-rejection.md" ]]; then
echo
echo '<!-- autofix-gate-rejection-start -->'
echo "**Why it was not pushed:**"
echo
# Must stay >= reject_fix's tail -c 3000 + label + two four-backtick
# fences (~3.1 KB), or the closing fence is silently truncated.
head -c 3500 "${WORKDIR}/gate-rejection.md" | iconv -f utf-8 -t utf-8 -c | sed 's/<!--/<!\\-\\-/g' || true
echo '<!-- autofix-gate-rejection-end -->'
fi
echo
echo
echo "Run log: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo
echo "---"
echo "🧠 Handled by **Qwen Code** · model/模型 \`${MODEL_DISPLAY}\`"
echo
echo "<!-- autofix-eval ts=${MARK_TS} acted=false round=${MARK_ROUND} win=${WINDOW:-none} -->"
# A sentinel ts means the agent evaluated NOTHING (crash, API
# error, gate crash) and the next scan must retry. Recording a
# judged head here would make RED_HEAD == LIVE_HEAD, so the
# retry scan sees N_RED_NOW=0 and goes idle despite the handoff
# promising a retry.
if [[ "${MARK_TS}" != '9999-12-31T23:59:59Z' ]]; then
echo "<!-- autofix-redcheck head=${REPORT_HEAD} -->"
fi
} > "${WORKDIR}/report.md"
gh pr comment "${PR}" --repo "${REPO}" --body-file "${WORKDIR}/report.md" || echo "::warning::Failed to post handoff comment on PR #${PR}"
fi
# Flip the status comment out of "working" so a finished round never
# leaves a live-looking line behind. PATCH-only on purpose: a round that
# never posted a status (stale duplicate, dry run) must not gain one here.
# The verdict stays in the round report this job already posts; this only
# records that the round ended, and keeps the run link reachable.
# Gated on 'stale' for the same reason the announcement is: the per-PR
# concurrency group serialises duplicate address jobs, so the discarded
# one runs AFTER the real round already finalised. Ungated, it would
# overwrite that round's "finished" with its own "ended without
# publishing" and report a successful round as a failed one. An empty
# 'stale' (prepare itself crashed) still finalises — that IS this job's
# round, and it is exactly the case that must not stay "working".
- name: 'Finalize autofix status comment'
if: |-
${{ always() && steps.prepare.outputs.stale != 'true' && needs.route.outputs.dry_run != 'true' }}
env:
GITHUB_TOKEN: '${{ secrets.CI_DEV_BOT_PAT }}'
EFFECTIVE_ROUND: '${{ steps.prepare.outputs.effective_round }}'
OUTCOME: '${{ steps.final_verify.outputs.outcome }}'
RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
# The id the announcement wrote. Empty means this round never
# announced (its step was skipped, or the post itself failed) — then
# no comment claims this round is working, so there is nothing to
# flip and no reason to scan for one. A previous round's comment is
# already terminal, and the next round's announcement re-PATCHes it.
STATUS_ID: '${{ steps.post_status.outputs.comment_id }}'
run: |-
set -uo pipefail
MARKER='<!-- autofix-status -->'
if [[ -z "${STATUS_ID}" ]]; then
echo "This round posted no status comment on PR #${PR}; nothing to finalize."
exit 0
fi
ROUND_DISPLAY="${EFFECTIVE_ROUND:-${ROUND}}"
# ROUND counts rounds already DONE; every other message numbers the
# round being performed (the report posts ROUND + 1). Match it, or
# the same round carries two different numbers in one thread.
if [[ "${ROUND_DISPLAY}" =~ ^[0-9]+$ ]]; then
ROUND_DISPLAY="$((ROUND_DISPLAY + 1))"
fi
# 'fixed'/'noop' are the two outcomes that published a round report;
# anything else means the round stopped before publishing one.
if [[ "${OUTCOME:-}" == 'fixed' || "${OUTCOME:-}" == 'noop' ]]; then
EN="$(printf '✅ **AutoFix round %s finished** — [view run](%s). See this round'"'"'s report below.' "${ROUND_DISPLAY}" "${RUN_URL}")"
ZH="$(printf '✅ **AutoFix 第 %s 轮已完成** —— [查看运行](%s)。本轮报告见下方。' "${ROUND_DISPLAY}" "${RUN_URL}")"
else
EN="$(printf '⚠️ **AutoFix round %s ended without publishing a report** — [view run](%s).' "${ROUND_DISPLAY}" "${RUN_URL}")"
ZH="$(printf '⚠️ **AutoFix 第 %s 轮结束但未发布报告** —— [查看运行](%s)。' "${ROUND_DISPLAY}" "${RUN_URL}")"
fi
BODY="$(printf '%s\n\n%s\n\n<details>\n<summary>中文说明</summary>\n\n%s\n\n</details>' \
"${MARKER}" "${EN}" "${ZH}")"
gh api --method PATCH "repos/${REPO}/issues/comments/${STATUS_ID}" \
-f body="${BODY}" > /dev/null ||
echo "::warning::Failed to finalize the autofix status comment on PR #${PR}; continuing."
# Nothing else removes the per-target WORKDIR; PR numbers only
# increase, so on the persistent pool every addressed PR would leave
# its transcripts and decision files behind forever. Last step, after
# every reader including the artifact upload. always() covers
# cancellation too; a dir abandoned by a hard runner kill is reclaimed
# by the next same-PR run's reset, or by the age sweep in Reset
# autofix workspace if that PR is never addressed again.
- name: 'Clean up autofix workdir'
if: 'always()'
run: 'rm -rf "${WORKDIR}"'