name: background_agent_mvp # NOTE: Scheduled runs disabled as of 2026-02-24. The workflow can still be # triggered manually via workflow_dispatch. See Notion doc "Background Agent # for Zed" for current status and contact info to resume this work. on: # schedule: # - cron: "0 16 * * 1-5" workflow_dispatch: inputs: crash_ids: description: "Optional comma-separated Sentry issue IDs (e.g. ZED-4VS,ZED-123)" required: false type: string reviewers: description: "Optional comma-separated GitHub reviewer handles" required: false type: string top: description: "Top N candidates when crash_ids is empty" required: false type: string default: "3" permissions: contents: write pull-requests: write env: FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }} DROID_MODEL: claude-opus-4-5-20251101 SENTRY_ORG: zed-dev jobs: run-mvp: runs-on: ubuntu-latest timeout-minutes: 180 steps: - name: Checkout repository uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 with: fetch-depth: 0 - name: Install Droid CLI run: | curl -fsSL https://app.factory.ai/cli | sh echo "${HOME}/.local/bin" >> "$GITHUB_PATH" echo "DROID_BIN=${HOME}/.local/bin/droid" >> "$GITHUB_ENV" "${HOME}/.local/bin/droid" --version - name: Setup Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" - name: Resolve reviewers id: reviewers env: INPUT_REVIEWERS: ${{ inputs.reviewers }} DEFAULT_REVIEWERS: ${{ vars.BACKGROUND_AGENT_REVIEWERS }} run: | set -euo pipefail if [ -z "$DEFAULT_REVIEWERS" ]; then DEFAULT_REVIEWERS="eholk,morgankrey,osiewicz,bennetbo" fi REVIEWERS="${INPUT_REVIEWERS:-$DEFAULT_REVIEWERS}" REVIEWERS="$(echo "$REVIEWERS" | tr -d '[:space:]')" echo "reviewers=$REVIEWERS" >> "$GITHUB_OUTPUT" - name: Select crash candidates id: candidates env: INPUT_CRASH_IDS: ${{ inputs.crash_ids }} INPUT_TOP: ${{ inputs.top }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_BACKGROUND_AGENT_MVP_TOKEN }} run: | set -euo pipefail PREFETCH_DIR="/tmp/crash-data" ARGS=(--select-only --prefetch-dir "$PREFETCH_DIR" --org "$SENTRY_ORG") if [ -n "$INPUT_CRASH_IDS" ]; then ARGS+=(--crash-ids "$INPUT_CRASH_IDS") else TARGET_DRAFT_PRS="${INPUT_TOP:-3}" if ! [[ "$TARGET_DRAFT_PRS" =~ ^[0-9]+$ ]] || [ "$TARGET_DRAFT_PRS" -lt 1 ]; then TARGET_DRAFT_PRS="3" fi CANDIDATE_TOP=$((TARGET_DRAFT_PRS * 5)) if [ "$CANDIDATE_TOP" -gt 100 ]; then CANDIDATE_TOP=100 fi ARGS+=(--top "$CANDIDATE_TOP" --sample-size 100) fi IDS="$(python3 script/run-background-agent-mvp-local "${ARGS[@]}")" if [ -z "$IDS" ]; then echo "No candidates selected" exit 1 fi echo "Using crash IDs: $IDS" echo "ids=$IDS" >> "$GITHUB_OUTPUT" - name: Run background agent pipeline per crash id: pipeline env: GH_TOKEN: ${{ github.token }} REVIEWERS: ${{ steps.reviewers.outputs.reviewers }} CRASH_IDS: ${{ steps.candidates.outputs.ids }} TARGET_DRAFT_PRS_INPUT: ${{ inputs.top }} run: | set -euo pipefail git config user.name "factory-droid[bot]" git config user.email "138933559+factory-droid[bot]@users.noreply.github.com" # Crash ID format validation regex CRASH_ID_PATTERN='^[A-Za-z0-9]+-[A-Za-z0-9]+$' TARGET_DRAFT_PRS="${TARGET_DRAFT_PRS_INPUT:-3}" if ! [[ "$TARGET_DRAFT_PRS" =~ ^[0-9]+$ ]] || [ "$TARGET_DRAFT_PRS" -lt 1 ]; then TARGET_DRAFT_PRS="3" fi CREATED_DRAFT_PRS=0 IFS=',' read -r -a CRASH_ID_ARRAY <<< "$CRASH_IDS" for CRASH_ID in "${CRASH_ID_ARRAY[@]}"; do if [ "$CREATED_DRAFT_PRS" -ge "$TARGET_DRAFT_PRS" ]; then echo "Reached target draft PR count ($TARGET_DRAFT_PRS), stopping candidate processing" break fi CRASH_ID="$(echo "$CRASH_ID" | xargs)" [ -z "$CRASH_ID" ] && continue # Validate crash ID format to prevent injection via branch names or prompts if ! [[ "$CRASH_ID" =~ $CRASH_ID_PATTERN ]]; then echo "ERROR: Invalid crash ID format: '$CRASH_ID' — skipping" continue fi BRANCH="background-agent/mvp-${CRASH_ID,,}-$(date +%Y%m%d)" echo "Running crash pipeline for $CRASH_ID on $BRANCH" # Deduplication: skip if a draft PR already exists for this crash EXISTING_BRANCH_PR="$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number' || echo "")" if [ -n "$EXISTING_BRANCH_PR" ]; then echo "Draft PR #$EXISTING_BRANCH_PR already exists for $CRASH_ID — skipping" continue fi if ! git fetch origin main; then echo "WARNING: Failed to fetch origin/main for $CRASH_ID — skipping" continue fi if ! git checkout -B "$BRANCH" origin/main; then echo "WARNING: Failed to create checkout branch $BRANCH for $CRASH_ID — skipping" continue fi CRASH_DATA_FILE="/tmp/crash-data/crash-${CRASH_ID}.md" if [ ! -f "$CRASH_DATA_FILE" ]; then echo "WARNING: No pre-fetched crash data for $CRASH_ID at $CRASH_DATA_FILE — skipping" continue fi python3 -c " import sys crash_id, data_file = sys.argv[1], sys.argv[2] prompt = f'''You are running the weekly background crash-fix MVP pipeline for crash {crash_id}. The crash report has been pre-fetched and is available at: {data_file} Read this file to get the crash data. Do not call script/sentry-fetch. Required workflow: 1. Read the crash report from {data_file} 2. Read and follow .rules. 3. Follow .factory/prompts/crash/investigate.md and write ANALYSIS.md 4. Follow .factory/prompts/crash/link-issues.md and write LINKED_ISSUES.md 5. Follow .factory/prompts/crash/fix.md to implement a minimal fix with tests 6. Run validators required by the fix prompt for the affected code paths 7. Write PR_BODY.md with sections: - Crash Summary - Root Cause - Fix - Validation - Potentially Related Issues (High/Medium/Low from LINKED_ISSUES.md) - Reviewer Checklist - Release Notes (final section; format as Release Notes:, then a blank line, then one bullet like - N/A) Constraints: - Do not merge or auto-approve. - Keep changes narrowly scoped to this crash. - Do not modify files in .github/, .factory/, or script/ directories. - When investigating git history, limit your search to the last 2 weeks of commits. Do not traverse older history. - If the crash is not solvable with available context, write a clear blocker summary to PR_BODY.md. ''' import textwrap with open('/tmp/background-agent-prompt.md', 'w') as f: f.write(textwrap.dedent(prompt)) " "$CRASH_ID" "$CRASH_DATA_FILE" if ! "$DROID_BIN" exec --auto medium -m "$DROID_MODEL" -f /tmp/background-agent-prompt.md; then echo "Droid execution failed for $CRASH_ID, continuing to next candidate" continue fi for REPORT_FILE in ANALYSIS.md LINKED_ISSUES.md PR_BODY.md; do if [ -f "$REPORT_FILE" ]; then echo "::group::${CRASH_ID} ${REPORT_FILE}" cat "$REPORT_FILE" echo "::endgroup::" fi done if git diff --quiet; then echo "No code changes produced for $CRASH_ID" continue fi # Stage only expected file types — not git add -A git add -- '*.rs' '*.toml' 'Cargo.lock' 'ANALYSIS.md' 'LINKED_ISSUES.md' 'PR_BODY.md' # Reject changes to protected paths PROTECTED_CHANGES="$(git diff --cached --name-only | grep -E '^(\.github/|\.factory/|script/)' || true)" if [ -n "$PROTECTED_CHANGES" ]; then echo "ERROR: Agent modified protected paths — aborting commit for $CRASH_ID:" echo "$PROTECTED_CHANGES" git reset HEAD -- . continue fi if ! git diff --cached --quiet; then git commit -m "Fix crash ${CRASH_ID}" fi git push -u origin "$BRANCH" CRATE_PREFIX="" CHANGED_CRATES="$(git diff --cached --name-only | awk -F/ '/^crates\/[^/]+\// {print $2}' | sort -u)" if [ -n "$CHANGED_CRATES" ] && [ "$(printf "%s\n" "$CHANGED_CRATES" | wc -l | tr -d ' ')" -eq 1 ]; then CRATE_PREFIX="${CHANGED_CRATES}: " fi TITLE="${CRATE_PREFIX}Fix crash ${CRASH_ID}" BODY_FILE="PR_BODY.md" if [ ! -f "$BODY_FILE" ]; then BODY_FILE="/tmp/pr-body-${CRASH_ID}.md" 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" fi python3 -c ' import re import sys path = sys.argv[1] body = open(path, encoding="utf-8").read() pattern = re.compile(r"(^|\n)Release Notes:\r?\n(?:\r?\n)*(?P(?:\s*-\s+.*(?:\r?\n|$))+)", re.MULTILINE) match = pattern.search(body) if match: bullets = [ re.sub(r"^\s*", "", bullet) for bullet in re.findall(r"^\s*-\s+.*$", match.group("bullets"), re.MULTILINE) ] if not bullets: bullets = ["- N/A"] section = "Release Notes:\n\n" + "\n".join(bullets) body_without_release_notes = (body[: match.start()] + body[match.end() :]).rstrip() if body_without_release_notes: normalized_body = f"{body_without_release_notes}\n\n{section}\n" else: normalized_body = f"{section}\n" else: normalized_body = body.rstrip() + "\n\nRelease Notes:\n\n- N/A\n" with open(path, "w", encoding="utf-8") as file: file.write(normalized_body) ' "$BODY_FILE" EXISTING_PR="$(gh pr list --head "$BRANCH" --json number --jq '.[0].number')" if [ -n "$EXISTING_PR" ]; then gh pr edit "$EXISTING_PR" --title "$TITLE" --body-file "$BODY_FILE" PR_NUMBER="$EXISTING_PR" else PR_URL="$(gh pr create --draft --base main --head "$BRANCH" --title "$TITLE" --body-file "$BODY_FILE")" PR_NUMBER="$(basename "$PR_URL")" fi if [ -n "$REVIEWERS" ]; then IFS=',' read -r -a REVIEWER_ARRAY <<< "$REVIEWERS" for REVIEWER in "${REVIEWER_ARRAY[@]}"; do [ -z "$REVIEWER" ] && continue gh pr edit "$PR_NUMBER" --add-reviewer "$REVIEWER" || true done fi CREATED_DRAFT_PRS=$((CREATED_DRAFT_PRS + 1)) echo "Created/updated draft PRs this run: $CREATED_DRAFT_PRS/$TARGET_DRAFT_PRS" done echo "created_draft_prs=$CREATED_DRAFT_PRS" >> "$GITHUB_OUTPUT" echo "target_draft_prs=$TARGET_DRAFT_PRS" >> "$GITHUB_OUTPUT" - name: Cleanup pre-fetched crash data if: always() run: rm -rf /tmp/crash-data - name: Workflow summary if: always() env: SUMMARY_CRASH_IDS: ${{ steps.candidates.outputs.ids }} SUMMARY_REVIEWERS: ${{ steps.reviewers.outputs.reviewers }} SUMMARY_CREATED_DRAFT_PRS: ${{ steps.pipeline.outputs.created_draft_prs }} SUMMARY_TARGET_DRAFT_PRS: ${{ steps.pipeline.outputs.target_draft_prs }} run: | { echo "## Background Agent MVP" echo "" echo "- Crash IDs: ${SUMMARY_CRASH_IDS:-none}" echo "- Reviewer routing: ${SUMMARY_REVIEWERS:-NOT CONFIGURED}" echo "- Draft PRs created: ${SUMMARY_CREATED_DRAFT_PRS:-0}/${SUMMARY_TARGET_DRAFT_PRS:-3}" echo "- Pipeline: investigate -> link-issues -> fix -> draft PR" } >> "$GITHUB_STEP_SUMMARY" concurrency: group: background-agent-mvp cancel-in-progress: false