background_agent_mvp.yml

  1name: background_agent_mvp
  2
  3# NOTE: Scheduled runs disabled as of 2026-02-24. The workflow can still be
  4# triggered manually via workflow_dispatch. See Notion doc "Background Agent
  5# for Zed" for current status and contact info to resume this work.
  6on:
  7  # schedule:
  8  #   - cron: "0 16 * * 1-5"
  9  workflow_dispatch:
 10    inputs:
 11      crash_ids:
 12        description: "Optional comma-separated Sentry issue IDs (e.g. ZED-4VS,ZED-123)"
 13        required: false
 14        type: string
 15      reviewers:
 16        description: "Optional comma-separated GitHub reviewer handles"
 17        required: false
 18        type: string
 19      top:
 20        description: "Top N candidates when crash_ids is empty"
 21        required: false
 22        type: string
 23        default: "3"
 24
 25permissions:
 26  contents: write
 27  pull-requests: write
 28
 29env:
 30  FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
 31  DROID_MODEL: claude-opus-4-5-20251101
 32  SENTRY_ORG: zed-dev
 33
 34jobs:
 35  run-mvp:
 36    runs-on: ubuntu-latest
 37    timeout-minutes: 180
 38
 39    steps:
 40      - name: Checkout repository
 41        uses: actions/checkout@v4
 42        with:
 43          fetch-depth: 0
 44
 45      - name: Install Droid CLI
 46        run: |
 47          curl -fsSL https://app.factory.ai/cli | sh
 48          echo "${HOME}/.local/bin" >> "$GITHUB_PATH"
 49          echo "DROID_BIN=${HOME}/.local/bin/droid" >> "$GITHUB_ENV"
 50          "${HOME}/.local/bin/droid" --version
 51
 52      - name: Setup Python
 53        uses: actions/setup-python@v5
 54        with:
 55          python-version: "3.12"
 56
 57      - name: Resolve reviewers
 58        id: reviewers
 59        env:
 60          INPUT_REVIEWERS: ${{ inputs.reviewers }}
 61          DEFAULT_REVIEWERS: ${{ vars.BACKGROUND_AGENT_REVIEWERS }}
 62        run: |
 63          set -euo pipefail
 64          if [ -z "$DEFAULT_REVIEWERS" ]; then
 65            DEFAULT_REVIEWERS="eholk,morgankrey,osiewicz,bennetbo"
 66          fi
 67          REVIEWERS="${INPUT_REVIEWERS:-$DEFAULT_REVIEWERS}"
 68          REVIEWERS="$(echo "$REVIEWERS" | tr -d '[:space:]')"
 69          echo "reviewers=$REVIEWERS" >> "$GITHUB_OUTPUT"
 70
 71      - name: Select crash candidates
 72        id: candidates
 73        env:
 74          INPUT_CRASH_IDS: ${{ inputs.crash_ids }}
 75          INPUT_TOP: ${{ inputs.top }}
 76          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_BACKGROUND_AGENT_MVP_TOKEN }}
 77        run: |
 78          set -euo pipefail
 79
 80          PREFETCH_DIR="/tmp/crash-data"
 81          ARGS=(--select-only --prefetch-dir "$PREFETCH_DIR" --org "$SENTRY_ORG")
 82          if [ -n "$INPUT_CRASH_IDS" ]; then
 83            ARGS+=(--crash-ids "$INPUT_CRASH_IDS")
 84          else
 85            TARGET_DRAFT_PRS="${INPUT_TOP:-3}"
 86            if ! [[ "$TARGET_DRAFT_PRS" =~ ^[0-9]+$ ]] || [ "$TARGET_DRAFT_PRS" -lt 1 ]; then
 87              TARGET_DRAFT_PRS="3"
 88            fi
 89            CANDIDATE_TOP=$((TARGET_DRAFT_PRS * 5))
 90            if [ "$CANDIDATE_TOP" -gt 100 ]; then
 91              CANDIDATE_TOP=100
 92            fi
 93            ARGS+=(--top "$CANDIDATE_TOP" --sample-size 100)
 94          fi
 95
 96          IDS="$(python3 script/run-background-agent-mvp-local "${ARGS[@]}")"
 97
 98          if [ -z "$IDS" ]; then
 99            echo "No candidates selected"
