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. Follow .factory/prompts/crash/investigate.md and write ANALYSIS.md
180 3. Follow .factory/prompts/crash/link-issues.md and write LINKED_ISSUES.md
181 4. Follow .factory/prompts/crash/fix.md to implement a minimal fix with tests
182 5. Run validators required by the fix prompt for the affected code paths
183 6. Write PR_BODY.md with sections:
184 - Crash Summary
185 - Root Cause
186 - Fix
187 - Validation
188 - Potentially Related Issues (High/Medium/Low from LINKED_ISSUES.md)
189 - Reviewer Checklist
190
191 Constraints:
192 - Do not merge or auto-approve.
193 - Keep changes narrowly scoped to this crash.
194 - Do not modify files in .github/, .factory/, or script/ directories.
195 - When investigating git history, limit your search to the last 2 weeks of commits. Do not traverse older history.
196 - If the crash is not solvable with available context, write a clear blocker summary to PR_BODY.md.
197 '''
198 import textwrap
199 with open('/tmp/background-agent-prompt.md', 'w') as f:
200 f.write(textwrap.dedent(prompt))
201 " "$CRASH_ID" "$CRASH_DATA_FILE"
202
203 if ! "$DROID_BIN" exec --auto medium -m "$DROID_MODEL" -f /tmp/background-agent-prompt.md; then
204 echo "Droid execution failed for $CRASH_ID, continuing to next candidate"
205 continue
206 fi
207
208 for REPORT_FILE in ANALYSIS.md LINKED_ISSUES.md PR_BODY.md; do
209 if [ -f "$REPORT_FILE" ]; then
210 echo "::group::${CRASH_ID} ${REPORT_FILE}"
211 cat "$REPORT_FILE"
212 echo "::endgroup::"
213 fi
214 done
215
216 if git diff --quiet; then
217 echo "No code changes produced for $CRASH_ID"
218 continue
219 fi
220
221 # Stage only expected file types — not git add -A
222 git add -- '*.rs' '*.toml' 'Cargo.lock' 'ANALYSIS.md' 'LINKED_ISSUES.md' 'PR_BODY.md'
223
224 # Reject changes to protected paths
225 PROTECTED_CHANGES="$(git diff --cached --name-only | grep -E '^(\.github/|\.factory/|script/)' || true)"
226 if [ -n "$PROTECTED_CHANGES" ]; then
227 echo "ERROR: Agent modified protected paths — aborting commit for $CRASH_ID:"
228 echo "$PROTECTED_CHANGES"
229 git reset HEAD -- .
230 continue
231 fi
232
233 if ! git diff --cached --quiet; then
234 git commit -m "fix: draft crash fix for ${CRASH_ID}"
235 fi
236
237 git push -u origin "$BRANCH"
238
239 TITLE="fix: draft crash fix for ${CRASH_ID}"
240 BODY_FILE="PR_BODY.md"
241 if [ ! -f "$BODY_FILE" ]; then
242 BODY_FILE="/tmp/pr-body-${CRASH_ID}.md"
243 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"
244 fi
245
246 EXISTING_PR="$(gh pr list --head "$BRANCH" --json number --jq '.[0].number')"
247 if [ -n "$EXISTING_PR" ]; then
248 gh pr edit "$EXISTING_PR" --title "$TITLE" --body-file "$BODY_FILE"
249 PR_NUMBER="$EXISTING_PR"
250 else
251 PR_URL="$(gh pr create --draft --base main --head "$BRANCH" --title "$TITLE" --body-file "$BODY_FILE")"
252 PR_NUMBER="$(basename "$PR_URL")"
253 fi
254
255 if [ -n "$REVIEWERS" ]; then
256 IFS=',' read -r -a REVIEWER_ARRAY <<< "$REVIEWERS"
257 for REVIEWER in "${REVIEWER_ARRAY[@]}"; do
258 [ -z "$REVIEWER" ] && continue
259 gh pr edit "$PR_NUMBER" --add-reviewer "$REVIEWER" || true
260 done
261 fi
262
263 CREATED_DRAFT_PRS=$((CREATED_DRAFT_PRS + 1))
264 echo "Created/updated draft PRs this run: $CREATED_DRAFT_PRS/$TARGET_DRAFT_PRS"
265 done
266
267 echo "created_draft_prs=$CREATED_DRAFT_PRS" >> "$GITHUB_OUTPUT"
268 echo "target_draft_prs=$TARGET_DRAFT_PRS" >> "$GITHUB_OUTPUT"
269
270 - name: Cleanup pre-fetched crash data
271 if: always()
272 run: rm -rf /tmp/crash-data
273
274 - name: Workflow summary
275 if: always()
276 env:
277 SUMMARY_CRASH_IDS: ${{ steps.candidates.outputs.ids }}
278 SUMMARY_REVIEWERS: ${{ steps.reviewers.outputs.reviewers }}
279 SUMMARY_CREATED_DRAFT_PRS: ${{ steps.pipeline.outputs.created_draft_prs }}
280 SUMMARY_TARGET_DRAFT_PRS: ${{ steps.pipeline.outputs.target_draft_prs }}
281 run: |
282 {
283 echo "## Background Agent MVP"
284 echo ""
285 echo "- Crash IDs: ${SUMMARY_CRASH_IDS:-none}"
286 echo "- Reviewer routing: ${SUMMARY_REVIEWERS:-NOT CONFIGURED}"
287 echo "- Draft PRs created: ${SUMMARY_CREATED_DRAFT_PRS:-0}/${SUMMARY_TARGET_DRAFT_PRS:-3}"
288 echo "- Pipeline: investigate -> link-issues -> fix -> draft PR"
289 } >> "$GITHUB_STEP_SUMMARY"
290
291concurrency:
292 group: background-agent-mvp
293 cancel-in-progress: false