diff --git a/.github/workflows/qwen-autofix-recovery.yml b/.github/workflows/qwen-autofix-recovery.yml new file mode 100644 index 0000000000..2ddd59837d --- /dev/null +++ b/.github/workflows/qwen-autofix-recovery.yml @@ -0,0 +1,7699 @@ +name: 'Qwen Autofix' + +# RECOVERY CLONE — 2026-08-19: the Actions backend wedged the ORIGINAL +# qwen-autofix.yml workflow entity (runs stuck "queued" with zero jobs, +# uncancellable via API; schedule ticks skipped; event-triggered runs +# dropped). This byte-identical copy lives under a new path so GitHub +# registers it as a NEW workflow entity and the autonomous-fix loop resumes. +# It deliberately keeps the same display name so name-keyed consumers +# (triage-finalize's workflow_run filter, the in-file check-run exclusions) +# keep matching. DELETE THIS FILE once the original entity schedules and +# executes runs normally again (and clear its zombie queued runs). +# +# 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 +# (open PRs only: reviews on closed/merged PRs +# drop at the route gate; the scheduled scan is +# the backstop for anything missed) +# • 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), +# '@qwen-code /takeover from N' (apply it and +# seed this window's round counter at N, for a +# PR that already spent N rounds in review), 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 + source: + description: 'Dispatch-origin marker (fork-bridge = the fork-review bridge); routing metadata only — never changes what may be touched, and its one behavioral effect (staying quiet at the round cap) is honored only after the scan verifies a matching fork-bridge run' + required: false + type: 'string' + +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 five + # change-producing rounds expands the diff and creates fresh review churn. + # From round 6 onward, only Critical findings, formally requested changes, + # failed checks, and base conflicts may drive code changes; lower-severity + # feedback is recorded and left open. Lowered from 10: at 10 the threshold + # only ever bound takeover PRs (the strict cap discards a plain PR at round + # 10 before Critical-only could engage), so long-running managed PRs spent + # ten rounds growing their diff on suggestions before the brake applied. + # Counted from the window's SEED, not always from zero: '@qwen-code + # /takeover from N' starts the window's counter at N so a PR taken over + # after N rounds of ordinary review reaches this threshold in the + # REMAINDER rather than a fresh five. Without a seed the counter starts at + # 0 exactly as before, so a PR that spent nine human rounds getting to + # "almost mergeable" no longer restarts the suggestion valve at full + # travel the moment it is managed. The seed is window-scoped like every + # other census: '@qwen-code /retry' or a bare re-takeover opens a window + # with no seed and the counter returns to 0 (that IS what re-arming + # means), so a late-stage PR is re-seeded by re-issuing the command with + # its number. It does NOT seed the GROWTH brake below, which anchors its + # baseline at the window's first measured round — a pre-takeover baseline + # is not recoverable, so growth is always measured from engagement. + CRITICAL_ONLY_AFTER_ROUND: '5' + # 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' + # Net-diff growth budgets per counting window — the SIZE sibling of the + # round brake above. CRITICAL_ONLY_AFTER_ROUND counts rounds, but one round + # can add hundreds of lines (#8853 grew 315 → 1393 net lines in four bot + # rounds, +609 in a single "harden per review feedback" round; #8276 grew + # ~2700 net lines under management), so a managed PR can bloat drastically + # while still under the round threshold — and every window re-arm reopens + # the suggestion valve. The first round of a counting window records the + # PR's net size (insertions minus deletions vs the merge base) as that + # window's baseline; once live growth beyond the baseline exceeds a budget, + # Critical-only mode engages early. Everything Critical-only preserves + # still flows — Critical findings, Request changes reviews, in-budget + # maintainer feedback, failed checks, conflict resolution — only the + # suggestion channel stops. `@qwen-code /retry` (or re-engaging takeover) + # opens a fresh window and re-anchors the baseline at the current size. + # TWO budgets, not one: measured bloat concentrates in TESTS (#8853's + # growth was 86% test lines — every round pins ever-more-marginal behavior; + # #8276's was 78%), so a single budget is effectively spent by test growth + # and cannot be tightened on tests without also strangling source fixes. + # Test lines are *.test.*/*.spec.* files, __snapshots__/, __tests__/, + # test-utils/, and + # integration-tests/ (the pathspec lives in the prepare step); source is + # everything else, minus mechanical churn (lockfiles and the regenerated + # settings schema) that is skimmed rather than reviewed. Either budget + # tripping engages the brake. + # TUNABLE WITHOUT A CODE CHANGE like the scan budgets above; a malformed + # value falls back to its default at the read site. + GROWTH_BUDGET_SRC_LINES: '${{ vars.QWEN_AUTOFIX_GROWTH_BUDGET_SRC_LINES || 400 }}' + GROWTH_BUDGET_TEST_LINES: '${{ vars.QWEN_AUTOFIX_GROWTH_BUDGET_TEST_LINES || 400 }}' + # Non-convergence handoff: once the growth brake has been over budget for + # this many PRIOR rounds in the window AND the diff has not shrunk from the + # most recent over-budget round, the round is DIVERGING (the fixes keep + # growing the diff, so + # Critical-only — which only trims non-Criticals — cannot help). At that + # point the round escalates to a maintainer-decision handoff (split / accept + # core + track the tail / redesign) instead of patching again. Same tunable + # contract as the budgets above (malformed → default at the read site). + GROWTH_DIVERGENCE_ROUNDS: '${{ vars.QWEN_AUTOFIX_GROWTH_DIVERGENCE_ROUNDS || 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). + # TUNABLE WITHOUT A CODE CHANGE: set the repository variable to re-size the + # loop as the takeover pool grows — Settings → Variables, no PR, no deploy. + # The literal here is only the fallback when the variable is unset. + # Why 30: 37 PRs carried autofix/takeover on 2026-08-08, so the previous + # budget of 10 emitted at most 27% of the eligible set per tick and pushed + # the rest a scan further out every time. It must also stay strictly above + # the address matrix's max-parallel or the matrix can never fill, and the + # same-repo candidate pool was 51, so 30 still bounds a pathological + # backlog. RAISE BOTH TOGETHER: this must stay above QWEN_AUTOFIX_MAX_PARALLEL. + MAX_TARGETS_PER_SCAN: '${{ vars.QWEN_AUTOFIX_MAX_TARGETS_PER_SCAN || 30 }}' + # 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. + # TUNABLE WITHOUT A CODE CHANGE, for the same reason as the two above: this + # one gates whether a PR is even LOOKED AT, so it has to grow ahead of the + # candidate pool or the oldest PRs starve. The pool was 51 same-repo open + # PRs on 2026-08-08, still under the 60 fallback. + MAX_CANDIDATE_INSPECTIONS: '${{ vars.QWEN_AUTOFIX_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' + # Commit-status context stamped PENDING on a PR head when a scan dispatches + # a review-address leg for it, and re-stamped SUCCESS by the leg on + # checkout. It closes the visibility window between dispatch and leg + # materialization: build-cli runs in between, and until the matrix expands + # the leg does not exist in the live-run jobs view, so an overlapping scan + # would re-dispatch the same PR. The scan treats a PENDING marker fresher + # than DISPATCH_STATUS_TTL_MINUTES as busy (a run that dies before the leg + # materializes leaves a marker that expires by age). Commit statuses only — + # the check-run creation API needs a GitHub App, and this workflow + # authenticates with a PAT. Same-repo heads only get a stamp: a fork head + # sha does not exist in this repo's object store. + DISPATCH_STATUS_CONTEXT: 'qwen-autofix/dispatch-pending' + DISPATCH_STATUS_TTL_MINUTES: '30' + # Comment-command sugar over TAKEOVER_LABEL ('' applies it, ' + # stop' removes it). Matched EXACTLY against the trimmed comment body — + # with ONE parameterized form, ' from N', which applies the label and + # additionally seeds this window's round counter at N (see + # CRITICAL_ONLY_AFTER_ROUND). The literal prefix still has to match this + # constant byte-for-byte; only a bounded 1-2 digit integer is read out of + # the body, and it reaches nothing but an integer comparison. + TAKEOVER_COMMAND: '@qwen-code /takeover' + # Escalation label: applied when the loop STOPS on a PR (round cap, + # consecutive-failure or time-budget breaker) and a human must act — + # re-arm, split, merge, or close. It makes paused PRs filterable and lets + # the fleet shepherd age out unanswered stops. Removed wherever management + # resumes (engage/re-arm) or a human releases the PR. + NEEDS_HUMAN_LABEL: 'autofix/needs-human' + # 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. + # The pull_request_review clause drops reviews on closed/merged PRs at the + # gate: a PR that can no longer receive commits has nothing to address. + # Finding-reply bursts on just-merged PRs otherwise start one no-op run + # per reply (observed 2026-08-16: 24+ reply reviews on merged #9222 and + # 26 runs on merged #9189 within minutes — issue #9296). The fleet never + # loses a legitimate target from this: the schedule scan engages any open + # PR with review context on its next tick, and address-time revalidation + # already drops targets whose PR closed after dispatch. + 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') && (github.event_name != 'pull_request_review' || github.event.pull_request.state == 'open') }} + 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 — + # fork-bridge dispatches included: `source` is a public + # workflow_dispatch input, so no dispatch may claim a trusted + # per-PR coalescing group by asserting an origin; fork-review + # bursts coalesce upstream instead (the signal per PR, the bridge + # per conclusion+head) + # 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. A fork-bridge dispatch is still a dispatch here: + # `source` is a public input any manual dispatch can set, so keying a + # cancellable per-PR group on it would let a manual dispatch cancel a + # queued one (and vice versa). + 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 }}' + takeover_from: '${{ steps.decide.outputs.takeover_from }}' + retry_pr: '${{ steps.decide.outputs.retry_pr }}' + cmd_pr: '${{ steps.decide.outputs.cmd_pr }}' + review_sender: '${{ github.event.review.user.login }}' + 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='' + # Round-counter seed carried by ' from N' (see the command + # parser below). Empty on every other path, which reads as "start + # this window at round 0" — the pre-existing behaviour. + TAKEOVER_FROM='' + 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 — decline, and say so. This event carries NO + # repository secrets: GitHub withholds them from every run + # tied to a pull request whose head lives in a fork, and the + # run header states it outright (`Secret source: None`). So + # CI_DEV_BOT_PAT arrives EMPTY and neither review-scan nor + # review-address could authenticate from here — the earlier + # claim that "this event runs in BASE-repo context" held for + # the workflow FILE, which is read from base, but not for the + # credentials. + # Admitting the PR anyway spent two API reads to decide it, + # then three more failing inside the scan, which exited 1 on + # `metadata_fetch_failed` — a reason whose blocked comment + # promises "a later scheduled scan will retry", true for a + # 5xx and false for a credential this run was never handed. + # Every review of a fork PR reddened the workflow while + # changing nothing, and that noise buried the failures that + # do need a human. + # The label and the feedback both keep working: the scheduled + # scan runs in repo context and admits fork takeover PRs on + # its own. This mirrors the pull_request label branch below, + # which already declines forks for exactly this reason. + echo "🧭 fork review noted for #${PR_NUMBER_EVENT} — this event carries no repository secrets, so nothing here could authenticate; the next scheduled scan engages (author write+ and allow-edits required)" + 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' + # ' from N' — the ONE parameterized form, and the only + # place this workflow reads a value out of a comment body. + # Kept inside the constants discipline above: the literal + # prefix must still match TAKEOVER_COMMAND byte-for-byte, the + # tail is a bounded integer, and the captured value only ever + # reaches an integer comparison and a printf '%s' of a + # re-validated number — never an unquoted shell word, a jq + # program, or an API path. Seeds the round counter so a PR + # that already burned N review rounds before takeover reaches + # CRITICAL_ONLY_AFTER_ROUND after the remainder rather than a + # full fresh five. 1..99: a seed at or past the effective cap + # is clamped at the read sites, but rejecting 3-digit input + # here keeps the obvious typo out entirely. + if [[ "${BODY_TRIMMED}" =~ ^(.*)\ from\ ([0-9]{1,2})$ ]]; then + # Two statements, not one `[[ ... && ... ]]`: BASH_REMATCH is + # only guaranteed populated once the =~ test has completed, + # and reading it from the right-hand operand of the same + # conditional relies on evaluation order this must not bet on. + if [[ "${BASH_REMATCH[1]}" == "${TAKEOVER_COMMAND}" ]]; then + CMD='add' + # 10# canonicalizes the capture to decimal: the value ends + # up in bare-context bash arithmetic downstream (the + # toggle's remainder, the read-site clamps' -ge), where a + # zero-padded spelling is octal — '08'/'09' even error + # outright and drop the seed note. '00' lands on '0', the + # explicit no-seed spelling. + TAKEOVER_FROM="$((10#${BASH_REMATCH[2]}))" + fi + fi + # 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}" + # Re-validated on the way out, not just on the way in: this value + # crosses a job boundary into a printf that writes a PR comment, and + # the only shape the marker reader accepts is a bare 1-2 digit + # integer. sanitize_number rejects (and warns about) anything else. + echo "takeover_from=$(sanitize_number "${TAKEOVER_FROM}")" >> "${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' && needs.review-scan.outputs.enum_failed != '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' && needs.review-scan.outputs.enum_failed != '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 + # The runner USER's global config is the same exec surface as the + # workspace config below: pool jobs run human-authored code (branch + # tests) as this user, and a stray `git config --global` outlives + # the job on the persistent pool. Measured: run 31516789251 found + # diff.external=global-driver in ~/.gitconfig, failing per-hunk + # probe tests in every later verification gate on this host. The + # gates read a throwaway global config now, so this scrub is host + # hygiene plus protection for THIS job's PAT-bearing git steps, + # which do read the real file. It runs BEFORE the .git early-exit: + # host hygiene owes nothing to the workspace existing. Denylist + # here, not the local allowlist below: the file belongs to the + # pool image, so routing/credential keys (http.*, url.*, + # credential.*) may be deliberate infra and are left alone — only + # the command-execution families go, plus include/includeIf (which + # can pull any of them back in) and protocol.ext.allow (which arms + # the command-executing ext:: transport a kept url.insteadOf could + # redirect to). Two ROUTING exceptions ride the denylist because + # each defeats the PAT steps directly: url.*.insteadOf/ + # pushInsteadOf (rewrites the push/fetch URL at transport time — + # the rest of url.* stays) and http.*.sslVerify/sslCAInfo (turns + # a kept http.proxy into a TLS-terminating interceptor; the pool + # works on the default CA today, so scrubbing these can only + # fail loudly, never silently). Subsection slots are `.+`, never + # `[^.]+`: git subsection names may contain dots (`[diff "a.b"] + # command` flattens to diff.a.b.command and would slip past + # `[^.]+`); overmatching is harmless in a denylist. Guarded + # `|| true` twice: no global file and no match are both normal, + # and either would kill the step under the default `bash -e` + + # pipefail otherwise. The same denylist lives in + # resanitize-git-config.sh, which the PAT-bearing steps re-run + # AFTER branch code executed on the host; the workflow contract + # tests pin every copy byte-identical — edit them together. + # The GLOBAL scope spans TWO files — ~/.gitconfig and + # ${XDG_CONFIG_HOME:-~/.config}/git/config — but with both + # present, `git config --global` lists and unsets ONLY + # ~/.gitconfig (probed on git 2.43 and 2.55: the listing omits + # the XDG keys and --unset-all exits 5 with them live), so sweep + # each file explicitly by pointing GIT_CONFIG_GLOBAL at it — the + # env var replaces the whole global scope with exactly that + # file, for reads and writes alike. + for global_file in "${HOME}/.gitconfig" "${XDG_CONFIG_HOME:-${HOME}/.config}/git/config"; do + { GIT_CONFIG_GLOBAL="${global_file}" git config --global --name-only --list 2>/dev/null || true; } \ + | { grep -iE '^(core\.(hookspath|fsmonitor|pager|editor|sshcommand|askpass|alternaterefscommand|gitproxy)$|diff\.external$|diff\..+\.(command|textconv)$|merge\..+\.driver$|filter\.|alias\.|pager\.|difftool\.|mergetool\.|interactive\.difffilter$|sequence\.editor$|gpg\.(.+\.)?program$|init\.templatedir$|remote\..+\.(uploadpack|receivepack)$|submodule\..+\.update$|url\..+\.(insteadof|pushinsteadof)$|http\.(.+\.)?(sslverify|sslcainfo)$|include\.|includeif\.|protocol\.(ext\.)?allow$)' || true; } \ + | while IFS= read -r key; do GIT_CONFIG_GLOBAL="${global_file}" git config --global --unset-all "$key" 2>/dev/null || true; done + done + # `.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 local 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: 'Remove stale sandbox containers' + run: |- + # run-agent.mjs's budget kill removes the container it launched, + # but a JOB timeout still reaps only the HOST-side docker client, + # not the container: a killed sandbox can keep running on this + # persistent runner. Observed directly — a hung leg's container + # name counter found qwen-code-0.21.8-0 already occupied and + # picked -1. But the docker DAEMON is per host while this pool + # runs several runner registrations on one OS, so a RUNNING + # qwen-code-* container can belong to a job executing on another + # registration of this same host — reaping it would destroy a + # live sandbox mid-run. Reap only provably-dead containers + # (exited/dead), before the sandbox picks a name (and before the + # leftovers can wedge the daemon). Every docker call here is + # tolerated: this step is hygiene, and a daemon blip, a racing + # reap on another registration, or a container that refuses + # removal must not kill the round at setup. Every call also runs + # under `timeout` (GNU coreutils on the ubuntu runners): a daemon + # that is alive but wedged blocks `docker ps` indefinitely, and + # `|| STALE=''` catches only a nonzero exit, not a hang — the + # step would sit until the job timeout, a silent round + # reintroduced ahead of the very idle watchdog this PR adds. + command -v docker > /dev/null || exit 0 + STALE="$(timeout 30 docker ps -aq --filter 'name=qwen-code-' --filter 'status=exited' --filter 'status=dead' 2>/dev/null)" || STALE='' + if [ -n "${STALE}" ]; then + echo "removing stale sandbox containers:" + timeout 30 docker ps -a --filter 'name=qwen-code-' --filter 'status=exited' --filter 'status=dead' --format ' {{.Names}} ({{.Status}})' || true + printf '%s\n' "${STALE}" | xargs -r -I{} timeout 30 docker rm -f {} > /dev/null 2>&1 || true + fi + + - 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' + id: 'stage' + 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/resanitize-git-config.sh "${RUNNER_TEMP}/resanitize-git-config.sh" + # The staged copy's trusted-base provenance holds at cp time only: + # RUNNER_TEMP is writable by the branch/agent code later steps run + # on this host, so record the digest in GITHUB_OUTPUT — expression + # context, which a disk write after staging cannot reach — for the + # PAT-bearing step to verify at invocation time. The trusted PATH is + # recorded the same way and before any branch code runs, so a + # $GITHUB_ENV-planted PATH/preload cannot swap the sha256sum/bash/git + # the PAT step resolves (that would defeat the digest gate itself). + echo "resanitize_sha256=$(sha256sum "${RUNNER_TEMP}/resanitize-git-config.sh" | cut -d' ' -f1)" >> "${GITHUB_OUTPUT}" + echo "trusted_path=${PATH}" >> "${GITHUB_OUTPUT}" + + - 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 + # 'Set up Node.js' assumes this pool keeps ~/.npm across jobs; + # log its size so a move to per-job containers is visible. + du -sh "${HOME}/.npm" 2>/dev/null || echo '~/.npm is absent on this runner.' + + - name: 'Set up Node.js' + uses: 'actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e' # v6.4.0 + with: + node-version: '22.x' + # No remote npm cache on the persistent pool: one measured + # review-address leg spent 339s in `Set up Node.js` restoring + # 2,654,052,865 bytes (~10 MB/s) to protect an `npm ci` that took + # 29s in the very next step, and those runners keep ~/.npm across + # jobs anyway, so every leg paid the download again — up to ten + # review-address legs per scan. The hosted fallback is ephemeral + # and keeps the cache; the choice keys on the runner fact directly. + # Keep the truthy literal in the MIDDLE of the ternary — GHA's + # &&/|| return operand values and '' is falsy, so + # `== 'self-hosted' && '' || 'npm'` yields 'npm' on BOTH pools + # (the contract tests evaluate the expression for both runner + # facts). package-manager-cache stops a future `packageManager` + # field in package.json from silently re-enabling the cache here. + cache: "${{ runner.environment != 'self-hosted' && 'npm' || '' }}" + package-manager-cache: false + 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" "${WORKDIR}/failure.zh.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" "${WORKDIR}/failure.zh.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}" + + # Hermetic git config for the gate and every check it spawns, same + # rationale and shape as run-autofix-review-verification.sh (a + # leaked global exec knob on the persistent pool must not fail + # branch tests, a branch-authored `git config --global` must not + # outlive the run, and GITHUB_ENV-injected git env channels outrank + # every file layer) — the contract test pins this env+redirect + # block equal to the review gate's copy. + unset GIT_CONFIG_PARAMETERS GIT_ALLOW_PROTOCOL GIT_PROXY_COMMAND \ + GIT_SSL_NO_VERIFY GIT_SSL_CAINFO GIT_EXEC_PATH GIT_DIR \ + GIT_WORK_TREE GIT_COMMON_DIR GIT_OBJECT_DIRECTORY \ + GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_SHALLOW_FILE \ + GIT_ASKPASS GIT_SSH GIT_SSH_COMMAND + export GIT_CONFIG_COUNT=0 + export GIT_TERMINAL_PROMPT=0 + export GIT_CONFIG_SYSTEM=/dev/null + export GIT_CONFIG_GLOBAL="${RUNNER_TEMP}/autofix-gate-gitconfig" + : > "${GIT_CONFIG_GLOBAL}" + git config --file "${GIT_CONFIG_GLOBAL}" safe.directory "$(pwd)" + if [ -s /etc/gitconfig ]; then + echo "::notice::/etc/gitconfig exists but is bypassed by the gate's GIT_CONFIG_SYSTEM redirect — replicate any setting the checks need via per-job env." + fi + + 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/' (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 failure.zh.md fix.diff; do + if [[ -f "${WORKDIR}/${f}" ]]; then + echo "=============== ${f} ===============" + # Agent-written content on step STDOUT: a line-start `::` would + # be parsed as a workflow command (::error::, ::add-mask::), the + # same reason the PR-lane dump loop neutralizes it. (The two + # step-SUMMARY loops write to a file, where `::` is not + # parsed.) + sed 's/::/;;/g' "${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 }}' + RESANITIZE_SHA256: '${{ steps.stage.outputs.resanitize_sha256 }}' + TRUSTED_PATH: '${{ steps.stage.outputs.trusted_path }}' + run: |- + # gh has its own $GITHUB_ENV-injectable channels: pin the host and + # drop any planted token BEFORE the identity check below, so a + # GH_HOST reroute cannot spoof `gh api user` and a planted GH_TOKEN + # cannot outrank the inline GITHUB_TOKEN. (git's channels are + # stripped in the hermetic preamble further down.) + export GH_HOST=github.com + unset GH_ENTERPRISE_TOKEN GH_TOKEN + # Point gh at a fresh empty config dir, not the default + # ~/.config/gh on the shared attacker-writable HOME — its + # config.yml can carry http_unix_socket and other transport + # reroutes no sweep here touches. mktemp -d gives an + # unpredictable path a watcher cannot pre-seed. + export GH_CONFIG_DIR="$(mktemp -d "${RUNNER_TEMP}/autofix-gh-config.XXXXXX")" + 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}" + # Take this PAT-bearing step off every mutable host git surface — + # both the shared config FILES and git's ENV channels — keep this + # block byte-identical to its twin in 'Push and report' (the + # contract test pins them equal). File scopes: the pool shares one + # HOME across ~27 runner registrations and review-address fans out + # max-parallel, so a concurrent job can rewrite ~/.gitconfig inside + # this step's sweep->push window (a URL-scoped sslVerify=false there + # overrides the -c pin below over real TLS); redirect global/system + # to a per-run throwaway (as the gates do) so the push reads neither. + # Env channels: branch code in an earlier step of THIS job can inject + # env through $GITHUB_ENV, and several channels OUTRANK file config or + # bypass it entirely — pin PATH to the staged trusted value and drop + # LD_PRELOAD/LD_AUDIT/LD_LIBRARY_PATH first (else a swapped + # git/sha256sum/bash defeats the digest gate below), then strip + # GIT_CONFIG_COUNT/_PARAMETERS (command-line-precedence config), + # GIT_ALLOW_PROTOCOL (env twin of protocol.allow — arms ext::), + # GIT_SSL_NO_VERIFY/GIT_SSL_CAINFO (override the sslVerify pin over + # real TLS), GIT_PROXY_COMMAND, GIT_EXEC_PATH (transport-helper + # binary), GIT_DIR/GIT_WORK_TREE/GIT_COMMON_DIR/GIT_OBJECT_DIRECTORY/ + # GIT_ALTERNATE_OBJECT_DIRECTORIES/GIT_SHALLOW_FILE (repoint the repo + # git reads and pushes), GIT_ASKPASS/GIT_SSH/GIT_SSH_COMMAND + # (credential/exec hijack). The throwaway global uses an + # unpredictable mktemp path so a same-user watcher cannot re-plant + # http.proxy/sslCAInfo into a fixed literal after the seed. All + # probe-verified in the #8961 review. + export PATH="${TRUSTED_PATH}" + unset LD_PRELOAD LD_AUDIT LD_LIBRARY_PATH \ + GIT_CONFIG_PARAMETERS GIT_ALLOW_PROTOCOL GIT_PROXY_COMMAND \ + GIT_SSL_NO_VERIFY GIT_SSL_CAINFO GIT_EXEC_PATH GIT_DIR \ + GIT_WORK_TREE GIT_COMMON_DIR GIT_OBJECT_DIRECTORY \ + GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_SHALLOW_FILE \ + GIT_ASKPASS GIT_SSH GIT_SSH_COMMAND + export GIT_CONFIG_COUNT=0 + export GIT_TERMINAL_PROMPT=0 + export GIT_CONFIG_SYSTEM=/dev/null + export GIT_CONFIG_GLOBAL="$(mktemp "${RUNNER_TEMP}/autofix-pat-gitconfig.XXXXXX")" + git config --file "${GIT_CONFIG_GLOBAL}" safe.directory "$(pwd)" + # Host hygiene + LOCAL .git/config scrub (the throwaway global above + # covers only the global scope, not the highest-precedence local + # file the branch/agent can plant). The staged copy's trusted-base + # provenance holds at cp time only — RUNNER_TEMP is writable by that + # same branch code — so verify the digest the staging step recorded + # in GITHUB_OUTPUT (unreachable from a disk write) before executing. + # Never run the script from the working tree; it holds the branch. + echo "${RESANITIZE_SHA256} ${RUNNER_TEMP}/resanitize-git-config.sh" | sha256sum -c - > /dev/null + bash "${RUNNER_TEMP}/resanitize-git-config.sh" + 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. The leading + # empty credential.helper RESETS the inherited helper list first: + # helpers run in config order and the first to answer wins, so a + # helper planted at any earlier scope would otherwise see the + # request (and the env) before ours answers — probe-verified in + # the #8961 review. http.sslVerify pins the transport: a kept + # http.proxy plus a planted sslVerify=false would otherwise let + # an interceptor read the credential off the wire. + git -c http.sslVerify=true -c credential.helper= -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 failure.zh.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.' + REASON_ZH='不会对该 issue 再做自动尝试。' + # Same hygiene as the PR-lane DETAIL_FILE excerpt: -c drops a + # partial multi-byte sequence the byte-level head -c may have + # split, and the markup neutralization stops a failure.md quoting + # HTML whose opener sits before the 1500-byte cut and closer + # after it (`' "${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
\n中文说明\n\n🚫 未接管:本 PR 带有 `%s` 标签,其优先级高于接管。请先移除 `%s`,再执行 `%s`。\n\n
\n\n' "${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
\n中文说明\n\n🚫 托管需要对 fork 分支的推送权限:请在 PR 上勾选 “Allow edits from maintainers” 后重新执行 `%s`。(组织账号的 fork 无法勾选 —— 维护者可改用领养:将 head 快照为本仓库分支后接管。)\n\n
\n\n' "${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
\n中文说明\n\n🚫 未接管:fork 托管要求 PR 作者在本仓库持有 write 及以上权限(作者 `%s` 当前为:`%s`)。维护者可改用领养:将 head 快照为本仓库分支并另开 PR(commit 署名保留),再对新 PR 执行接管。\n\n
\n\n' "${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}")" + # The round seed rides as its OWN marker on a separate line, NEVER as + # a field inside ''. That literal is + # matched with jq `contains()` — closing '-->' included — at seven + # read sites: four here (the ack dedup, the scan's first-pickup + # dedup, and the two REARM_KEY window readers) and three in + # qwen-fleet-shepherd.yml (the paused/resume detector). Appending a + # field would silently break all seven: the window key would fall + # back to an OLDER engage ack, so the round counter would read a dead + # window, and the shepherd would stop seeing the engage as a resume + # signal and age out a PR that was just re-armed. Same reasoning, and + # the same shape, as the autofix-redcheck marker. + # Rendered EN/ZH too, because the ack otherwise reports + # "round 4/100" on its first managed round and reads like a bug. + FROM_MARKER='' + FROM_NOTE='' + FROM_NOTE_ZH='' + FROM_NOTE_REARM='' + FROM_NOTE_REARM_ZH='' + # A seeded RE-ARM must not keep the unseeded fresh-window clause: + # the seed makes the earlier rounds count toward the cap (they ARE + # the seed), and on a re-arm they were typically already-managed + # rounds, not pre-takeover review — both wordings flip below. + REARM_FRESH_CLAUSE=' (previous rounds no longer count toward the cap)' + REARM_FRESH_CLAUSE_ZH='(此前轮次不再计入上限)' + if [[ -n "${CMD_FROM}" && "${CMD_FROM}" =~ ^[0-9]{1,2}$ && "${CMD_FROM}" != '0' ]]; then + FROM_MARKER="$(printf '\n' "${CMD_FROM}")" + REARM_FRESH_CLAUSE=' (earlier rounds count toward the cap only via this seed)' + REARM_FRESH_CLAUSE_ZH='(此前轮次仅通过该种子计入上限)' + FROM_REMAIN="$(( CRITICAL_ONLY_AFTER_ROUND > CMD_FROM ? CRITICAL_ONLY_AFTER_ROUND - CMD_FROM : 0 ))" + FROM_NOTE="$(printf ' This window'"'"'s round counter starts at %s (the rounds this PR spent in review before takeover), so the Critical-only brake engages after %s more change-producing round(s) instead of a full fresh %s.' "${CMD_FROM}" "${FROM_REMAIN}" "${CRITICAL_ONLY_AFTER_ROUND}")" + FROM_NOTE_ZH="$(printf '本窗口轮次计数从 %s 起算(即本 PR 托管前已进行的评审轮数),因此再经过 %s 个产生改动的轮次即进入 Critical-only,而非重新计满 %s 轮。' "${CMD_FROM}" "${FROM_REMAIN}" "${CRITICAL_ONLY_AFTER_ROUND}")" + FROM_NOTE_REARM="$(printf ' This window'"'"'s round counter restarts at %s (rounds already spent on this PR), so the Critical-only brake engages after %s more change-producing round(s) instead of a full fresh %s.' "${CMD_FROM}" "${FROM_REMAIN}" "${CRITICAL_ONLY_AFTER_ROUND}")" + FROM_NOTE_REARM_ZH="$(printf '本窗口轮次计数从 %s 重启(即本 PR 已消耗的轮次),因此再经过 %s 个产生改动的轮次即进入 Critical-only,而非重新计满 %s 轮。' "${CMD_FROM}" "${FROM_REMAIN}" "${CRITICAL_ONLY_AFTER_ROUND}")" + fi + 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. + # Body built ONCE so the retry posts byte-identical text. + # Same one-retry shape as the engage post below — the seed + # marker's only copy rides in this body too — but the final + # fallback is LOUD: nothing heals a missing re-arm (the scan + # heals only engage-less PRs, and the pre-existing engage ack + # suppresses the dedup), and a 're-armed' claim plus the + # stale-escalation cleanup must not follow a window reset that + # never landed (R7-7). + REARM_BODY="$(printf '🔄 Takeover re-armed: the round counter starts a fresh window%s; management continues.%s\n\n
\n中文说明\n\n🔄 已重新武装:轮次计数开启新窗口%s,托管继续。%s\n\n
\n\n%s' "${REARM_FRESH_CLAUSE}" "${FROM_NOTE_REARM}" "${REARM_FRESH_CLAUSE_ZH}" "${FROM_NOTE_REARM_ZH}" "${FROM_MARKER}")" + gh pr comment "${PR}" --repo "${REPO}" --body "${REARM_BODY}" \ + || { sleep 5; gh pr comment "${PR}" --repo "${REPO}" --body "${REARM_BODY}"; } \ + || { echo "::error::re-arm ack comment failed on #${PR} after one retry — the round window was NOT reset and no seed landed; re-run the command"; exit 1; } + echo "🔄 re-armed ${TAKEOVER_LABEL} window on #${PR}" + # Management resumed — the escalation label is stale. 404 is + # the common case (the PR was never paused). + if ! NH_ERR="$(gh api -X DELETE "repos/${REPO}/issues/${PR}/labels/$(jq -rn --arg l "${NEEDS_HUMAN_LABEL}" '$l|@uri')" 2>&1)"; then + [[ "${NH_ERR}" == *"HTTP 404"* ]] || echo "::warning::#${PR}: ${NEEDS_HUMAN_LABEL} removal failed — ${NH_ERR}" + fi + else + # REST for consistency and runner-version independence: `gh pr + # edit`'s GraphQL lookup requests + # repository.pullRequest.projectCards, which GitHub rejects on + # the gh builds that still send that query (demonstrated on the + # ECS pool — see pr-self-report-label.yml). This job runs on + # ubuntu-latest, where the command still worked; REST behaves + # the same on every runner image. + # Idempotent create first, with the label's real color: the + # REST add would silently create a missing label with a RANDOM + # color (gh pr edit failed loud there), and this was the one + # POST site without the guard its siblings carry + # (pr-self-report-label.yml creates; repo-hygiene.yml probes). + gh label create "${TAKEOVER_LABEL}" --repo "${REPO}" --color '1D76DB' \ + --description 'Summon the autofix loop to manage this PR (remove to release; needs triage+)' \ + 2> /dev/null || true + gh api -X POST "repos/${REPO}/issues/${PR}/labels" -f "labels[]=${TAKEOVER_LABEL}" > /dev/null + 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 + # Body built ONCE so the retry posts byte-identical text. + # One retry before the heal-path warning: the seed marker's + # only copy lives in this body, and the heal ack has no slot + # to recover it — a transient 5xx must not silently un-seed + # the window. + ENGAGE_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%s Remove the `%s` label (or comment `%s stop`) to release.\n\n
\n中文说明\n\n🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。%s%s移除 `%s` 标签(或评论 `%s stop`)即可释放。\n\n
\n\n%s' "${FORK_NOTE}" "${FROM_NOTE}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${FORK_NOTE_ZH}" "${FROM_NOTE_ZH}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${FROM_MARKER}")" + gh pr comment "${PR}" --repo "${REPO}" --body "${ENGAGE_BODY}" \ + || { sleep 5; gh pr comment "${PR}" --repo "${REPO}" --body "${ENGAGE_BODY}"; } \ + || echo "::warning::engage ack comment failed on #${PR}; the scan's first-pickup ack heals it" + # Engaged (possibly re-engaging an auto-released PR) — the + # escalation label is stale. 404 is the common case. + if ! NH_ERR="$(gh api -X DELETE "repos/${REPO}/issues/${PR}/labels/$(jq -rn --arg l "${NEEDS_HUMAN_LABEL}" '$l|@uri')" 2>&1)"; then + [[ "${NH_ERR}" == *"HTTP 404"* ]] || echo "::warning::#${PR}: ${NEEDS_HUMAN_LABEL} removal failed — ${NH_ERR}" + fi + fi + else + if [[ "${HAS}" != 'true' ]]; then + echo "ℹ️ #${PR} does not carry ${TAKEOVER_LABEL} — nothing to do" + else + # REST for the same reason as the add above; the label name is a + # path segment and contains a slash, so it must be URI-encoded. + # A concurrent removal between the presence check and this + # DELETE already reached the end state — the 404 must not abort + # the step and drop the release ack below. Other failures (403, + # 5xx, network) also must not drop the ack — a later + # `/takeover stop` retries the removal — but must not disappear + # silently either: masked, the ack reads "released" while the + # loop keeps managing the PR. + # The 404-tolerance block is pinned byte-identical to the other + # workflows' label DELETE (a contract test), so REMOVED_OK — + # whether the takeover release actually LANDED — is derived + # AFTER the idiom from the captured stream: gh api prints the + # remaining-labels JSON body on success (even '[]'), while a + # failure carries "HTTP " in the error text. 404 = + # already off (landed); any other HTTP error = the release did + # NOT land and the needs-human removal below must NOT run + # (R4-32) — or the PR keeps the takeover label (still capped, + # nothing manages it now) while losing the only filterable + # escalation state. The flag is keyed on the EXIT STATUS, with + # one text-derived exception: a failed DELETE whose error + # carries the exact "HTTP 404" token is the already-off case. + # The match must stay that precise token, not a bare "404" + # substring: transport failures embed the request URL — a PR + # number containing 404 would flip the classification — while + # no transport error carries an "HTTP" token (R6-1/R6-19). + LBL_DEL_FAILED=false + if ! REMOVE_ERR="$(gh api -X DELETE "repos/${REPO}/issues/${PR}/labels/$(jq -rn --arg l "${TAKEOVER_LABEL}" '$l|@uri')" 2>&1)"; then + [[ "${REMOVE_ERR}" == *"HTTP 404"* ]] || { LBL_DEL_FAILED=true; echo "::warning::#${PR}: ${TAKEOVER_LABEL} removal failed — ${REMOVE_ERR}"; } + fi + REMOVED_OK=true + if [[ "${LBL_DEL_FAILED}" == "true" ]]; then + REMOVED_OK=false + fi + if [[ "${REMOVED_OK}" == "true" ]]; then + echo "🏷️ removed ${TAKEOVER_LABEL} from #${PR}" + else + # R7-7: the success claim must not fire when the DELETE did + # not land — the motivating fix for pr-self-report-label.yml + # in this very PR is precisely this lying-log shape. + echo "⚠️ #${PR}: ${TAKEOVER_LABEL} removal did not land — the next /takeover stop retries" + fi + # Released means a human is driving — the escalation label is + # stale. Remove it only when the release landed (REMOVED_OK) + # AND the PR is not frozen by skip (a frozen PR must keep its + # only filterable escalation state — R4-3). 404 is the common + # case (never paused). + SKIP_STATE="$(jq -r --arg t "${SKIP_LABEL}" '[.labels[].name] | index($t) != null' <<< "${PR_INFO}")" + if [[ "${REMOVED_OK}" != "true" ]]; then + echo "::warning::#${PR}: release did not land — keeping ${NEEDS_HUMAN_LABEL}; the next /takeover stop retries both" + elif [[ "${SKIP_STATE}" == "true" ]]; then + echo "🧭 ${NEEDS_HUMAN_LABEL} removal skipped: ${SKIP_LABEL} present on #${PR}" + elif ! NH_ERR="$(gh api -X DELETE "repos/${REPO}/issues/${PR}/labels/$(jq -rn --arg l "${NEEDS_HUMAN_LABEL}" '$l|@uri')" 2>&1)"; then + [[ "${NH_ERR}" == *"HTTP 404"* ]] || echo "::warning::#${PR}: ${NEEDS_HUMAN_LABEL} removal failed — ${NH_ERR}" + fi + # 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 [[ "${REMOVED_OK}" != "true" ]]; then + # The DELETE did not land — the label is still on and the + # loop still manages this PR. A "released" ack (and its + # marker) would record a release that never happened: no + # unlabeled event fires, nothing retries, and no human + # re-issues the command. Own the failure and name the retry. + REL_BODY="$(printf '⚠️ Takeover release did not land: removing the `%s` label failed transiently, so it is still present and the autofix loop keeps managing this PR under the round cap. Comment `%s stop` to retry the release.\n\n
\n中文说明\n\n⚠️ 释放未生效:移除 `%s` 标签时瞬时失败,标签仍在,autofix 循环仍按轮次上限继续托管此 PR。评论 `%s stop` 可重试释放。\n\n
\n\n' "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}")" + elif [[ "${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
\n中文说明\n\n👋 接管模式结束。本 bot 创建的 PR 同时带有 `%s`,已完全退出常规 bot 管理 —— 移除该标签前不会有任何介入。\n\n
\n\n' "${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
\n中文说明\n\n👋 接管模式结束:提升的轮次上限不再适用。这是 bot 创建的 PR,常规 bot 管理仍将继续(严格上限;如需完全退出请打 `%s`)。重新打上 `%s` 标签(或评论 `%s`)可恢复提升上限。\n\n
\n\n' "${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
\n中文说明\n\n👋 已释放:autofix 循环不再介入此 PR(在飞的一轮如有,将完成其有界工作)。重新打上 `%s` 标签(或评论 `%s`)即可再次接管。\n\n
\n\n' "${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
\n中文说明\n\n\U0001F504 已重新武装 AutoFix。下一次扫描会从头重新读取本 PR 的反馈,轮次计数也已重置。未删除任何内容 —— 本标记使其上方的评估标记失效。\n\n
\n\n')" + echo "🔄 re-armed PR #${PR}" + # Management resumed — the escalation label is stale. 404 is the + # common case (the PR was never paused). Remove it only when the + # PR will actually be MANAGED after this re-arm (R5-2): the scan + # candidate population is bot-authored or takeover-labeled PRs + # only, so on an auto-released human PR (no takeover label, not + # bot-authored) /retry posts a marker nothing will act on — the + # label must stay as the only filterable escalation state. Skip + # also wins over re-arm everywhere (a frozen PR keeps its label), + # and the read FAILS CLOSED (mirrors takeover-ack's exit-1). + if ! RETRY_INFO="$(gh pr view "${PR}" --repo "${REPO}" --json labels,author 2> /dev/null)"; then + echo "::warning::#${PR}: label state unreadable — keeping ${NEEDS_HUMAN_LABEL} (fail closed)" + elif [[ "$(jq -r --arg t "${SKIP_LABEL}" '[.labels[]?.name] | index($t) != null' <<< "${RETRY_INFO}")" == "true" ]]; then + echo "🧭 ${NEEDS_HUMAN_LABEL} removal skipped: ${SKIP_LABEL} present on #${PR}" + elif [[ "$(jq -r --arg ab "${AUTOFIX_BOT}" --arg tk "${TAKEOVER_LABEL}" ' + ((.author.login // "") == $ab) or (([.labels[]?.name] | index($tk)) != null) + ' <<< "${RETRY_INFO}")" != "true" ]]; then + echo "🧭 keeping ${NEEDS_HUMAN_LABEL} on #${PR}: nothing manages it until re-engaged (no takeover label, not bot-authored)" + elif ! NH_ERR="$(gh api -X DELETE "repos/${REPO}/issues/${PR}/labels/$(jq -rn --arg l "${NEEDS_HUMAN_LABEL}" '$l|@uri')" 2>&1)"; then + [[ "${NH_ERR}" == *"HTTP 404"* ]] || echo "::warning::#${PR}: ${NEEDS_HUMAN_LABEL} removal failed — ${NH_ERR}" + fi + + takeover-ack: + needs: 'route' + if: |- + ${{ needs.route.outputs.takeover_ack != '' }} + # A delayed or overlapping ack must not race a newer cycle's ack: + # queued (never cancelled) per-PR execution serializes the runs so + # the staleness reads below see each predecessor's writes (mirrors + # takeover-command). + concurrency: + group: 'qwen-autofix-takeover-ack-${{ needs.route.outputs.ack_pr }}' + cancel-in-progress: false + 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 + # R2-4: a delayed release ack can run AFTER takeover was + # re-applied and the new cycle paused again — a live takeover + # label means this ack's premise is stale. Post nothing and + # touch nothing: the new cycle's own events produce their own + # acks, while a stale run here would delete the new cycle's + # needs-human and claim a release while takeover is live. + if [[ "${ACK}" == 'released' && "$(jq -r --arg t "${TAKEOVER_LABEL}" '[.labels[]?.name] | index($t) != null' <<< "${PR_STATE_INFO}")" == "true" ]]; then + echo "⚠️ released ack skipped on #${PR}: ${TAKEOVER_LABEL} re-applied since the release (stale ack)" + exit 0 + fi + # R2-4 mirror for the engaged direction: a delayed engaged ack — a + # red run re-run later, or overlapping runs from quick label + # toggles — must not DELETE a fresh cycle's needs-human and must + # not post a marker that resets the round window (REARM_KEY is + # the newest engage marker). Label absent → the engagement ended + # after this event. A bot engage ack at/after the newest labeled + # event → the current cycle is already acked. Both skip without + # posting or touching labels; an unreadable history skips too + # (fail closed — the scan's NEED_ENGAGE_ACK dedup heals a + # genuinely missed ack, while nothing heals a stale marker). + if [[ "${ACK}" == 'engaged' ]]; then + if [[ "$(jq -r --arg t "${TAKEOVER_LABEL}" '[.labels[]?.name] | index($t) != null' <<< "${PR_STATE_INFO}")" != "true" ]]; then + echo "⚠️ engaged ack skipped on #${PR}: ${TAKEOVER_LABEL} removed since the label event (stale ack)" + exit 0 + fi + if ! ack_ic="$(gh api "repos/${REPO}/issues/${PR}/comments" --paginate 2> /dev/null)"; then + echo "::warning::engaged ack skipped on #${PR}: comment history unreadable (fail closed)" + exit 0 + fi + if ! ack_ev="$(gh api "repos/${REPO}/issues/${PR}/events" --paginate 2> /dev/null)"; then + echo "::warning::engaged ack skipped on #${PR}: event history unreadable (fail closed)" + exit 0 + fi + ack_aced_ts="$(jq -rs --arg ab "${AUTOFIX_BOT}" ' + add // [] | [ .[] | select((.user.login // "") == $ab) + | select((.body // "") | contains("")) + | .created_at ] | max // ""' <<< "${ack_ic}")" + ack_labeled_ts="$(jq -rs --arg tl "${TAKEOVER_LABEL}" ' + add // [] | [ .[] | select((.event // "") == "labeled") + | select((.label.name // "") == $tl) + | .created_at ] | max // ""' <<< "${ack_ev}")" + if [[ -n "${ack_aced_ts}" && ! "${ack_labeled_ts}" > "${ack_aced_ts}" ]]; then + echo "⚠️ engaged ack skipped on #${PR}: a bot engage ack already landed after the newest ${TAKEOVER_LABEL} label event (stale ack)" + exit 0 + fi + 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
\n中文说明\n\n🚫 未接管:循环只管理以 `main` 为 base 的 PR,而本 PR 的 base 是 `%s`。堆叠 PR 会随 base 分支移动,因此“自上一轮以来的新反馈”与 base 冲突处理都无法良定义。两条路:待 base 的 PR 合入后把本 PR 改为面向 `main` —— `%s` 标签予以保留,扫描按标签枚举,下一次扫描即会自动接管,无需重新打标签;或改为接管 base 那个 PR。\n\n
\n\n' "${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
\n中文说明\n\n🚫 已打接管标签但未生效:本 PR 带有 `%s`,其优先级高于接管,循环不会介入。移除 `%s` 后才会接管。\n\n
\n\n' "${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
\n中文说明\n\n🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。移除 `%s` 标签(或评论 `%s stop`)即可释放。\n\n
\n\n' "${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
\n中文说明\n\n👋 接管模式结束。本 bot 创建的 PR 同时带有 `%s`,已完全退出常规 bot 管理 —— 移除该标签前不会有任何介入。\n\n
\n\n' "${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
\n中文说明\n\n👋 接管模式结束:提升的轮次上限不再适用。这是 bot 创建的 PR,常规 bot 管理仍将继续(严格上限;如需完全退出请打 `%s`)。重新打上 `%s` 标签(或评论 `%s`)可恢复提升上限。\n\n
\n\n' "${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
\n中文说明\n\n👋 已释放:autofix 循环不再介入此 PR(在飞的一轮如有,将完成其有界工作)。重新打上 `%s` 标签(或评论 `%s`)即可再次接管。\n\n
\n\n' "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}")" + fi + # The escalation label goes stale on a real engage or any release + # (a human is driving again). NOT on base-refused (nothing changed) + # and NOT on skip-blocked (management never resumed) — and a + # RELEASE onto a skip-frozen PR also keeps the label: nothing + # manages or restores that PR, so it must stay in the needs-human + # filter (R4-3). 404 is the common case (the PR was never paused). + # Runs BEFORE the ack comment: the state change already happened + # (the human toggled the label — the comment is purely + # informational), so a transient comment failure aborting this step + # under set -e must not strand the stale label. + if [[ ( "${ACK}" == 'released' || "${ACK}" == 'engaged' ) && "${HAS_SKIP}" != 'true' ]]; then + if ! NH_ERR="$(gh api -X DELETE "repos/${REPO}/issues/${PR}/labels/$(jq -rn --arg l "${NEEDS_HUMAN_LABEL}" '$l|@uri')" 2>&1)"; then + [[ "${NH_ERR}" == *"HTTP 404"* ]] || echo "::warning::#${PR}: ${NEEDS_HUMAN_LABEL} removal failed — ${NH_ERR}" + fi + 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 }}' + enum_failed: '${{ steps.scan.outputs.enum_failed }}' + 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 }}' + REVIEW_SENDER: '${{ needs.route.outputs.review_sender }}' + DISPATCH_SOURCE: "${{ github.event_name == 'workflow_dispatch' && inputs.source || '' }}" + run: |- + # Every lane that reaches this scan is supposed to hold the PAT: + # route now declines the one event GitHub is known to run without + # secrets (a fork PR's own review) before it can set do_review. An + # empty PAT here is therefore a deleted or renamed secret, or a lane + # nobody has modelled yet — neither repaired by a later tick, and + # neither visible to a job `if:`, which cannot read the `secrets` + # context at all. + # It must not be quiet. With no credential every `gh` call below + # answers as if the repository held no PRs, so the scan would walk an + # empty candidate list and report a healthy fleet of zero — green, + # forever, while the whole loop is dead. + if [[ -z "${GITHUB_TOKEN}" ]]; then + echo "::error::CI_DEV_BOT_PAT is empty in this ${EVENT_NAME} scan — every API call would be unauthenticated and the scan would report an empty fleet; check the repository secret" + exit 1 + fi + + # 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 '\n\n⛔ **AutoFix blocked** — takeover admission stopped at `%s`, so no work was started. [View run](%s/%s/actions/runs/%s). %s\n\n
\n中文说明\n\n⛔ **AutoFix 已阻塞** —— takeover 准入停在 `%s`,因此本轮未开始处理。[查看运行](%s/%s/actions/runs/%s)。%s\n\n
' \ + "${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 '' \ + '.[][] | 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 + + # Review-workflow id, resolved ONCE per scan for the review-in-flight + # gate below (#8888): during qwen-code-pr-review.yml's 10-minute + # delay-automatic-review wait the review-pr JOB (and thus its + # check-run in statusCheckRollup) does not exist yet, so the rollup + # alone misses a just-triggered review; the runs API sees the run + # by head SHA before its job starts. Empty on lookup failure — the + # gate then degrades to the rollup check only (fail-open here, + # though the BUSY_PRS enumeration below is fail-closed). + REVIEW_WF_ID="$(gh api "repos/${REPO}/actions/workflows/qwen-code-pr-review.yml" --jq '.id' 2> /dev/null || echo '')" + REVIEW_RUNS_JSON='{"workflow_runs":[]}' + if [[ -n "${REVIEW_WF_ID}" ]] \ + && ! REVIEW_RUNS_JSON="$(gh api "repos/${REPO}/actions/workflows/${REVIEW_WF_ID}/runs?per_page=100" 2> /dev/null)"; then + REVIEW_RUNS_JSON='{"workflow_runs":[]}' + 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. + # + # FAIL-CLOSED: any enumeration failure (the run list, or one run's + # jobs view) empties THIS scan's candidate set. Measured 2026-08-16 + # (#9296): silently swallowing these errors re-dispatched PRs whose + # legs had been running or queued for 3-12 minutes; each duplicate + # burned one build-cli (~5 min) before cancelling a queued sibling + # leg through the per-PR group's latest-wins queue. A duplicate + # costs far more than one skipped scan — the next tick re-inspects + # with fresh reads. Only an EXPLICIT dispatch (workflow_dispatch + # with a PR number) keeps its override semantics and is NOT emptied + # by an enumeration failure — FORCED_PR is ALSO set for trusted + # pull_request_review scans, which are not explicit dispatches. The + # step also emits enum_failed: the scan exits 0, and an emptied set + # would otherwise read exactly like "no PR needs work" and flip the + # scheduled issue phase ON against its declared ordering. The + # dispatch-pending status check below additionally covers the + # window where the leg does not exist yet (behind build-cli). + BUSY_PRS=' ' + BUSY_ENUM_OK=1 + LIVE_RUNS='' + BUSY_ENUM_ERR="$(mktemp)" + for LIVE_STATUS in in_progress queued; do + if ! PART="$(gh run list --repo "${REPO}" --workflow qwen-autofix.yml \ + --status "${LIVE_STATUS}" --limit 50 --json databaseId \ + --jq '.[].databaseId' 2>> "${BUSY_ENUM_ERR}")"; then + BUSY_ENUM_OK=0 + break + fi + LIVE_RUNS="${LIVE_RUNS}${PART}"$'\n' + done + if [[ "${BUSY_ENUM_OK}" == '1' ]]; then + while IFS= read -r LIVE_RUN; do + [[ -z "${LIVE_RUN}" ]] && continue + if ! BUSY_OUT="$(gh run view "${LIVE_RUN}" --repo "${REPO}" --json jobs \ + --jq '.jobs[] | select(.status != "completed") | .name | capture("^review-address \\((?[0-9]+),") | .pr' 2>> "${BUSY_ENUM_ERR}")"; then + BUSY_ENUM_OK=0 + break + fi + while IFS= read -r BUSY; do + [[ -n "${BUSY}" ]] && BUSY_PRS="${BUSY_PRS}${BUSY} " + done <<< "${BUSY_OUT}" + done <<< "$(sort -u <<< "${LIVE_RUNS}")" + fi + if [[ "${BUSY_ENUM_OK}" != '1' ]]; then + BUSY_ENUM_ERR_TAIL="$(tail -c 200 "${BUSY_ENUM_ERR}" 2> /dev/null | tr '\r\n' ' ')" + echo "::warning::busy-PR enumeration failed (run list or jobs view unreadable) — failing closed: no scan targets dispatched this pass${BUSY_ENUM_ERR_TAIL:+ — last error: ${BUSY_ENUM_ERR_TAIL}}" + fleet_row '-' 'fail-closed' "busy enumeration unreadable; scan dispatch skipped this pass (next tick retries)${BUSY_ENUM_ERR_TAIL:+ — last error: ${BUSY_ENUM_ERR_TAIL}}" + echo "enum_failed=true" >> "${GITHUB_OUTPUT}" + if [[ -z "${FORCED_PR}" || "${EVENT_NAME}" != 'workflow_dispatch' ]]; then + CANDIDATES='' + fi + fi + rm -f "${BUSY_ENUM_ERR}" + [[ "${BUSY_PRS}" != ' ' ]] && echo "🚧 address in flight/queued for PR(s):${BUSY_PRS}" + + # Cutoff for the dispatch-pending marker check in the loop below. + DISPATCH_CUTOFF="$(date -u -d "${DISPATCH_STATUS_TTL_MINUTES} minutes ago" +%Y-%m-%dT%H:%M:%SZ)" + + # 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), labels (the effective round + # cap), and author (the cap branch's bot-author exemption) — + # avoids extra round-trips per candidate PR. + PR_META="$(gh pr view "${PR}" --repo "${REPO}" \ + --json headRefName,headRefOid,statusCheckRollup,createdAt,labels,isCrossRepository,headRepositoryOwner,headRepository,author 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 + # Dispatch-pending marker: a scan that dispatched this PR within + # DISPATCH_STATUS_TTL_MINUTES may still be building its CLI + # bundle — its matrix leg does not exist yet, so the live-run + # busy enumeration above cannot see it. The emitting scan stamped + # a PENDING status on the PR head at dispatch (same-repo heads + # only), and the leg re-stamps it SUCCESS on checkout; treat a + # fresh PENDING as busy. Costs no extra API call — the rollup is + # already in PR_META. Unlike the in-memory busy skip this runs + # after the metadata fetch, so it consumes inspection budget; + # acceptable because the case is rare (a PR dispatched <30m ago). + if jq -e --arg ctx "${DISPATCH_STATUS_CONTEXT}" --arg cut "${DISPATCH_CUTOFF}" ' + [.statusCheckRollup[]? | select(.__typename == "StatusContext") + | select(.context == $ctx) | select(.state == "PENDING") + | select((.startedAt // "") > $cut)] | length > 0' <<< "${PR_META}" > /dev/null; then + echo "⏳ #${PR}: dispatch pending (marker fresher than ${DISPATCH_STATUS_TTL_MINUTES}m, leg not materialized yet) — skipping" + fleet_row "${PR}" 'busy' "dispatch-pending marker live (<${DISPATCH_STATUS_TTL_MINUTES}m)" + 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; 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}")" + + # Review-in-flight gate (#8888): NON_BLOCKING_CHECKS keeps an + # in-flight review-pr from blocking the FEEDBACK gate (its + # conclusion carries nothing the loop acts on — #7416), but every + # head mutation this scan can make (a stale-base update-branch, + # infra rerun, or address push later) is a synchronize event that + # cancels the in-flight review via qwen-code-pr-review.yml's + # cancel-in-progress, discarding up to ~3h of review work — the + # self-reinforcing cancellation loop of #8830 (three killed runs + # in one PR, two by merge-main). Its findings are also the very + # feedback the next round should batch with, so deferring the + # WHOLE round until the review lands loses nothing: the watermark + # is not advanced on a skip, so the feedback stays visible. This + # is deliberately SEPARATE from HAS_PENDING_CHECKS rather than a + # NON_BLOCKING_CHECKS revert: that gate ages checks out after + # PENDING_STALE_MIN and would also re-block on the review's + # conclusion, reintroducing #7416's median-49-minute wait. + REVIEW_PR_LIVE="$(jq -r ' + [ .[] + | select((((.status // .state // "") | IN("QUEUED", "IN_PROGRESS", "PENDING", "WAITING", "REQUESTED")) and ((.name // "") == "review-pr") and ((.workflowName // "") == "🧐 Qwen Pull Request Review"))) ] + | length > 0 + ' <<< "${CHECKS_JSON}")" + REVIEW_RUN_STARTED_AT="" + if [[ "${REVIEW_PR_LIVE}" != "true" && -n "${REVIEW_WF_ID}" && -n "${PR_HEAD_OID}" ]]; then + # Delay-window fallback: a review run parked BEFORE its job + # starts (the 10-minute environment wait) has no review-pr + # check-run yet, but a push now would still cancel it via + # synchronize. Only pull_request_target runs are cancelable — + # comment/review-triggered runs use per-run concurrency groups + # that a synchronize never cancels, so holding the round for + # one would defer autofix for nothing (R2-1). The scan fetched + # the newest run page once above; match by immutable head SHA or + # PR number, never by fork-controlled bare branch name. + REVIEW_RUN_STARTED_AT="$(jq -r --arg wf "${REVIEW_WF_ID}" --arg pr "${PR}" --arg head "${PR_HEAD_OID}" ' + [ .workflow_runs[]? + | select((.workflow_id | tostring) == $wf) + | select((.event // "") == "pull_request_target") + | select((.status // "") | IN("queued", "waiting", "pending", "requested", "in_progress")) + | select(((.head_sha // "") == $head) or any(.pull_requests[]?; (.number | tostring) == $pr)) + | (.run_started_at // .created_at // "") ] + | map(select(. != "")) | sort | last // "" + ' <<< "${REVIEW_RUNS_JSON}")" + if [[ -n "${REVIEW_RUN_STARTED_AT}" ]]; then + REVIEW_PR_LIVE="true" + fi + fi + + # 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}" && "${REVIEW_PR_LIVE}" != "true" ]] && 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). + # The dispatch-pending marker is exempted by context: it is this + # loop's own StatusContext busy signal (no .workflowName/.name, so + # it passes the filters above) and its dedicated TTL check above is + # the authority on it — the 330-minute horizon here would keep a + # stranded marker blocking long past its TTL. + HAS_PENDING_CHECKS="$(jq -r --arg cut "${PENDING_CUTOFF}" \ + --argjson nonblocking "${NON_BLOCKING_CHECKS}" \ + --arg ctx "${DISPATCH_STATUS_CONTEXT}" ' + [ .[] + | 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((.context // "") != $ctx) + | 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 + if [[ "${REVIEW_PR_LIVE}" == "true" ]]; then + echo "🔍 #${PR}: review-pr in flight on this head — holding this round so the push cannot cancel it (#8888)" + fleet_row "${PR}" 'review-in-flight' 'review-pr live on head; round deferred' + # Ack-on-defer (#8888): a real-time human review routed this + # scan straight here, but the gate holds every mutation — from + # the human's seat the bot read their review and then did + # nothing. Say so once per in-flight review (the marker embeds + # the review-pr check's startedAt, so a NEW review re-arms the + # ack). The feedback itself needs no ack: the watermark is not + # advanced on this skip, so the next scan after the review + # lands still sees and addresses it. Cron scans stay silent — + # nothing arrived in them that a human is waiting on, and the + # fleet table already shows the deferral. + if [[ "${EVENT_NAME}" == 'pull_request_review' && "${DRY_RUN}" != "true" && "${REVIEW_SENDER}" != "${REVIEW_BOT}" ]]; then + REVIEW_STARTED_AT="$(jq -r ' + [ .[] + | select((((.status // .state // "") | IN("QUEUED", "IN_PROGRESS", "PENDING", "WAITING", "REQUESTED")) and ((.name // "") == "review-pr") and ((.workflowName // "") == "🧐 Qwen Pull Request Review"))) + | (.startedAt // "") + | select(. != "") ] | first // ""' <<< "${CHECKS_JSON}")" + [[ -z "${REVIEW_STARTED_AT}" ]] && REVIEW_STARTED_AT="${REVIEW_RUN_STARTED_AT}" + # An empty key (a queued check with no startedAt yet) would + # make the marker match EVERY future deferral — skip the ack + # this scan rather than arm a permanently-dead dedup. + if [[ -z "${REVIEW_STARTED_AT}" ]]; then + echo "🕐 #${PR}: deferred-review ack skipped: live review-pr check has no startedAt yet (queued); a later scan acks once it starts" + else + DEFER_ACKS="$(gh api "repos/${REPO}/issues/${PR}/comments" --paginate \ + | jq -r --arg ab "${AUTOFIX_BOT}" '.[] | select((.user.login // "") == $ab) | .body // ""' 2> /dev/null || true)" + if grep -qF "" <<< "${DEFER_ACKS}"; then + echo "🕐 #${PR}: deferred-review ack already posted for this review run" + 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::#${PR}: deferred-review ack skipped: PAT authenticates as '${SCAN_BOT_ACTOR}', expected ${AUTOFIX_BOT}" + else + gh pr comment "${PR}" --repo "${REPO}" --body "$(printf '🕐 Review received — an automatic review of the current head is still running, so this round is held until it lands (a push now would cancel it and discard its work, #8888). Your feedback stays queued for the next eligible round.\n\n
\n中文说明\n\n🕐 已收到评审 —— 当前 head 上仍有一轮自动 review 在运行,本轮暂缓(现在推送会取消该 review 并丢弃其工作,#8888)。反馈保持排队,等待下一次可运行的轮次处理。\n\n
\n\n' "${REVIEW_STARTED_AT}")" > /dev/null 2>&1 \ + || echo "::warning::#${PR}: deferred-review ack failed — the dedup marker is NOT posted (a later scan may ack again)" + fi + fi + fi + fi + 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("")) + | .created_at] | sort | last // ""' "${WORKDIR}/ic.json")" + PR_EVENTS_OK='' + if gh api "repos/${REPO}/issues/${PR}/events" --paginate 2> /dev/null \ + | jq -s 'add // []' > "${WORKDIR}/pr-events.json"; then + PR_EVENTS_OK=true + else + echo '[]' > "${WORKDIR}/pr-events.json" + fi + 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
\n中文说明\n\n🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。移除 `%s` 标签(或评论 `%s stop`)即可释放。\n\n
\n\n' "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}" "${TAKEOVER_LABEL}" "${TAKEOVER_COMMAND}")"; then + # Engaged — the escalation label is stale. 404 is the + # common case (the PR was never paused). + if ! NH_ERR="$(gh api -X DELETE "repos/${REPO}/issues/${PR}/labels/$(jq -rn --arg l "${NEEDS_HUMAN_LABEL}" '$l|@uri')" 2>&1)"; then + [[ "${NH_ERR}" == *"HTTP 404"* ]] || echo "::warning::#${PR}: ${NEEDS_HUMAN_LABEL} removal failed — ${NH_ERR}" + fi + # 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("") ] | .[] + | {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("")) + | .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("") ] | .[] + | {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 + # '' 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("")) + or ((.body // "") | contains(""))) + | .created_at ] | max // "none"' "${WORKDIR}/ic.json")" + # Seed for THIS window, from the ' from N' marker carried by + # the comment that IS the window key — so it is window-scoped for + # free, exactly like the key itself: a later /retry or a bare + # /takeover opens a window whose anchor has no marker and the seed + # returns to 0. Read by created_at equality against REARM_KEY, so a + # seed from a SUPERSEDED window can never leak into the live one. + # `scan` (not `capture`, which errors when absent) and `last` + # (a hand-written marker further down a bot comment loses to the + # workflow's own, which is always the final line). + ROUND_START="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg key "${REARM_KEY}" ' + [ .[] | select((.user.login // "") == $ab) + | select((.created_at // "") == $key) + | ((.body // "") | [ scan("") ] | .[] | .[0]) ] + | last // "0"' "${WORKDIR}/ic.json" 2> /dev/null || echo '0')" + [[ "${ROUND_START}" =~ ^[0-9]{1,2}$ ]] || ROUND_START=0 + # Clamp strictly below the effective cap. The seed must be able to + # bring the Critical-only brake forward; it must NEVER be able to + # park a PR at its round cap on the very round it is taken over + # (which would stop the loop instead of starting it), and a seed + # is honoured on any PR whose window anchor carries the marker — + # including one whose takeover label was later removed, dropping + # EFF_MAX_ROUNDS back to the strict 10. + if [[ "${EFF_MAX_ROUNDS:-0}" -gt 0 && "${ROUND_START}" -ge "${EFF_MAX_ROUNDS}" ]]; then + echo "🔢 #${PR}: round seed ${ROUND_START} clamped to $(( EFF_MAX_ROUNDS - 1 )) (effective cap ${EFF_MAX_ROUNDS})" + ROUND_START=$(( EFF_MAX_ROUNDS - 1 )) + fi + ROUND="$(jq -r --arg key "${REARM_KEY}" --argjson start "${ROUND_START}" 'map(select(.win == $key)) | map(.round) | max // $start' <<< "${MARKERS}")" + # No mention of the Critical-only threshold here, deliberately: the + # scan must stay ignorant of that brake. It keeps SELECTING fresh + # suggestions so a no-op report can still advance the watermark; + # only prepare hides them from the agent. A test pins the scan + # against the string. + [[ "${ROUND_START}" != '0' ]] && echo "🔢 #${PR}: window seeded at round ${ROUND_START} → effective round ${ROUND}/${EFF_MAX_ROUNDS}" + + # 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. fork-bridge dispatches are + # the one dispatch-shaped exception: they are fork-PR reviews + # laundered into dispatch form (a fork's review event carries no + # secrets), not an explicit human/shepherd dispatch — answering + # each one loudly would post one refusal per review on a capped + # fork PR, the exact #7836 spam this gate exists to prevent. + # But `source` is a public workflow_dispatch input any manual + # dispatch can set, so the silence is honored ONLY on positive + # proof of origin: a recent SUCCESSFUL fork-bridge run whose + # title names this exact PR (the bridge propagates the signal's + # run-name into its own title — both base-branch files, not + # fork-forgeable). The window is generous because route backlog + # can queue a dispatch for hours; the PR match, not the window, + # is what proves origin. Unverified → answered like any + # explicit dispatch. + FORK_BRIDGE_VERIFIED=false + if [[ "${DISPATCH_SOURCE}" == 'fork-bridge' ]]; then + BRIDGE_CUTOFF="$(date -u -d '360 minutes ago' +%Y-%m-%dT%H:%M:%SZ)" + if BRIDGE_RUNS="$(gh run list --repo "${REPO}" --workflow qwen-autofix-fork-bridge.yml --limit 20 --json conclusion,createdAt,displayTitle 2> /dev/null)" \ + && jq -e --arg pr "${PR}" --arg cutoff "${BRIDGE_CUTOFF}" ' + [ .[] + | select(.conclusion == "success" and ((.createdAt // "") >= $cutoff)) + | select((.displayTitle // "") | startswith("fork-bridge: fork-signal: PR \($pr) reviewed by ")) ] + | length > 0' <<< "${BRIDGE_RUNS}" > /dev/null; then + FORK_BRIDGE_VERIFIED=true + else + echo "::warning::fork-bridge provenance unverified for #${PR} — answering this dispatch like an explicit one" + fi + fi + if [[ -n "${FORCED_PR}" && "${FORCED_PR}" == "${PR}" && "${EVENT_NAME}" == 'workflow_dispatch' && "${FORK_BRIDGE_VERIFIED}" != 'true' ]]; 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
\n中文说明\n\n⏸️ 已拒绝本次调度:本 PR 的自动轮次上限已用完(%s/%s),循环不会介入——触发本次调度的事项(合并冲突、新反馈)仍未处理。评论 `%s` 可重置计数窗口,或 `%s` 获得更高的接管上限;随后下一次定时扫描会接手。\n\n
\n\n' "${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("")) + | select((.created_at // "") > $rt) ] | length' "${WORKDIR}/ic.json")" + # Release evidence = a takeover unlabeled EVENT at-or-newer + # than the window key. GitHub records it whenever the label + # comes off — `/takeover stop`, the ack job, or a manual UI + # removal — unlike the release-ack COMMENT, which both release + # paths tolerate losing (R4-1): the stop branch swallows a + # failed ack post, and the ack job's set -e aborts before it. + # A re-arm advances REARM_KEY past the event, re-enabling the + # label. Same-second ties resolve toward "released" — never + # re-escalate a completed release (R5-9). Fail closed: an + # unreadable event history suppresses the re-label rather than + # risk the ping-pong. + # A capped takeover candidate already paid for this paginated + # endpoint earlier in the same iteration (pr-events.json, + # gated by PR_EVENTS_OK) — reuse that fetch instead of paying + # it twice per capped takeover PR per scan. Non-takeover + # candidates keep the standalone fetch, and the fail-closed + # semantics ride the flag: the engage-side '[]' fallback must + # never read here as "no releases". + RELEASE_ACKED='' + if [[ "${HAS_TAKEOVER}" == "true" && "${PR_EVENTS_OK}" == "true" ]]; then + if ! cp "${WORKDIR}/pr-events.json" "${WORKDIR}/ev.json"; then + RELEASE_ACKED='unreadable' + fi + elif ! gh api "repos/${REPO}/issues/${PR}/events" --paginate 2> /dev/null | jq -s 'add // []' > "${WORKDIR}/ev.json"; then + RELEASE_ACKED='unreadable' + fi + if [[ "${RELEASE_ACKED}" != 'unreadable' ]]; then + RELEASE_ACKED="$(jq -r --arg tl "${TAKEOVER_LABEL}" --arg rt "${NOTICE_RT}" ' + [ .[] | select((.event // "") == "unlabeled") + | select((.label.name // "") == $tl) + | select((.created_at // "") >= $rt) ] | length' "${WORKDIR}/ev.json")" + fi + # A release only suppresses re-labeling for HUMAN-authored PRs. + # A bot-authored PR released from takeover returns to STANDARD + # management (its ack says so) — it is still managed, so at the + # strict cap it deserves the same notice + escalation label as + # any managed PR (R4-5). + IS_BOT_AUTHOR="$(jq -r --arg ab "${AUTOFIX_BOT}" '((.author.login // "") == $ab)' <<< "${PR_META}")" + if [[ "${DRY_RUN}" == "true" ]]; then + echo "🧪 DRY-RUN: would post cap-paused notice and apply ${NEEDS_HUMAN_LABEL} on #${PR}" + else + # 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. The read + # FAILS CLOSED (mirrors takeover-ack): an unreadable label + # state must not get a notice or the escalation label — + # collapsing the failure to '' would ignore a concurrently + # added skip for standard bot PRs. + LIVE_LABELS_JSON="$(gh pr view "${PR}" --repo "${REPO}" --json labels 2> /dev/null || echo '')" + LIVE_LABELS="$(jq -r '[.labels[]?.name] | join(" ")' <<< "${LIVE_LABELS_JSON}" 2> /dev/null || echo '')" + if [[ -z "${LIVE_LABELS_JSON}" ]]; then + echo "🧭 cap notice skipped: label state unreadable (fail closed) on #${PR}" + continue + fi + 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}" + elif [[ "${RELEASE_ACKED}" != "0" && "${IS_BOT_AUTHOR}" != "true" ]]; then + # R6-4: an events-API outage is NOT a release — report it + # as what it is, or the log lies during an incident. + if [[ "${RELEASE_ACKED}" == "unreadable" ]]; then + echo "🧭 cap label/notice skipped: release history unreadable (fail closed) on #${PR}" + else + echo "🧭 cap label/notice skipped: PR was released after its last re-arm (#${PR})" + fi + else + # The escalation label rides EVERY cap detection, noticed + # or not: the once-per-window dedup suppresses repeat + # comments, but the label is what makes a paused PR + # filterable (the shepherd's auto-release ages from the + # cap notice itself, not from the label) — and applying + # it unconditionally backfills the already-paused fleet + # via the scan rotation after this ships (idle backoff: + # expect hours, not the first scan). + gh label create "${NEEDS_HUMAN_LABEL}" --repo "${REPO}" --color 'D93F0B' \ + --description 'The autofix loop stopped on this PR — a human must re-arm, split, merge, or close it' \ + 2> /dev/null || true + if ! gh api -X POST "repos/${REPO}/issues/${PR}/labels" -f "labels[]=${NEEDS_HUMAN_LABEL}" > /dev/null; then + echo "::warning::${NEEDS_HUMAN_LABEL} add failed for #${PR}; will retry next scan" + fi + if [[ "${CAP_NOTICED}" == "0" ]]; then + 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
\n中文说明\n\n⏸️ 托管已暂停:本 PR 达到轮次上限(%s/%s)。评论 `%s` 可重新武装、开启新窗口继续托管;或评论 `%s stop` 释放。\n\n
\n\n' "${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
\n中文说明\n\n⏸️ AutoFix 已暂停:本 PR 达到自动轮次上限(%s/%s),循环不再管理——新反馈与 base 冲突将无人处理。评论 `%s` 可在同一上限下重置计数窗口,或评论 `%s` 以更高上限接管。\n\n
\n\n' "${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 + fi + continue + fi + if [[ "${REVIEW_PR_LIVE}" == "true" ]]; then + 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("")) + | 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
\n中文说明\n\n🔀 已更新 base:红色检查 [%s] 在当前 main 上通过 —— 已通过 update-branch 合入当前 main,CI 将重新运行。\n\n
\n\n' "${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='") ] | .[] + | {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("")) + | .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("")) + or ((.body // "") | contains(""))) + | .created_at ] | max // "none"' "${WORKDIR}/ic.json")" + # …and so does the round seed: same marker, same created_at-equality + # read against the live window key, same clamp. MAX_ROUNDS is the + # matrix-shadowed EFFECTIVE cap here (see the address job's env), so + # the clamp is against the same ceiling the scan used. + LIVE_ROUND_START="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg key "${LIVE_REARM_KEY}" ' + [ .[] | select((.user.login // "") == $ab) + | select((.created_at // "") == $key) + | ((.body // "") | [ scan("") ] | .[] | .[0]) ] + | last // "0"' "${WORKDIR}/ic.json" 2> /dev/null || echo '0')" + [[ "${LIVE_ROUND_START}" =~ ^[0-9]{1,2}$ ]] || LIVE_ROUND_START=0 + # The PRE-clamp value is what the maintainer typed; the Critical-only + # audit clause cites it so a clamped seed never renders a command + # nobody sent while the engage ack still shows the original number. + LIVE_ROUND_START_RAW="${LIVE_ROUND_START}" + if [[ "${MAX_ROUNDS:-0}" -gt 0 && "${LIVE_ROUND_START}" -ge "${MAX_ROUNDS}" ]]; then + LIVE_ROUND_START=$(( MAX_ROUNDS - 1 )) + fi + LIVE_MAX_ROUND="$(jq -r --arg key "${LIVE_REARM_KEY}" --argjson start "${LIVE_ROUND_START}" 'map(select(.win == $key)) | map(.round) | max // $start' <<< "${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("") ] | .[] + | {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("") ] | .[] + | {src: (.[0] | tonumber), test: (.[1] | tonumber), win: .[2], at: ($c.created_at // "")} ] + | map(select(.win == $key)) + | map(select($baseupd == "" or (.at > $baseupd))) | sort_by(.at) + | .[0] // empty | "\(.src) \(.test)"' "${WORKDIR}/ic.json")" + GROWTH_BASE_NEW='false' + if [[ "${NET_MEASURED}" != 'true' ]]; then + BASE_SRC=0 + BASE_TEST=0 + elif [[ "${GROWTH_BASELINE}" =~ ^(-?[0-9]+)\ (-?[0-9]+)$ ]]; then + BASE_SRC="${BASH_REMATCH[1]}" + BASE_TEST="${BASH_REMATCH[2]}" + else + BASE_SRC="${NET_SRC}" + BASE_TEST="${NET_TEST}" + GROWTH_BASE_NEW='true' + fi + GROWTH_SRC=$(( NET_SRC - BASE_SRC )) + GROWTH_TEST=$(( NET_TEST - BASE_TEST )) + [[ "${NET_MEASURED}" != 'true' ]] && { GROWTH_SRC=0; GROWTH_TEST=0; } + { + echo "growth_base_new=${GROWTH_BASE_NEW}" + echo "growth_base_src=${BASE_SRC}" + echo "growth_base_test=${BASE_TEST}" + # The key the baseline was READ under. The report must write the + # marker under this same key, not the matrix WINDOW: a conflict + # round is exempt from the supersede discard, so it can run with + # a stale WINDOW after a re-arm — a marker written under that + # dead key would be invisible to every later read and the + # round's pushed growth would escape the budget for the rest of + # the live window. + echo "growth_base_win=${LIVE_REARM_KEY}" + # The round's own growth + over-budget flag, so the report step can + # write this round's autofix-growth-now marker (the per-round + # history the divergence read above consumes). + echo "growth_src=${GROWTH_SRC}" + echo "growth_test=${GROWTH_TEST}" + } >> "${GITHUB_OUTPUT}" + echo "📏 net diff src ${NET_SRC} / test ${NET_TEST} lines (window baseline ${BASE_SRC}/${BASE_TEST}, growth ${GROWTH_SRC}/${GROWTH_TEST}, budgets ${GROWTH_BUDGET_SRC_LINES}/${GROWTH_BUDGET_TEST_LINES})" + + CRITICAL_ONLY='false' + CRITICAL_ONLY_ROUNDS='false' + CRITICAL_ONLY_GROWTH='false' + if [[ "${ROUND}" -ge "${CRITICAL_ONLY_AFTER_ROUND}" ]]; then + CRITICAL_ONLY='true' + CRITICAL_ONLY_ROUNDS='true' + fi + if [[ "${GROWTH_SRC}" -gt "${GROWTH_BUDGET_SRC_LINES}" || "${GROWTH_TEST}" -gt "${GROWTH_BUDGET_TEST_LINES}" ]]; then + CRITICAL_ONLY='true' + CRITICAL_ONLY_GROWTH='true' + fi + # Divergence: Critical-only only trims non-Criticals, so when the + # GROWTH that trips the brake is Critical-driven the diff keeps + # climbing anyway. Read this window's prior per-round growth markers + # (written by the report step): count the rounds that were over + # budget, and take the MOST RECENT prior over-budget run's growth + # SUM (latest measured= — see below; NOT the window-wide max, which a + # one-off spike would raise forever). The round is DIVERGING when it + # is over budget now, the brake has already fired for + # >= GROWTH_DIVERGENCE_ROUNDS prior rounds, and the diff has NOT + # shrunk from that most-recent sum — the fixes are not converging, so + # the round must escalate to a human decision instead of patching + # again. A diff that is over budget but SHRINKING (agent removing + # code) or a one-off overshoot stays in ordinary Critical-only. + if [[ ! "${GROWTH_DIVERGENCE_ROUNDS}" =~ ^([1-9][0-9]{0,3})$ ]]; then + echo "::warning::GROWTH_DIVERGENCE_ROUNDS='${GROWTH_DIVERGENCE_ROUNDS}' is not a positive count; using 2" + GROWTH_DIVERGENCE_ROUNDS=2 + fi + # Count runs whenever the net is measured (not only over budget), so + # the trajectory clause below is accurate even on a round that pulled + # back under budget. markers: + # + # Deduped by run=GITHUB_RUN_ID (the per-workflow-run id) and ORDERED + # by measured=: the report post's bounded retry re-posts one run's + # marker, and a failed job's re-run keeps the same run_id, so a run + # collapses to its LATEST measurement — and that collapse happens + # BEFORE the over/window/cutoff filters, or a re-run that came back + # under budget would still be represented by its stale over=true + # attempt. Within the collapse an explicit measured= beats the + # created_at fallback: a re-run attempt that crashed BEFORE prepare + # — or whose measurement failed — posts an inert over=false marker + # with no measured=, whose fallback (post-run) timestamp would + # otherwise outdate and erase the same run's real prepare-time + # measurement. Every distinct address run has a fresh run_id. + # KNOWN RESIDUAL (#9114): during the one-time deploy transition a + # run whose FIRST attempt posted a legacy (no measured=) over=true + # marker and whose re-run crashes before prepare still collapses + # fallback-vs-fallback on created_at — the later inert marker wins + # and erases the count. Self-limiting: once deployed, every real + # measurement carries measured= and beats any inert marker. + # round=/eval-watermark are NOT a safe identity — a state-triggered + # lane (a persistent merge conflict selects the PR every scan with no + # new evaluable feedback) freezes both NEWEST and ROUND, so distinct + # over-budget runs would share them and collapse, stalling the count. + # Filtered on measured= (the prepare-time measurement instant, NOT + # the comment's post-agent created_at) after GROWTH_NOW_CUTOFF, so a + # prior sum measured against a pre-base-update tree is dropped rather + # than compared to this round's. KNOWN RESIDUAL (#9114): the tree is + # fixed at the branch fetch/checkout while the cutoff comes from + # ic.json fetched afterwards, so a base update landing between the + # fetch and the measured_at stamp admits a pre-update marker; + # self-heals at the next re-arm/base update. measured= is OPTIONAL in + # the scan: + # markers posted before it existed fall back to their comment's + # created_at, so deploying this does not blank the census of a window + # that is already in flight. KNOWN RESIDUAL (#9114): during that + # transition the sort mixes two clocks — a legacy marker's fallback + # is its POST-RUN created_at while a new marker stamps prepare time — + # so PREV_SUM can briefly come from an older measurement; the count + # is unaffected and it self-heals at the next re-arm/base update. + # The "not shrinking" test compares against the MOST RECENT prior + # over-budget run's sum (latest measured=), not the window-wide max: a + # single transient spike would otherwise raise the bar forever and a + # genuine plateau-over-budget runaway (the exact case to escalate) + # would never clear it. The CURRENT run's own markers are excluded + # (run != GITHUB_RUN_ID): a re-run of a failed job keeps the same run + # id and its failed attempt already posted a marker, so counting it + # would over-report the round's own attempt as a PRIOR one. + GROWTH_DIVERGED='false' + OVER_ROUNDS_PRIOR=0 + PREV_SUM=0 + if [[ "${NET_MEASURED}" == 'true' ]]; then + read -r OVER_ROUNDS_PRIOR PREV_SUM < <(jq -r --arg ab "${AUTOFIX_BOT}" --arg key "${LIVE_REARM_KEY}" --arg cutoff "${GROWTH_NOW_CUTOFF}" --arg curr "${GITHUB_RUN_ID}" ' + [ .[] | select((.user.login // "") == $ab) | . as $c | ($c.body // "") + | [ scan("") ] | .[] + | {sum: ((.[0] | tonumber) + (.[1] | tonumber)), over: .[2], round: (.[3] | tonumber), run: (.[4] | tonumber), measured: (.[5] // ($c.created_at // "")), explicit: (.[5] != null), win: .[6]} ] + | group_by(.run) | map(max_by([.explicit, .measured])) + | map(select(.win == $key and .over == "true")) + | map(select(.run != ($curr | tonumber))) + | map(select($cutoff == "" or (.measured > $cutoff))) + | sort_by(.measured) + | "\(length) \((last.sum) // 0)"' "${WORKDIR}/ic.json" 2> /dev/null || echo "0 0") + [[ "${OVER_ROUNDS_PRIOR}" =~ ^[0-9]+$ ]] || OVER_ROUNDS_PRIOR=0 + [[ "${PREV_SUM}" =~ ^-?[0-9]+$ ]] || PREV_SUM=0 + if [[ "${CRITICAL_ONLY_GROWTH}" == 'true' \ + && "${OVER_ROUNDS_PRIOR}" -ge "${GROWTH_DIVERGENCE_ROUNDS}" \ + && $(( GROWTH_SRC + GROWTH_TEST )) -ge "${PREV_SUM}" ]]; then + GROWTH_DIVERGED='true' + fi + fi + # The over-budget flag feeds the report's per-round marker; the + # handoff itself is enforced by the feedback.md text below, so + # GROWTH_DIVERGED needs no step output. + echo "critical_only_growth=${CRITICAL_ONLY_GROWTH}" >> "${GITHUB_OUTPUT}" + [[ "${GROWTH_DIVERGED}" == 'true' ]] && + echo "🛑 diff not converging: over budget now, ${OVER_ROUNDS_PRIOR} prior over-budget round(s) in this window, growth not shrinking — escalating to a maintainer decision instead of patching." + # 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("") ] | .[] + | {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("/,//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("")) ] | map(.[0] // "none")) as $wins + | ($wins | length) > 0 and (($wins | last) == $key)) + ] | 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='' + 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
\n中文说明\n\n🔄 **AutoFix 正在处理此 PR** —— 第 %s/%s 轮。[查看实时进度](%s);本轮结束后会在此发布报告。\n\n
' \ + "${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" "${WORKDIR}/failure.zh.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. + # Hand-maintained sibling of the triage-budget sanitize step in + # qwen-triage.yml's authorize job; the failure modes deliberately + # differ (this one clamps garbage to the ceiling, that one falls + # back to the default), so a boundary-bug fix in one must be + # re-derived in the other. + 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 + env: + TRUSTED_PATH: '${{ steps.stage.outputs.trusted_path }}' + VERIFY_RUNNER_SHA256: '${{ steps.stage.outputs.verify_runner_sha256 }}' + # Step-level env outranks $GITHUB_ENV: an earlier shell-capable + # step (the agent runs branch code on the host) must not be able + # to downgrade a repo-variable 'reject' back to 'advisory'. + FOOTPRINT_ENFORCE: "${{ vars.QWEN_AUTOFIX_FOOTPRINT_ENFORCE || 'advisory' }}" + run: |- + # The gate decides whether the PAT push runs, and the first pass + # executes the branch's own build/test on the host before the + # second — so pin PATH to the staged trusted value, drop the + # preload channels, and verify the staged runner's digest (recorded + # in GITHUB_OUTPUT, unreachable from a disk write) before executing, + # or a mid-run overwrite lets the branch define its own verdict. + export PATH="${TRUSTED_PATH}" + unset LD_PRELOAD LD_AUDIT LD_LIBRARY_PATH + echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | sha256sum -c - > /dev/null + 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}/failure.zh.md" \ + "${WORKDIR}/handoff.md" \ + "${WORKDIR}/gate-output.log" \ + "${WORKDIR}/gate-output.log.check" \ + "${WORKDIR}/gate-output.log.baseline" \ + "${WORKDIR}/gate-rejection.md" \ + "${WORKDIR}/agent-api-error" \ + "${WORKDIR}/agent-api-error-kind" \ + "${WORKDIR}/agent-timeout" \ + "${WORKDIR}/resolved-comments.txt" \ + "${WORKDIR}/comment-replies.json" + # NOT deleted with its siblings: run 2 writes its own + # deferred-findings.json, and a deferral dropped here is gone for + # good (the eval watermark filters this round's feedback out of + # every later round). Carry run 1's into a sidecar the upsert + # unions in; merge if a previous repair already left one. + if [[ -s "${WORKDIR}/deferred-findings.json" ]]; then + if [[ -s "${WORKDIR}/deferred-findings.carry.json" ]]; then + # This round FIRST: unique_by keeps first-of-group in original + # order, so a finding re-emitted with fresher text wins — the + # same precedence the upsert script documents for its own union. + if jq -s 'add' "${WORKDIR}/deferred-findings.json" \ + "${WORKDIR}/deferred-findings.carry.json" \ + > "${WORKDIR}/deferred-findings.carry.next" 2> /dev/null; then + mv "${WORKDIR}/deferred-findings.carry.next" \ + "${WORKDIR}/deferred-findings.carry.json" + # Merged: this round's copy lives on inside the sidecar. + rm -f "${WORKDIR}/deferred-findings.json" + else + # Which side is corrupt is NOT known here — jq -s fails if + # EITHER input is unparseable, and today's topology cannot + # even produce a pre-existing carry (WORKDIR is wiped at run + # start and there is exactly one repair step), so this branch + # is defensive. Say what is certain: the merge failed, the + # earlier set is kept, this round's is preserved unmerged. + # The only loss path in this feature without a raw dump: the + # newer set is discarded here and the eval watermark means + # nothing re-derives it, so print it before deleting. `::` is + # neutralized because the content is agent-written and a raw + # `::` at line start would be parsed as a workflow command. + _dfsize="$(wc -c < "${WORKDIR}/deferred-findings.json" 2> /dev/null | tr -d ' ')" + if [[ -n "${_dfsize}" ]] && (( _dfsize > 4000 )); then + echo "::warning::could not merge carried deferrals across the repair (one of the two sets is unparseable); keeping the carried set and preserving this round's as deferred-findings.unmerged.json. Raw content follows, TRUNCATED at 4000 of ${_dfsize} bytes — the full file rides this run's artifact dump:" + else + echo "::warning::could not merge carried deferrals across the repair (one of the two sets is unparseable); keeping the carried set and preserving this round's as deferred-findings.unmerged.json. Raw content follows:" + fi + head -c 4000 "${WORKDIR}/deferred-findings.json" | sed 's/::/;;/g' + echo + rm -f "${WORKDIR}/deferred-findings.carry.next" + # Keep the discarded set ON DISK so the warning's pointer at + # the artifact dump is true past the 4000-byte clip. Renamed, + # not left in place: the upsert would otherwise union it back + # in as if it had merged. + mv "${WORKDIR}/deferred-findings.json" \ + "${WORKDIR}/deferred-findings.unmerged.json" + fi + else + mv "${WORKDIR}/deferred-findings.json" \ + "${WORKDIR}/deferred-findings.carry.json" + fi + fi + 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 + env: + TRUSTED_PATH: '${{ steps.stage.outputs.trusted_path }}' + VERIFY_RUNNER_SHA256: '${{ steps.stage.outputs.verify_runner_sha256 }}' + # Step-level env outranks $GITHUB_ENV: an earlier shell-capable + # step (the agent runs branch code on the host) must not be able + # to downgrade a repo-variable 'reject' back to 'advisory'. + FOOTPRINT_ENFORCE: "${{ vars.QWEN_AUTOFIX_FOOTPRINT_ENFORCE || 'advisory' }}" + run: |- + # The gate decides whether the PAT push runs, and the first pass + # executes the branch's own build/test on the host before the + # second — so pin PATH to the staged trusted value, drop the + # preload channels, and verify the staged runner's digest (recorded + # in GITHUB_OUTPUT, unreachable from a disk write) before executing, + # or a mid-run overwrite lets the branch define its own verdict. + export PATH="${TRUSTED_PATH}" + unset LD_PRELOAD LD_AUDIT LD_LIBRARY_PATH + echo "${VERIFY_RUNNER_SHA256} ${RUNNER_TEMP}/run-autofix-review-verification.sh" | sha256sum -c - > /dev/null + 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 }}' + FIRST_PREEXISTING: '${{ steps.verify.outputs.preexisting }}' + REPAIR_PREEXISTING: '${{ steps.verify_repair.outputs.preexisting }}' + run: |- + OUTCOME="${FIRST_OUTCOME}" + COMMITTED="${FIRST_COMMITTED}" + VERIFIED_HEAD="${FIRST_VERIFIED_HEAD}" + # The flag travels WITH the attempt whose outcome is selected: the + # first pass can fail a round-caused check, the repair fixes it, and + # the repair verification can then hit a pre-existing failure — that + # final classification is the one the report must render. Forwarded + # so the failure report can say "base update needed" instead of the + # generic gate-rejection clause. + PREEXISTING="${FIRST_PREEXISTING}" + if [[ "${REPAIR_ATTEMPTED}" == 'true' ]]; then + PREEXISTING="${REPAIR_PREEXISTING}" + fi + if [[ "${PREEXISTING}" == 'true' ]]; then + echo "preexisting=true" >> "${GITHUB_OUTPUT}" + fi + 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 failure.zh.md handoff.md gate-rejection.md gate-advisories.md agent-api-error agent-api-error-kind agent-timeout resolved-comments.txt comment-replies.json deferred-findings.json deferred-findings.carry.json deferred-findings.unmerged.json pr.diff; do + if [[ -f "${WORKDIR}/${f}" ]]; then + echo "=============== ${f} ===============" + # Agent-written content: a line-start `::` would be parsed as a + # workflow command (::error::, ::add-mask::), the same reason + # every other echo of these files neutralizes it. + sed 's/::/;;/g' "${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 }}' + # The seed the window opened at (prepare's clamped read; 0 when + # unseeded): the milestone crossing trigger counts rounds + # accumulated in the window, not seed-inflated absolute ones. + ROUND_START: '${{ steps.prepare.outputs.round_start }}' + # 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 }}' + RESANITIZE_SHA256: '${{ steps.stage.outputs.resanitize_sha256 }}' + UPSERT_SRC: '${{ steps.stage.outputs.upsert_src }}' + TRUSTED_PATH: '${{ steps.stage.outputs.trusted_path }}' + # Growth-brake baseline: written into this window's FIRST report + # comment only (growth_base_new), so later rounds' first-wins parse + # keeps the anchor. Empty when prepare exited early — no marker. + GROWTH_BASE_NEW: '${{ steps.prepare.outputs.growth_base_new }}' + GROWTH_BASE_SRC: '${{ steps.prepare.outputs.growth_base_src }}' + GROWTH_BASE_TEST: '${{ steps.prepare.outputs.growth_base_test }}' + # The window key prepare READ the baseline under (LIVE_REARM_KEY), + # not the matrix WINDOW: conflict rounds are supersede-exempt and + # can report under a stale WINDOW after a re-arm; the marker must + # land under the key later reads will use. + GROWTH_BASE_WIN: '${{ steps.prepare.outputs.growth_base_win }}' + # This round's own growth + over-budget flag, written as the + # per-round autofix-growth-now marker so later rounds can measure + # divergence (growth still climbing over budget = not converging). + GROWTH_SRC: '${{ steps.prepare.outputs.growth_src }}' + GROWTH_TEST: '${{ steps.prepare.outputs.growth_test }}' + CRITICAL_ONLY_GROWTH: '${{ steps.prepare.outputs.critical_only_growth }}' + MEASURED_AT: '${{ steps.prepare.outputs.measured_at }}' + run: |- + # gh has its own $GITHUB_ENV-injectable channels: pin the host and + # drop any planted token BEFORE the identity check below, so a + # GH_HOST reroute cannot spoof `gh api user` and a planted GH_TOKEN + # cannot outrank the inline GITHUB_TOKEN. (git's channels are + # stripped in the hermetic preamble further down.) + export GH_HOST=github.com + unset GH_ENTERPRISE_TOKEN GH_TOKEN + # Point gh at a fresh empty config dir, not the default + # ~/.config/gh on the shared attacker-writable HOME — its + # config.yml can carry http_unix_socket and other transport + # reroutes no sweep here touches. mktemp -d gives an + # unpredictable path a watcher cannot pre-seed. + export GH_CONFIG_DIR="$(mktemp -d "${RUNNER_TEMP}/autofix-gh-config.XXXXXX")" + # 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 + + # Shared by the pushed and no-op outcomes: a no-op round may + # resolve re-verified findings (verified_head is the unchanged, + # previously verified origin head) and must post its declines' + # replies — silence in still-open threads was a no-op-only gap. + resolve_and_reply_threads() { + 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. + # Paginated, because GitHub returns reviewThreads in ASCENDING + # creation order: a single first-100 page is the OLDEST hundred, + # which on a long-running PR is precisely not the threads this + # round is answering. Measured on #8403 (1256 threads): one page + # reached 8% of them, so an implemented Critical past it stayed + # open and read as unaddressed, and a decline past it was answered + # by silence — the two outcomes this function exists to prevent. + # A partial fetch is USED, not discarded: losing twelve good pages + # to a rate limit on the thirteenth would resolve nothing at all, + # so the failure is announced and the threads in hand still map. + # Residual: a thread with more than 100 comments still truncates, + # so a comment past that page is unmapped and each block falls + # back to the id as given; announced below, and unobserved so far. + # Do NOT close that residual by adding endCursor to the inner + # comments pageInfo: gh's paginator adopts the FIRST pageInfo + # carrying both hasNextPage and endCursor, so the inner one would + # hijack the thread-page cursor and stop after page one (exit 0, no + # warning) — silently restoring the oldest-hundred bug this fetch + # exists to fix. The outer cursor wins only because the inner + # pageInfo asks for hasNextPage alone. The outer field ORDER is + # load-bearing for the same reason: the scanner carries its flags + # across pageInfo objects and breaks at the first one yielding + # both, so alphabetizing to pageInfo{endCursor hasNextPage} makes + # it break on the outer endCursor while hasNextPage still carries + # the last INNER page's value (almost always false — thread comment + # pages rarely truncate, and the outer page's own hasNextPage is + # read only after the break) — gh then returns no cursor and the + # walk silently stops after page one. + if [[ -s "${WORKDIR}/resolved-comments.txt" || -s "${WORKDIR}/comment-replies.json" ]]; then + THREADS_FETCH_OK='true' + # gh's stderr goes to a fresh mktemp regular file, never a named + # WORKDIR path: WORKDIR is bind-mounted read-write into the agent + # sandbox, so branch code from the round that just ran can plant + # anything it likes at a predictable name here. A planted FIFO + # blocks bash's O_WRONLY open before gh even execs, and the only + # reader is the tail below gh — so the step would hang to the job + # timeout AFTER the push landed, losing the report and the round + # markers and breaking this block's own invariant that a resolve + # failure must never fail a good push. A planted symlink would + # instead truncate its target and fold 300 bytes of it into a + # public ::warning::. Same reasoning, same shape as the `gh api + # user` checks elsewhere in this file. + threads_err_file="$(mktemp)" + THREADS_RAW="$(gh api graphql --paginate -f owner="${REPO%%/*}" -f name="${REPO##*/}" -F pr="${PR}" -f query=' + query($owner:String!,$name:String!,$pr:Int!,$endCursor:String){ + repository(owner:$owner,name:$name){ + pullRequest(number:$pr){ + reviewThreads(first:100, after:$endCursor){ + nodes{id isResolved comments(first:100){nodes{databaseId} pageInfo{hasNextPage}}} + pageInfo{hasNextPage endCursor} + } + } + } + }' --jq '.data.repository.pullRequest.reviewThreads.nodes[]' 2> "${threads_err_file}")" || THREADS_FETCH_OK='false' + # gh emits one node per line across every page; slurp them + # into the flat array both blocks below already expect. The + # stream is consumed inline into a shell variable and never + # lands in a WORKDIR json file, so it takes no part in the + # slurp normalizer the paginated WORKDIR fetches share. + # Keep only thread-shaped documents: on a failing page gh skips + # --jq and appends that page's raw response body (a rate-limit + # message, or a GraphQL error envelope) to stdout after the good + # nodes. Slurped unfiltered it becomes a stray element, and the + # consumers below iterate .comments.nodes[] over it and exit 5 — + # which under errexit aborts this step AFTER a good push, losing + # the report and the markers. Both invariants above forbid that: + # a resolve failure must never fail a good push, and a partial + # fetch is used rather than discarded. + THREADS_JSON="$(jq -s '[.[] | select(type == "object" and has("id") and has("comments"))]' <<< "${THREADS_RAW}" 2> /dev/null)" || THREADS_JSON='[]' + [[ -n "${THREADS_JSON}" ]] || THREADS_JSON='[]' + if [[ "${THREADS_FETCH_OK}" != 'true' ]]; then + # Fold in gh's stderr: the warning announces THAT pagination + # stopped, and only this says WHY — a transient rate limit + # (back off) reads identically to an expired PAT (rotate) or a + # network failure without it. + echo "::warning::review-thread pagination did not complete; $(jq 'length' <<< "${THREADS_JSON}") thread(s) fetched, and any thread past them will not be resolved or answered in-thread: $(tail -c 300 "${threads_err_file}" 2> /dev/null | tr '\r\n' ' ')" + fi + rm -f "${threads_err_file}" + if [[ "$(jq -r 'map(select(.comments.pageInfo.hasNextPage)) | length' <<< "${THREADS_JSON}")" != "0" ]]; then + echo "::warning::a review thread carries more than 100 comments; a comment past that page is not mapped to its 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///' 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/" + echo "" + if [[ "${GROWTH_BASE_NEW}" == 'true' ]]; then + echo "" + fi + # Per-round growth history the next round's divergence read + # counts; run=GITHUB_RUN_ID is the DEDUP identity (a retry or a + # job re-run re-posts the same run; measured= orders and picks + # that run's latest attempt). + echo "" + } > "${WORKDIR}/report.md" + STATUS="pushed (round ${NEXT_ROUND}/${MAX_ROUNDS})" + else + # No push happened, so the verified head is the unchanged + # origin head; resolution's own live-head guards still apply. + PUSH_RACE_MERGED='false' + resolve_and_reply_threads + # Best-effort: verified out-of-footprint findings persist into + # the per-PR tracking issue (script content from expression + # context; append-only comment design — see the script). + run_deferred_upsert + # 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/" + echo "" + if [[ "${GROWTH_BASE_NEW}" == 'true' ]]; then + echo "" + fi + # Per-round growth history the next round's divergence read + # counts; run=GITHUB_RUN_ID is the DEDUP identity (a retry or a + # job re-run re-posts the same run; measured= orders and picks + # that run's latest attempt). + echo "" + } > "${WORKDIR}/report.md" + STATUS="no action needed" + fi + + # Bounded retry on the report post: this one comment carries the + # round's ENTIRE persisted state (autofix-eval watermark/round, + # redcheck head, growth baseline). The push has already landed, so + # a transient API failure here loses the marker while keeping the + # growth — the retry scan would re-anchor the baseline at the + # post-push size and re-evaluate feedback it already addressed. + # Three attempts bound that to genuine outages; the final failure + # keeps today's semantics (step fails, no marker, next scan + # retries the round). + REPORT_POSTED='false' + for attempt in 1 2 3; do + if gh pr comment "${PR}" --repo "${REPO}" --body-file "${WORKDIR}/report.md"; then + REPORT_POSTED='true' + break + fi + if [[ "${attempt}" == 3 ]]; then + echo "::error::report post failed ${attempt} times for PR #${PR}; giving up" + else + echo "::warning::report post attempt ${attempt} failed for PR #${PR}; retrying" + sleep 10 + fi + done + [[ "${REPORT_POSTED}" == 'true' ]] || exit 1 + + # 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). The + # window opens at the round SEED, not at zero: a '/takeover + # from 60' counter starts at 60, so the no-digest-yet baseline + # is the seed — otherwise the seed-inflated counter digests on + # the window's first push with a 1-2 round census. + MS_LAST="$(jq -r --arg ab "${AUTOFIX_BOT}" --arg win "${WINDOW:-none}" --argjson start "${ROUND_START:-0}" ' + [ .[] | select((.user.login // "") == $ab) | (.body // "") + | [ scan("") ] | .[] + | select(.[1] == $win) | (.[0] | tonumber) ] + | max // $start' "${WORKDIR}/ic.json" 2> /dev/null || echo "${ROUND_START:-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("")) ] | map(.[0] // "none")) as $wins + | ($wins | length) > 0 and (($wins | last) == $win))] + | 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("")) + | 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
\n中文说明\n\n📊 接管里程碑 —— 第 %s/%s 轮(%s)。统计:推送修复 %s 次、审阅无需改动 %s 次、超时 %s 次、验证拒绝 %s 次、其他轮次(崩溃/模型错误/门错误/infra)%s 次、base 更新 %s 次。\n\n轮次到这个量值得人工看一眼。可选:继续(无需操作);若轮次持续累积,考虑拆分或缩减 PR;或释放接管(移除 `%s` 标签或评论 `%s stop`)。不操作则托管照常继续。\n\n
\n\n' "${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 }}' + PREEXISTING: '${{ steps.final_verify.outputs.preexisting }}' + 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 }}' + # This step also posts a round report (timeout / gate-rejection / + # abort), so it writes the per-round growth-now marker too — else an + # over-budget round that never reaches 'Push and report' leaves a + # history gap and the divergence count under-reports. Empty outputs + # (prepare never ran) fall through the :-0/:-false marker fallbacks + # to an inert over=false entry — measured= then OMITS itself (an + # EMPTY measured= value matches no scan and would silently drop the + # marker these fallbacks exist to keep); the reader falls back to + # the comment's created_at. + GROWTH_SRC: '${{ steps.prepare.outputs.growth_src }}' + GROWTH_TEST: '${{ steps.prepare.outputs.growth_test }}' + CRITICAL_ONLY_GROWTH: '${{ steps.prepare.outputs.critical_only_growth }}' + MEASURED_AT: '${{ steps.prepare.outputs.measured_at }}' + GROWTH_BASE_WIN: '${{ steps.prepare.outputs.growth_base_win }}' + UPSERT_SRC: '${{ steps.stage.outputs.upsert_src }}' + TRUSTED_PATH: '${{ steps.stage.outputs.trusted_path }}' + run: |- + # NOTE: the deferred-findings upsert below runs its PAT identity + # check and the script itself in a sound /usr/bin/env -i child (see + # the block near the end of this step) — the script arrives as + # content from expression context, so there is no staged copy and + # no digest gate. This step body needs no in-shell hardening + # preamble for it. + # The handoff `gh pr comment` here is pre-existing surface at the + # workflow's baseline posture; hardening every pre-existing PAT gh + # call against BASH_FUNC/transport plants (via the same clean-child + # pattern) is tracked separately, out of this feature's scope. + # 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 failure.zh.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, markup neutralized (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. + # The tag substitutions are not just the comment opener: this + # value flows into CAUSE_ZH -> HEADLINE_ZH, which renders INSIDE + # the 中文说明
wrapper — a bare `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 -e 's/")) ] | map(.[0] // "none")) as $wins + | ($wins | length) > 0 and (($wins | last) == $win))] + | 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." + HEADLINE_ZH="🤖 AutoFix 已停止:连续 ${CONSEC_FAIL} 轮未能推送任何内容(超时和/或验证门拒绝)。以相同的单轮预算重试并不收敛 —— 这通常意味着 PR 过大,或与快速变动的 \`main\` 冲突。应由人工 rebase、拆分或缩减它,然后评论 \`${RETRY_COMMAND}\` 重新武装。在此之前,后续扫描将跳过本 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 + # Idle (silent-sandbox) timeouts share the census — they burn + # the same full budget — but no budget increase cures them, so + # when the window contains any, the breaker says so. + IDLE_N="$(grep -c 'idle-timeout' <<< "${PRIOR_HEADS}" || true)" + if [[ "${AGENT_TIMEOUT:-}" == 'idle-timeout'* ]]; then + IDLE_N=$(( IDLE_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). + IDLE_CLAUSE='' + IDLE_CLAUSE_ZH='' + if [[ "${IDLE_N}" -gt 0 ]]; then + IDLE_CLAUSE=" ${IDLE_N} of those were silent-sandbox (idle) timeouts that no budget increase can cure — investigate the sandbox image and runner docker daemon for those." + IDLE_CLAUSE_ZH="其中 ${IDLE_N} 次是静默 sandbox(idle)超时,提高预算也治不了 —— 请针对这些排查 sandbox 镜像与 runner 的 docker daemon。" + fi + # Mirror the round-level split: when EVERY counted timeout + # was idle, the closing remedy must not prescribe the + # budget increase the clause above just declared useless. + REMEDY='split or reduce the PR (or raise the agent time budget AND its step backstop together)' + REMEDY_ZH='拆分或缩减该 PR(或同时提高 agent 时间预算与其步骤兜底)' + if [[ "${IDLE_N}" -ge "${TIMEOUT_N}" ]]; then + REMEDY='investigate the sandbox image and runner docker daemon' + REMEDY_ZH='排查 sandbox 镜像与 runner 的 docker daemon' + fi + 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.${IDLE_CLAUSE} A human should ${REMEDY}, then comment \`${RETRY_COMMAND}\` to re-arm. Until then future scans will skip this PR." + HEADLINE_ZH="🤖 AutoFix 已停止:当前计数窗口内已累计 ${TIMEOUT_N} 次时间预算耗尽(含其间推送过的轮次;本轮本身可能以别的方式失败)。即 ${TIMEOUT_N} 次完整 agent 运行没有推送任何内容。${IDLE_CLAUSE_ZH}应由人工${REMEDY_ZH},然后评论 \`${RETRY_COMMAND}\` 重新武装。在此之前,后续扫描将跳过本 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. + # The tag substitutions beyond `' + echo "**Why it was not pushed:**" + echo + # The rejection text below was written BEFORE the stale-base + # auto-update: on that path its framing (pre-existing, + # unreachable, cured by a base update) is already stale — the + # base HAS been updated, and the next round re-measures and + # may charge the round. Say so, or the retry agent is steered + # toward no-action on the one round designed to re-measure. + if [[ "${STALE_BASE_RETRY:-false}" == 'true' ]]; then + echo "_Note: the base has since been auto-updated; the verdict below predates that update, and the next round's re-measurement may charge the round._" + echo + fi + # reject_fix sizes its own document: the evidence tail is + # dynamic (budget 3300 minus the preamble, floored at 500), + # so the finished file tops out ≈3.3 KB. 3900 is headroom, + # not arithmetic — retune it only together with the + # script's tail_budget formula, or the closing fence gets + # silently truncated again. + head -c 3900 "${WORKDIR}/gate-rejection.md" | iconv -f utf-8 -t utf-8 -c | sed 's/' + fi + # Bilingual companion. Repo convention is English first, Chinese + # in a collapsed
. failure.md itself stays English-only + # — a byte-truncated excerpt of it is embedded above, and a + # severed agent-written
there would swallow the rest of + # the comment. So the Chinese lives in a SEPARATE agent-written + # file, failure.zh.md, and the workflow wraps it in its OWN + #
below: the wrapper tags are emitted HERE and the + # closing
unconditionally, so a truncated translation + # can lose content but can never swallow the markers that follow. + # A missing failure.zh.md (run-agent.mjs wrote failure.md itself, + # or the agent skipped it) degrades to the headline translation + # alone — never fail the round over a missing translation. + echo + echo '
' + echo '中文说明' + echo + echo "${HEADLINE_ZH}" + if [[ -s "${WORKDIR}/failure.zh.md" ]]; then + echo + if [[ "${COMMITTED}" == "true" ]]; then + echo "⚠️ 此改动未被推送 —— 下文引用的任何提交都只存在于 runner 工作区,已被丢弃。以下是 agent 的报告:" + else + echo "**停止前我了解到的情况:**" + fi + # Same byte-budget hygiene as the English excerpt above. 3000 + # bytes ≈ 1000 CJK characters — roughly the information in the + # 1500-byte English excerpt. Beyond the `" + # Per-round growth history the divergence read counts — same + # marker the push/no-op report paths write, so an over-budget + # round that timed out or was gate-rejected is not a gap. run= + # (per-workflow-run) is the DEDUP identity; measured= orders. + echo "" + # 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 "" + 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 + # A failed round must not lose verified deferred findings: the + # agent's analysis is independent of whether this round's commit + # survived verification. Guarded like the handoff MINUS its + # outcome!=fixed/noop condition — deliberately: when the outcome IS + # fixed/noop but "Push and report" died before its own upsert (the + # push loop's exit paths), this block is the only persistence + # route left. + if [[ "${DRY_RUN}" != "true" && "${STALE:-}" != "true" && -n "${GITHUB_TOKEN:-}" ]]; then + if [[ -z "${UPSERT_SRC:-}" ]]; then + # Stage never ran (pre-stage failure): nothing was deferred. + echo 'deferred-findings upsert skipped: stage step never ran' + else + # Same shape as run_deferred_upsert in 'Push and report' — no + # agent-writable path, content from expression context, child + # messages on fd 3 — plus the PAT bot-identity check, because + # POST_HANDOFF's own check is skipped on the fixed/noop-outcome + # path that also reaches here. + UPSERT_OUT="$( { LD_PRELOAD= LD_AUDIT= LD_LIBRARY_PATH= \ + LD_PROFILE= LD_PROFILE_OUTPUT= LD_DEBUG= LD_DEBUG_OUTPUT= \ + /usr/bin/env -i \ + PATH="${TRUSTED_PATH}" \ + GITHUB_TOKEN="${GITHUB_TOKEN}" \ + GH_HOST=github.com \ + RUNNER_TEMP="${RUNNER_TEMP}" \ + WORKDIR="${WORKDIR}" \ + PR="${PR}" \ + REPO="${REPO}" \ + AUTOFIX_BOT="${AUTOFIX_BOT}" \ + UPSERT_SRC="${UPSERT_SRC}" \ + bash --norc -c ' + set -uo pipefail + exec >&3 + printf "%s\n" "__upsert_child_live__" + if ! GH_CONFIG_DIR="$(mktemp -d "${RUNNER_TEMP}/autofix-gh-config.XXXXXX")"; then + echo "__upsert_trusted__::warning::could not create a gh config dir; deferred findings NOT persisted this round" + exit 0 + fi + export GH_CONFIG_DIR + UPSERT_ACTOR="$(GH_TOKEN="${GITHUB_TOKEN}" gh api user --jq .login 2> /dev/null || true)" + if [[ "${UPSERT_ACTOR}" != "${AUTOFIX_BOT}" ]]; then + echo "__upsert_trusted__::warning::CI_DEV_BOT_PAT identity check failed for the deferred-findings upsert (got ${UPSERT_ACTOR:-none}); NOT persisted this round" + exit 0 + fi + bash -c "${UPSERT_SRC}" || + echo "__upsert_trusted__::warning::deferred-findings upsert failed; continuing" + ' > /dev/null 2>&1 ; } 3>&1 )" || true + # Builtin-only inspection, same rationale as the twin. + if [[ "${UPSERT_OUT}" != *'__upsert_child_live__'* ]]; then + echo "::warning::deferred-findings upsert child never started (loader trace mode or exec failure); NOT persisted this round" + fi + while IFS= read -r _upsert_line; do + if [[ "${_upsert_line}" == '__upsert_child_live__' ]]; then + : + elif [[ "${_upsert_line}" == __upsert_trusted__* ]]; then + printf '%s\n' "${_upsert_line#__upsert_trusted__}" + else + printf '%s\n' "${_upsert_line//::/;;}" + fi + done <<< "${UPSERT_OUT}" + fi + 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='' + 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
\n中文说明\n\n%s\n\n
' \ + "${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}"'