From 21af08e076b06df19c934b03b3f51f68e2e9d8d1 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 31 Aug 2026 07:45:09 +0200 Subject: [PATCH 1/3] feat(ci): review-swarm GitHub Action + companion post script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recovered from cloud run 45b1974c (drive) after DELIVER_FAIL_SYNC — the sandbox left a coredump at testdata/preflight/core that git apply couldn't handle. Stripped the coredump hunk from the patch, kept the GHA scaffold, then hand-fixed the swarm findings on the prior attempt (#74) before opening. Files: - .github/workflows/review-swarm.yml — trigger on kjgbot/miyaontherelay PR events, launch workflows/review-swarm.yaml in cloud, poll status, hand off to swarm-post.sh - .github/workflows/scripts/swarm-post.sh — sync run, extract per-lens verdicts, post transcripts + one sticky aggregate marker - README.md — one-line doc for the required RELAY_WORKSPACE_KEY secret Addresses swarm findings on #74: - Poll loop now exits on ALL terminal states (failed/errored/cancelled), not just completed — a real failure won't masquerade as a 65-min timeout - Poll deadline is 65m (was 45m) so it always ≥ the swarm's own 60m timeoutMs plus buffer, with a comment pinning the invariant - Aggregate verdict is LAST SWARM_ token (not substring anywhere) — same pattern commit f59d9cd fixed elsewhere - Lens verdicts sort by FILENAME (YYYYMMDD-HHMM prefix), not mtime — same pattern commit b2535aa fixed elsewhere - Verdict is last non-empty line's token, not whole-file grep — a passing review that quotes REVIEW_FAILED won't misclassify - Missing/unclear lens degrades aggregate to FAILED - Marker uses hidden HTML comment and edits in place across re-runs — a PR with N pushes gets 1 marker, not N --- .github/workflows/review-swarm.yml | 78 +++++++++++++++++++++++ .github/workflows/scripts/swarm-post.sh | 83 +++++++++++++++++++++++++ README.md | 2 + 3 files changed, 163 insertions(+) create mode 100644 .github/workflows/review-swarm.yml create mode 100755 .github/workflows/scripts/swarm-post.sh diff --git a/.github/workflows/review-swarm.yml b/.github/workflows/review-swarm.yml new file mode 100644 index 000000000..b19a209c0 --- /dev/null +++ b/.github/workflows/review-swarm.yml @@ -0,0 +1,78 @@ +name: Review swarm + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: review-swarm-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + review: + if: >- + github.event.pull_request.user.login == 'kjgbot' || + github.event.pull_request.user.login == 'miyaontherelay' + runs-on: ubuntu-latest + timeout-minutes: 50 + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + RELAY_WORKSPACE_KEY: ${{ secrets.RELAY_WORKSPACE_KEY }} + steps: + - name: Check out PR merge commit + uses: actions/checkout@v4 + with: + ref: refs/pull/${{ github.event.pull_request.number }}/merge + fetch-depth: 0 + + - name: Install agent-relay + run: npm install --global agent-relay@11.8.7 + + - name: Set review target + run: echo "$PR_NUMBER" > .review-target + + - name: Launch review swarm + id: launch + shell: bash + run: | + set -euo pipefail + response=$(agent-relay cloud run workflows/review-swarm.yaml --json) + printf '%s\n' "$response" + run_id=$(jq -er '.runId // .id // .run.id' <<<"$response") + echo "run_id=$run_id" >> "$GITHUB_OUTPUT" + + - name: Wait for review swarm + shell: bash + env: + RUN_ID: ${{ steps.launch.outputs.run_id }} + # Poll deadline (3900s = 65min) must be >= workflows/review-swarm.yaml's + # own timeoutMs (3600000 = 60min) plus buffer, or CI abandons swarms + # still executing in cloud. Exit on ALL terminal states so a real + # failure/cancel doesn't masquerade as a 65-min timeout. + run: | + set -euo pipefail + deadline=$((SECONDS + 3900)) + while (( SECONDS < deadline )); do + response=$(agent-relay cloud status "$RUN_ID" --json) + printf '%s\n' "$response" + status=$(jq -er '.status // .run.status' <<<"$response") + case "$status" in + completed) exit 0 ;; + failed|errored|cancelled|canceled) + echo "review swarm ended: $status" >&2 + exit 1 ;; + esac + sleep 30 + done + echo "review swarm timed out after 65 minutes" >&2 + exit 1 + + - name: Sync and post review comments + env: + RUN_ID: ${{ steps.launch.outputs.run_id }} + run: bash .github/workflows/scripts/swarm-post.sh "$RUN_ID" "$PR_NUMBER" diff --git a/.github/workflows/scripts/swarm-post.sh b/.github/workflows/scripts/swarm-post.sh new file mode 100755 index 000000000..c393198d8 --- /dev/null +++ b/.github/workflows/scripts/swarm-post.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Sync a completed cloud review-swarm run and post its transcripts + marker +# back to the PR. +# +# Two load-bearing extraction rules (both hard-won repo history): +# 1. SORT transcripts by FILENAME (starts with YYYYMMDD-HHMM), not by mtime. +# Fresh checkouts give transcripts uniform mtimes and mtime-sort picked +# stale verdicts — commit b2535aa fixed this class of bug elsewhere. +# 2. The verdict is the LAST non-empty line's token, not a whole-file grep. +# A whole-file grep of REVIEW_FAILED misclassifies a passing review that +# quotes the token in prose — commit f59d9cd fixed this elsewhere too. +# Same for the aggregate: LAST SWARM_ token in the log, not a substring match. +# +# Sticky marker: hidden HTML comment identifies the marker; edit-in-place +# across re-runs so N pushes don't accumulate N marker comments. +set -euo pipefail + +run_id=${1:-} +pr_number=${2:-} + +[[ "$run_id" =~ ^[[:alnum:]-]+$ ]] || { + echo "usage: $0 " >&2 + exit 2 +} +[[ "$pr_number" =~ ^[0-9]+$ ]] || { + echo "usage: $0 " >&2 + exit 2 +} + +agent-relay cloud sync "$run_id" +run_log=$(agent-relay cloud logs "$run_id") +printf '%s\n' "$run_log" + +# Aggregate: last SWARM_ token in the log (not a substring anywhere). +overall=FAILED +last_swarm=$(printf '%s\n' "$run_log" | grep -Eo 'SWARM_(PASSED|FAILED)' | tail -1 || true) +[[ "$last_swarm" == SWARM_PASSED ]] && overall=PASSED + +declare -A verdicts +for lens in maintainability history structure; do + # Sort lexicographically by filename (YYYYMMDD-HHMM prefix), take newest. + shopt -s nullglob + matches=(ops/reviews/*-pr"$pr_number"-"$lens".md) + shopt -u nullglob + if ((${#matches[@]} == 0)); then + echo "missing $lens transcript for PR #$pr_number" >&2 + verdicts[$lens]=MISSING + continue + fi + transcript=$(printf '%s\n' "${matches[@]}" | sort | tail -1) + + # Verdict = last non-empty line's token. + last_line=$(awk 'NF { last=$0 } END { print last }' "$transcript") + case "$last_line" in + *REVIEW_PASSED*) verdicts[$lens]=PASSED ;; + *REVIEW_FAILED*) verdicts[$lens]=FAILED ;; + *) verdicts[$lens]=UNCLEAR ;; + esac + + gh pr comment "$pr_number" --body-file "$transcript" +done + +# Degrade the aggregate if any lens is missing or unclear — a partial signal +# must not read as PASSED. +for lens in maintainability history structure; do + case "${verdicts[$lens]:-MISSING}" in + MISSING|UNCLEAR) overall=FAILED ;; + esac +done + +marker_id='' +body="$marker_id"$'\n'"🎯 review-swarm: $overall (M:${verdicts[maintainability]:-MISSING} H:${verdicts[history]:-MISSING} S:${verdicts[structure]:-MISSING})" + +# Sticky comment: find existing by identity marker, edit in place. +existing_id=$(gh api "repos/${GITHUB_REPOSITORY:?}/issues/$pr_number/comments" --jq \ + ".[] | select(.body | contains(\"$marker_id\")) | .id" | head -1) + +if [[ -n "$existing_id" ]]; then + gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/$existing_id" \ + -f body="$body" > /dev/null +else + gh pr comment "$pr_number" --body "$body" +fi diff --git a/README.md b/README.md index 9584dae11..497d158c0 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ an entire application. The constitution is [`docs/RFC-0001-everything-is-a-relayflow.md`](docs/RFC-0001-everything-is-a-relayflow.md). Nothing in this repo may contradict it; changing it is a human decision. +The required `RELAY_WORKSPACE_KEY` repository secret authenticates review-swarm GitHub Actions runs to the canonical Agent Relay Cloud workspace. + ## Layout ``` From f784529be960e82189a570a94f1473b307c70f0b Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 31 Aug 2026 08:08:01 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(ci):=20address=20swarm=20blockers=20on?= =?UTF-8?q?=20#75=20=E2=80=94=20job=20timeout,=20evidence=20loss,=20RFC=20?= =?UTF-8?q?filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit History + structure lens on #75 identified three blockers, all real: 1. `timeout-minutes: 50` was less than the poll deadline of 65 minutes, so GHA would kill the job before the poll could time out. Raised job cap to 75 (65 + 10 headroom for checkout/install/post steps). 2. Wait step exiting nonzero on cloud `failed` made GHA skip the post step — so a rejecting swarm's transcripts never reached the PR. That is exactly the evidence-loss class ops/DRIVE-LOG.md warns about. Restructured: wait step records terminal status as an output and ALWAYS exits 0. Post step runs with `if: always()`. New final step fails the job iff swarm wasn't `completed`. Transcripts and marker land on the PR before the CI check goes red. 3. Author filter `kjgbot`/`miyaontherelay` contradicts RFC-0001 §2 rule 7 "Every PR is met by a review swarm". Dropped the filter — human PRs get reviewed too, per the constitution. Comment records the intent. --- .github/workflows/review-swarm.yml | 41 +++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/.github/workflows/review-swarm.yml b/.github/workflows/review-swarm.yml index b19a209c0..f8862a721 100644 --- a/.github/workflows/review-swarm.yml +++ b/.github/workflows/review-swarm.yml @@ -14,11 +14,14 @@ concurrency: jobs: review: - if: >- - github.event.pull_request.user.login == 'kjgbot' || - github.event.pull_request.user.login == 'miyaontherelay' + # Per RFC-0001 §2 rule 7: "Every PR is met by a review swarm." No author + # filter — human PRs get reviewed too. If we ever need a rollout-scoped + # narrower filter, express it as a documented rule, not a hardcoded + # username list. runs-on: ubuntu-latest - timeout-minutes: 50 + # Job cap must exceed the poll deadline (65min) + install overhead. + # 75min gives 10min headroom for checkout, npm install, sync, post. + timeout-minutes: 75 env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ github.event.pull_request.number }} @@ -47,13 +50,16 @@ jobs: echo "run_id=$run_id" >> "$GITHUB_OUTPUT" - name: Wait for review swarm + id: wait shell: bash env: RUN_ID: ${{ steps.launch.outputs.run_id }} # Poll deadline (3900s = 65min) must be >= workflows/review-swarm.yaml's # own timeoutMs (3600000 = 60min) plus buffer, or CI abandons swarms - # still executing in cloud. Exit on ALL terminal states so a real - # failure/cancel doesn't masquerade as a 65-min timeout. + # still executing in cloud. The job's timeout-minutes must exceed 65. + # Record the terminal status in an output so the next step can post + # transcripts EVEN when the swarm rejects — otherwise a rejecting + # swarm's evidence never reaches the PR. run: | set -euo pipefail deadline=$((SECONDS + 3900)) @@ -62,17 +68,28 @@ jobs: printf '%s\n' "$response" status=$(jq -er '.status // .run.status' <<<"$response") case "$status" in - completed) exit 0 ;; - failed|errored|cancelled|canceled) - echo "review swarm ended: $status" >&2 - exit 1 ;; + completed|failed|errored|cancelled|canceled) + echo "swarm_status=$status" >> "$GITHUB_OUTPUT" + exit 0 ;; esac sleep 30 done - echo "review swarm timed out after 65 minutes" >&2 - exit 1 + echo "swarm_status=timeout" >> "$GITHUB_OUTPUT" - name: Sync and post review comments + # ALWAYS run, even if wait recorded a non-completed status. A rejecting + # swarm's transcripts and marker are the evidence we need on the PR; + # skipping this step on failure is exactly the evidence-loss class + # ops/DRIVE-LOG.md warns about. + if: always() && steps.launch.outputs.run_id != '' env: RUN_ID: ${{ steps.launch.outputs.run_id }} run: bash .github/workflows/scripts/swarm-post.sh "$RUN_ID" "$PR_NUMBER" + + - name: Fail the job when swarm rejected + # Post-transcript gate: the marker comment now records the verdict on + # the PR, so we can safely fail the CI check to block merge. + if: steps.wait.outputs.swarm_status != 'completed' + run: | + echo "Swarm terminated with status: ${{ steps.wait.outputs.swarm_status }}" >&2 + exit 1 From 68b5e2895a7259c8aa451e2d2a08b86d4e9d9331 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Mon, 31 Aug 2026 08:23:25 +0200 Subject: [PATCH 3/3] fix(ci): aggregate is fail-closed against lens verdicts (fixes #75 B2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit History lens on #75 caught: prior aggregate logic only degraded on MISSING/UNCLEAR, not FAILED. So a log-derived PASSED (from the cloud run's own aggregate step) could survive even when a lens transcript said REVIEW_FAILED. That posted a PASSED marker while a lens rejected — exactly the "single honest refusal must block merge" rule broken. Now: aggregate = ALL lenses PASSED, else FAILED. Log-derived overall is discarded — transcripts are the load-bearing evidence per the review-swarm.yaml aggregate step's own rule. --- .github/workflows/scripts/swarm-post.sh | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/.github/workflows/scripts/swarm-post.sh b/.github/workflows/scripts/swarm-post.sh index c393198d8..b2f70dd8c 100755 --- a/.github/workflows/scripts/swarm-post.sh +++ b/.github/workflows/scripts/swarm-post.sh @@ -60,12 +60,22 @@ for lens in maintainability history structure; do gh pr comment "$pr_number" --body-file "$transcript" done -# Degrade the aggregate if any lens is missing or unclear — a partial signal -# must not read as PASSED. +# Compute the aggregate from lens verdicts DIRECTLY, not from cloud logs. The +# logs-derived `overall` above is a first pass but can disagree with the +# actual transcripts (log parsing missed a lens, aggregate step raced, etc). +# The transcripts are the load-bearing evidence — a single FAILED lens means +# aggregate FAILED, per the review-swarm.yaml aggregate step's own rule +# ("any single honest refusal blocks the merge"). +# +# Compute aggregate = ALL lenses PASSED; else FAILED. This is fail-closed: +# MISSING, UNCLEAR, FAILED all degrade to FAILED. The prior bug only +# degraded MISSING/UNCLEAR, so a log-derived PASSED could survive even when +# a lens transcript said FAILED. +overall=PASSED for lens in maintainability history structure; do - case "${verdicts[$lens]:-MISSING}" in - MISSING|UNCLEAR) overall=FAILED ;; - esac + if [[ "${verdicts[$lens]:-MISSING}" != PASSED ]]; then + overall=FAILED + fi done marker_id=''