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