diff --git a/.github/workflows/on_issues_ai_triage.yaml b/.github/workflows/on_issues_ai_triage.yaml index ec9524272f..a46934022f 100644 --- a/.github/workflows/on_issues_ai_triage.yaml +++ b/.github/workflows/on_issues_ai_triage.yaml @@ -175,6 +175,20 @@ jobs: # contents:read + issues:write, the model gets no credentials and no # Bash, and every value it produces is validated in Apply verdict. allowed_non_write_users: "*" + # Separate gate from the one above, and it bit the catch-up path in + # production: checkHumanActor (src/github/validation/actor.ts) + # rejects any actor whose account type is not User. The catch-up + # dispatches with GITHUB_TOKEN, so those runs arrive as + # github-actions[bot] and died with "Workflow initiated by non-human + # actor". allowed_non_write_users does NOT cover this — it is only + # consulted for User accounts. + # Named rather than "*": only this repo's own workflows can dispatch + # as github-actions, whereas "*" would also admit any other App that + # can reach a trigger. The matcher lowercases and strips a trailing + # [bot], so this entry matches the github-actions[bot] actor. + # Scheduled runs are unaffected either way — they arrive as + # actor=alexbelgium, a User. + allowed_bots: "github-actions" show_full_output: true prompt: | Read /tmp/ai-triage/context.md, then follow the instructions in @@ -220,46 +234,72 @@ jobs: set -euo pipefail mkdir -p /tmp/ai-triage F=/tmp/ai-triage/verdict.json + + # A reporter reply consumed ai:needs-info in the claim step above, so + # every early exit below has to restore it or the next reply could + # never re-trigger. Defined once here rather than repeated per exit. + restore_needs_info() { + [ "${EVENT_NAME:-}" = "issue_comment" ] || return 0 + gh issue edit "$ISSUE" --repo "$REPO" --add-label ai:needs-info >/dev/null 2>&1 || true + } + + # GATE 1 — did the action itself run? This is checked BEFORE looking + # at the payload, because the action can fail *after* having written + # a valid structured output: the object would sail through the shape + # check below, labels and a comment would be applied, and the step + # would exit 0 — a green run on a failed action, which is the exact + # silent-failure mode this workflow was rebuilt to eliminate. + # A failed action means its output is not trustworthy, full stop. + # + # This branch is also deliberately label-neutral. An action failure + # (auth, config, an outage) is systemic — it hits every issue the + # same way — so quarantining here would silently bury a batch a day + # while the real fault sits in the workflow. Fail red, add nothing, + # let the catch-up retry once it's fixed. Classify carries + # continue-on-error so this step still runs at all; without the + # explicit exit 1 the job would report success. + if [ "${CLASSIFY_OUTCOME:-}" = "failure" ]; then + restore_needs_info + echo "::error::the Classify action failed for #$ISSUE — this is usually a workflow-level fault affecting every issue, so the issue is left untouched for a retry. See the Classify step." + exit 1 + fi + # The verdict arrives as the action's schema-validated structured # output rather than a file the model wrote — see the Classify step. - # Materialise it here so the validation below is unchanged. printf '%s' "${STRUCTURED:-}" > "$F" - # `jq -e .` alone accepts any truthy JSON, so a verdict of `[1,2]` or - # `"hi"` passes here and then dies on `.verdict` below with "Cannot - # index array with string" — set -e kills the step before the - # ai:needs-info restore below, stranding the issue so no later reply - # can ever re-trigger it. Require an object. + + # GATE 2 — the action ran, but is the payload usable? `jq -e .` alone + # accepts any truthy JSON, so a verdict of `[1,2]` or `"hi"` would + # pass and then die on `.verdict` below with "Cannot index array with + # string", killing the step under set -e before the restore. Require + # an object. + # + # Reaching here means the failure is specific to THIS issue — the + # model looked at it and produced nothing usable — so a retry is + # worth exactly one attempt. A workflow_dispatch is the catch-up or + # a manual re-triage, i.e. the second look, so hand it to a human + # rather than re-dispatching the same issue every day forever; + # ai:needs-human is in the catch-up exclusion search, so it drops out + # of the queue instead of starving newer issues behind it. if [ ! -s "$F" ] || ! jq -e 'type == "object"' "$F" >/dev/null 2>&1; then - # A reporter reply consumed ai:needs-info in the claim step above. - # With no verdict we would otherwise leave the issue with the flag - # gone, so the next reply could never re-trigger — restore it. - # Do this FIRST, before any exit path below. - if [ "${EVENT_NAME:-}" = "issue_comment" ]; then - gh issue edit "$ISSUE" --repo "$REPO" --add-label ai:needs-info >/dev/null 2>&1 || true + restore_needs_info + echo "::warning::no usable verdict produced for #$ISSUE" + if [ "${EVENT_NAME:-}" = "workflow_dispatch" ]; then + echo "::warning::second attempt produced no verdict, handing #$ISSUE to a human" + gh label create ai:needs-human --repo "$REPO" --color ededed >/dev/null 2>&1 || true + # Drop the retry triggers in the same call. The catch-up search + # already excludes both, so this only bites on a MANUAL + # re-triage of an issue that still carries them — but there it + # matters: leaving ai-triage would keep an issue we just handed + # to a human sitting in tier 2's unattended fix queue, and + # leaving ai:needs-info would let a reporter reply silently + # re-trigger classification behind the human's back. The normal + # verdict path below already clears stale control labels; this + # keeps the escalation path consistent with it. + gh issue edit "$ISSUE" --repo "$REPO" \ + --add-label ai:needs-human \ + --remove-label ai-triage --remove-label ai:needs-info >/dev/null 2>&1 || true fi - - # Classify carries continue-on-error so the restore above always - # gets to run. That also means a hard failure inside the action - # reports the job green — which is exactly how tier 1 stayed dead - # for weeks while every run showed success. Re-raise it here. - if [ "${CLASSIFY_OUTCOME:-}" = "failure" ]; then - echo "::error::classification failed (see the Classify step); no verdict produced for #$ISSUE" - # A workflow_dispatch here is the daily catch-up (or a manual - # re-triage) — i.e. this issue has already had at least one shot - # and failed again. Hand it to a human rather than letting the - # catch-up re-dispatch the same issue every day forever; the - # label is in catchup's exclusion search, so it drops out of the - # queue instead of starving the newer issues behind it. - if [ "${EVENT_NAME:-}" = "workflow_dispatch" ]; then - gh label create ai:needs-human --repo "$REPO" --color ededed >/dev/null 2>&1 || true - gh issue edit "$ISSUE" --repo "$REPO" --add-label ai:needs-human >/dev/null 2>&1 || true - fi - exit 1 - fi - - # Action succeeded but produced nothing usable: leave the issue - # untriaged so tomorrow's catch-up picks it up, and stay green. - echo "::warning::no usable verdict produced, leaving issue untouched" exit 0 fi diff --git a/.github/workflows/on_pr_coderabbit.yml b/.github/workflows/on_pr_coderabbit.yml index eb390d77a2..1882f21ea1 100644 --- a/.github/workflows/on_pr_coderabbit.yml +++ b/.github/workflows/on_pr_coderabbit.yml @@ -87,6 +87,17 @@ jobs: # coderabbitai[bot], the review submitter. AI_PR_TOKEN, not # GITHUB_TOKEN, so the pushed fixes re-trigger CI on the PR. github_token: ${{ secrets.AI_PR_TOKEN }} + # Latent until now only because this job has never reached the action: + # every run so far skipped on the `ai-fix/*` branch guard. On the + # first real firing github.actor is coderabbitai[bot], and + # checkHumanActor (src/github/validation/actor.ts) rejects any actor + # whose account type is not User — a different gate from the write + # check above, which does return early for a [bot] actor. Without + # this the whole CodeRabbit follow-up tier would fail on its first + # genuine invocation. Named, not "*": the job `if` already requires + # the review to come from coderabbitai[bot], so nothing else can get + # here anyway, and "*" would only widen it if that guard changed. + allowed_bots: "coderabbitai" prompt: | CodeRabbit has reviewed pull request #${{ github.event.pull_request.number }} on ${{ github.repository }}. You are on that PR's branch. Follow