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