100            exit 1
101          fi
102
103          echo "Using crash IDs: $IDS"
104          echo "ids=$IDS" >> "$GITHUB_OUTPUT"
105
106      - name: Run background agent pipeline per crash
107        id: pipeline
108        env:
109          GH_TOKEN: ${{ github.token }}
110          REVIEWERS: ${{ steps.reviewers.outputs.reviewers }}
111          CRASH_IDS: ${{ steps.candidates.outputs.ids }}
112          TARGET_DRAFT_PRS_INPUT: ${{ inputs.top }}
113        run: |
114          set -euo pipefail
115
116          git config user.name "factory-droid[bot]"
117          git config user.email "138933559+factory-droid[bot]@users.noreply.github.com"
118
119          # Crash ID format validation regex
120          CRASH_ID_PATTERN='^[A-Za-z0-9]+-[A-Za-z0-9]+$'
121          TARGET_DRAFT_PRS="${TARGET_DRAFT_PRS_INPUT:-3}"
122          if ! [[ "$TARGET_DRAFT_PRS" =~ ^[0-9]+$ ]] || [ "$TARGET_DRAFT_PRS" -lt 1 ]; then
123            TARGET_DRAFT_PRS="3"
124          fi
125          CREATED_DRAFT_PRS=0
126
127          IFS=',' read -r -a CRASH_ID_ARRAY <<< "$CRASH_IDS"
128
129          for CRASH_ID in "${CRASH_ID_ARRAY[@]}"; do
130            if [ "$CREATED_DRAFT_PRS" -ge "$TARGET_DRAFT_PRS" ]; then
131              echo "Reached target draft PR count ($TARGET_DRAFT_PRS), stopping candidate processing"
132              break
133            fi
134
135            CRASH_ID="$(echo "$CRASH_ID" | xargs)"
136            [ -z "$CRASH_ID" ] && continue
137
138            # Validate crash ID format to prevent injection via branch names or prompts
139            if ! [[ "$CRASH_ID" =~ $CRASH_ID_PATTERN ]]; then
140              echo "ERROR: Invalid crash ID format: '$CRASH_ID' — skipping"
141              continue
142            fi
143
144            BRANCH="background-agent/mvp-${CRASH_ID,,}-$(date +%Y%m%d)"
145            echo "Running crash pipeline for $CRASH_ID on $BRANCH"
146
147            # Deduplication: skip if a draft PR already exists for this crash
148            EXISTING_BRANCH_PR="$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number' || echo "")"
149            if [ -n "$EXISTING_BRANCH_PR" ]; then
150              echo "Draft PR #$EXISTING_BRANCH_PR already exists for $CRASH_ID — skipping"
151              continue
152            fi
153
154            if ! git fetch origin main; then
155              echo "WARNING: Failed to fetch origin/main for $CRASH_ID — skipping"
156              continue
157            fi
158
159            if ! git checkout -B "$BRANCH" origin/main; then
160              echo "WARNING: Failed to create checkout branch $BRANCH for $CRASH_ID — skipping"
161              continue
162            fi
163
164            CRASH_DATA_FILE="/tmp/crash-data/crash-${CRASH_ID}.md"
165            if [ ! -f "$CRASH_DATA_FILE" ]; then
166              echo "WARNING: No pre-fetched crash data for $CRASH_ID at $CRASH_DATA_FILE — skipping"
167              continue
168            fi
169
170            python3 -c "
171          import sys
172          crash_id, data_file = sys.argv[1], sys.argv[2]
173          prompt = f'''You are running the weekly background crash-fix MVP pipeline for crash {crash_id}.
174
175          The crash report has been pre-fetched and is available at: {data_file}
176          Read this file to get the crash data. Do not call script/sentry-fetch.
177
178          Required workflow:
179          1. Read the crash report from {data_file}
180          2. Read and follow .rules.
181          3. Follow .factory/prompts/crash/investigate.md and write ANALYSIS.md
182          4. Follow .factory/prompts/crash/link-issues.md and write LINKED_ISSUES.md
183          5. Follow .factory/prompts/crash/fix.md to implement a minimal fix with tests
184          6. Run validators required by the fix prompt for the affected code paths
185          7. Write PR_BODY.md with sections:
186             - Crash Summary
187             - Root Cause
188             - Fix
189             - Validation
190             - Potentially Related Issues (High/Medium/Low from LINKED_ISSUES.md)
191             - Reviewer Checklist
192             - Release Notes (final section; format as Release Notes:, then a blank line, then one bullet like - N/A)
193
194          Constraints:
195          - Do not merge or auto-approve.
196          - Keep changes narrowly scoped to this crash.
197          - Do not modify files in .github/, .factory/, or script/ directories.
198          - When investigating git history, limit your search to the last 2 weeks of commits. Do not traverse older history.
199          - If the crash is not solvable with available context, write a clear blocker summary to PR_BODY.md.
200          '''
201          import textwrap
202          with open('/tmp/background-agent-prompt.md', 'w') as f:
203              f.write(textwrap.dedent(prompt))
204          " "$CRASH_ID" "$CRASH_DATA_FILE"
205
206            if ! "$DROID_BIN" exec --auto medium -m "$DROID_MODEL" -f /tmp/background-agent-prompt.md; then
207              echo "Droid execution failed for $CRASH_ID, continuing to next candidate"
208              continue
209            fi
210
211            for REPORT_FILE in ANALYSIS.md LINKED_ISSUES.md PR_BODY.md; do
212              if [ -f "$REPORT_FILE" ]; then
213                echo "::group::${CRASH_ID} ${REPORT_FILE}"
214                cat "$REPORT_FILE"
215                echo "::endgroup::"
216              fi
217            done
218
219            if git diff --quiet; then
220              echo "No code changes produced for $CRASH_ID"
221              continue
222            fi
223
224            # Stage only expected file types — not git add -A
225            git add -- '*.rs' '*.toml' 'Cargo.lock' 'ANALYSIS.md' 'LINKED_ISSUES.md' 'PR_BODY.md'
226
227            # Reject changes to protected paths
228            PROTECTED_CHANGES="$(git diff --cached --name-only | grep -E '^(\.github/|\.factory/|script/)' || true)"
229            if [ -n "$PROTECTED_CHANGES" ]; then
230              echo "ERROR: Agent modified protected paths — aborting commit for $CRASH_ID:"
231              echo "$PROTECTED_CHANGES"
232              git reset HEAD -- .
233              continue
234            fi
235
236            if ! git diff --cached --quiet; then
237              git commit -m "Fix crash ${CRASH_ID}"
238            fi
239
240            git push -u origin "$BRANCH"
241
242            CRATE_PREFIX=""
243            CHANGED_CRATES="$(git diff --cached --name-only | awk -F/ '/^crates\/[^/]+\// {print $2}' | sort -u)"
244            if [ -n "$CHANGED_CRATES" ] && [ "$(printf "%s\n" "$CHANGED_CRATES" | wc -l | tr -d ' ')" -eq 1 ]; then
245              CRATE_PREFIX="${CHANGED_CRATES}: "
246            fi
247
248            TITLE="${CRATE_PREFIX}Fix crash ${CRASH_ID}"
249            BODY_FILE="PR_BODY.md"
250            if [ ! -f "$BODY_FILE" ]; then
251              BODY_FILE="/tmp/pr-body-${CRASH_ID}.md"
252              printf "Automated draft crash-fix pipeline output for %s.\n\nNo PR_BODY.md was generated by the agent; please review commit and linked artifacts manually.\n" "$CRASH_ID" > "$BODY_FILE"
253            fi
254
255            python3 -c '
256            import re
257            import sys
258
259            path = sys.argv[1]
260            body = open(path, encoding="utf-8").read()
261            pattern = re.compile(r"(^|\n)Release Notes:\r?\n(?:\r?\n)*(?P<bullets>(?:\s*-\s+.*(?:\r?\n|$))+)", re.MULTILINE)
262            match = pattern.search(body)
263
264            if match:
265                bullets = [
266                    re.sub(r"^\s*", "", bullet)
267                    for bullet in re.findall(r"^\s*-\s+.*$", match.group("bullets"), re.MULTILINE)
268                ]
269                if not bullets:
270                    bullets = ["- N/A"]
271                section = "Release Notes:\n\n" + "\n".join(bullets)
272                body_without_release_notes = (body[: match.start()] + body[match.end() :]).rstrip()
273                if body_without_release_notes:
274                    normalized_body = f"{body_without_release_notes}\n\n{section}\n"
275                else:
276                    normalized_body = f"{section}\n"
277            else:
278                normalized_body = body.rstrip() + "\n\nRelease Notes:\n\n- N/A\n"
279
280            with open(path, "w", encoding="utf-8") as file:
281                file.write(normalized_body)
282            ' "$BODY_FILE"
283
284            EXISTING_PR="$(gh pr list --head "$BRANCH" --json number --jq '.[0].number')"
285            if [ -n "$EXISTING_PR" ]; then
286              gh pr edit "$EXISTING_PR" --title "$TITLE" --body-file "$BODY_FILE"
287              PR_NUMBER="$EXISTING_PR"
288            else
289              PR_URL="$(gh pr create --draft --base main --head "$BRANCH" --title "$TITLE" --body-file "$BODY_FILE")"
290              PR_NUMBER="$(basename "$PR_URL")"
291            fi
292
293            if [ -n "$REVIEWERS" ]; then
294              IFS=',' read -r -a REVIEWER_ARRAY <<< "$REVIEWERS"
295              for REVIEWER in "${REVIEWER_ARRAY[@]}"; do
296                [ -z "$REVIEWER" ] && continue
297                gh pr edit "$PR_NUMBER" --add-reviewer "$REVIEWER" || true
298              done
299            fi
300
301            CREATED_DRAFT_PRS=$((CREATED_DRAFT_PRS + 1))
302            echo "Created/updated draft PRs this run: $CREATED_DRAFT_PRS/$TARGET_DRAFT_PRS"
303          done
304
305          echo "created_draft_prs=$CREATED_DRAFT_PRS" >> "$GITHUB_OUTPUT"
306          echo "target_draft_prs=$TARGET_DRAFT_PRS" >> "$GITHUB_OUTPUT"
307
308      - name: Cleanup pre-fetched crash data
309        if: always()
310        run: rm -rf /tmp/crash-data
311
312      - name: Workflow summary
313        if: always()
314        env:
315          SUMMARY_CRASH_IDS: ${{ steps.candidates.outputs.ids }}
316          SUMMARY_REVIEWERS: ${{ steps.reviewers.outputs.reviewers }}
317          SUMMARY_CREATED_DRAFT_PRS: ${{ steps.pipeline.outputs.created_draft_prs }}
318          SUMMARY_TARGET_DRAFT_PRS: ${{ steps.pipeline.outputs.target_draft_prs }}
319        run: |
320          {
321            echo "## Background Agent MVP"
322            echo ""
323            echo "- Crash IDs: ${SUMMARY_CRASH_IDS:-none}"
324            echo "- Reviewer routing: ${SUMMARY_REVIEWERS:-NOT CONFIGURED}"
325            echo "- Draft PRs created: ${SUMMARY_CREATED_DRAFT_PRS:-0}/${SUMMARY_TARGET_DRAFT_PRS:-3}"
326            echo "- Pipeline: investigate -> link-issues -> fix -> draft PR"
327          } >> "$GITHUB_STEP_SUMMARY"
328
329concurrency:
330  group: background-agent-mvp
331  cancel-in-progress: false