Blame · Line-by-line history
fix-agent.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 055ebc4 | 1 | /** |
| 2 | * Block K5 — Fix Agent. | |
| 3 | * | |
| 4 | * Pairs with Gatetest. When a PR has failing checks (or a human pokes the | |
| 5 | * "try fix" button), the fix agent calls `runAndRepair` on Gatetest, which | |
| 6 | * returns file-level before/after patches. For v1 we DO NOT auto-push the | |
| 7 | * patches — instead we post a structured PR comment listing each repair with | |
| 8 | * its reason. A human (or a follow-up agent) applies. This keeps the blast | |
| 9 | * radius bounded while the platform gets its sea legs. | |
| 10 | * | |
| 11 | * Design rules (mirrors heal-bot.ts): | |
| 12 | * - Never throws. Every DB / network error returns `{ ok: false, summary }`. | |
| 13 | * - Runs inside the K1 `executeAgentRun` wrapper so the run row carries | |
| 14 | * status + log + cost. | |
| 15 | * - Cost: 3¢ flat (Gatetest compute proxy). No Anthropic round-trip. | |
| 16 | * - No new tables. | |
| 17 | */ | |
| 18 | ||
| 19 | import { eq } from "drizzle-orm"; | |
| 20 | import { db } from "../../db"; | |
| 21 | import { | |
| 22 | prComments, | |
| 23 | pullRequests, | |
| 24 | repositories, | |
| 25 | users, | |
| 26 | } from "../../db/schema"; | |
| 27 | import { runAndRepair, type GatetestRepair } from "../gatetest-client"; | |
| 28 | import { | |
| 29 | executeAgentRun, | |
| 30 | startAgentRun, | |
| 31 | type AgentExecutorContext, | |
| 32 | } from "../agent-runtime"; | |
| 33 | ||
| 34 | // --------------------------------------------------------------------------- | |
| 35 | // Types | |
| 36 | // --------------------------------------------------------------------------- | |
| 37 | ||
| 38 | export interface RunFixAgentArgs { | |
| 39 | repositoryId: string; | |
| 40 | pullRequestId: string; | |
| 41 | triggerBy?: string | null; | |
| 42 | targetGlob?: string | null; | |
| 43 | } | |
| 44 | ||
| 45 | export interface RunFixAgentResult { | |
| 46 | ok: boolean; | |
| 47 | summary: string; | |
| 48 | runId: string | null; | |
| 49 | } | |
| 50 | ||
| 51 | // --------------------------------------------------------------------------- | |
| 52 | // Constants | |
| 53 | // --------------------------------------------------------------------------- | |
| 54 | ||
| 55 | /** Per-run Gatetest compute cost proxy, in cents. */ | |
| 56 | export const FIX_AGENT_COST_CENTS = 3; | |
| 57 | ||
| 58 | /** Max individual repairs to enumerate in the PR comment body. */ | |
| 59 | export const FIX_AGENT_MAX_REPAIRS_IN_COMMENT = 20; | |
| 60 | ||
| 61 | /** Bot slug — matches the identity K2 will ensureAgentApp for. */ | |
| 62 | export const FIX_AGENT_SLUG = "agent-fix"; | |
| 63 | ||
| 64 | /** Bot username used in comment bodies so humans know who commented. */ | |
| 65 | export const FIX_AGENT_BOT_USERNAME = "agent-fix[bot]"; | |
| 66 | ||
| 67 | // --------------------------------------------------------------------------- | |
| 68 | // Pure helpers (unit-testable) | |
| 69 | // --------------------------------------------------------------------------- | |
| 70 | ||
| 71 | /** | |
| 72 | * Render the PR comment body for a successful Gatetest run. Deterministic + | |
| 73 | * no I/O so we can exercise it in isolation. | |
| 74 | */ | |
| 75 | export function renderFixAgentComment(params: { | |
| 76 | passed: boolean; | |
| 77 | totalTests: number; | |
| 78 | failedBefore: number; | |
| 79 | failedAfter: number; | |
| 80 | repairs: GatetestRepair[]; | |
| 81 | unfixable: { file: string; reason: string }[]; | |
| 82 | }): string { | |
| 83 | const { passed, totalTests, failedBefore, failedAfter, repairs, unfixable } = | |
| 84 | params; | |
| 85 | const lines: string[] = []; | |
| 86 | lines.push(`## Fix Agent`); | |
| 87 | lines.push(""); | |
| 88 | if (passed) { | |
| 89 | lines.push( | |
| 90 | `Gatetest ran **${totalTests}** test${totalTests === 1 ? "" : "s"} — all passing after ${repairs.length} repair${repairs.length === 1 ? "" : "s"}.` | |
| 91 | ); | |
| 92 | } else { | |
| 93 | lines.push( | |
| 94 | `Gatetest proposed **${repairs.length}** repair${repairs.length === 1 ? "" : "s"} (${failedBefore} → ${failedAfter} failing).` | |
| 95 | ); | |
| 96 | } | |
| 97 | lines.push(""); | |
| 98 | ||
| 99 | if (repairs.length > 0) { | |
| 100 | lines.push(`### Repairs`); | |
| 101 | const cap = Math.min(repairs.length, FIX_AGENT_MAX_REPAIRS_IN_COMMENT); | |
| 102 | for (let i = 0; i < cap; i++) { | |
| 103 | const r = repairs[i]!; | |
| 104 | lines.push(`- \`${r.file}\` — ${r.reason}`); | |
| 105 | } | |
| 106 | if (repairs.length > cap) { | |
| 107 | lines.push(`- _…and ${repairs.length - cap} more._`); | |
| 108 | } | |
| 109 | lines.push(""); | |
| 110 | } | |
| 111 | ||
| 112 | if (unfixable.length > 0) { | |
| 113 | lines.push(`### Unfixable`); | |
| 114 | for (const u of unfixable.slice(0, FIX_AGENT_MAX_REPAIRS_IN_COMMENT)) { | |
| 115 | lines.push(`- \`${u.file}\` — ${u.reason}`); | |
| 116 | } | |
| 117 | lines.push(""); | |
| 118 | } | |
| 119 | ||
| 120 | lines.push(`_Generated by ${FIX_AGENT_BOT_USERNAME}._`); | |
| 121 | return lines.join("\n"); | |
| 122 | } | |
| 123 | ||
| 124 | /** Short summary stored in agent_runs.summary. */ | |
| 125 | export function buildFixAgentSummary(params: { | |
| 126 | offline: boolean; | |
| 127 | passed: boolean; | |
| 128 | failedBefore: number; | |
| 129 | failedAfter: number; | |
| 130 | repairs: number; | |
| 131 | }): string { | |
| 132 | if (params.offline) return "gatetest offline; skipped"; | |
| 133 | if (params.repairs === 0 && params.failedBefore === 0) { | |
| 134 | return "suite healthy; nothing to fix"; | |
| 135 | } | |
| 136 | if (params.repairs === 0) { | |
| 137 | return `${params.failedBefore} failing, 0 repairs proposed`; | |
| 138 | } | |
| 139 | if (params.passed) { | |
| 140 | return `repaired ${params.repairs} (${params.failedBefore} → 0)`; | |
| 141 | } | |
| 142 | return `proposed ${params.repairs} repair${params.repairs === 1 ? "" : "s"} (${params.failedBefore} → ${params.failedAfter})`; | |
| 143 | } | |
| 144 | ||
| 145 | // --------------------------------------------------------------------------- | |
| 146 | // Internal DB helpers — defensive, never throw. | |
| 147 | // --------------------------------------------------------------------------- | |
| 148 | ||
| 149 | interface PrContext { | |
| 150 | prId: string; | |
| 151 | prNumber: number; | |
| 152 | headBranch: string; | |
| 153 | isClosed: boolean; | |
| 154 | isMerged: boolean; | |
| 155 | repoId: string; | |
| 156 | repoName: string; | |
| 157 | ownerUsername: string; | |
| 158 | ownerId: string; | |
| 159 | } | |
| 160 | ||
| 161 | async function resolvePrContext( | |
| 162 | pullRequestId: string | |
| 163 | ): Promise<PrContext | null> { | |
| 164 | try { | |
| 165 | const rows = await db | |
| 166 | .select({ | |
| 167 | prId: pullRequests.id, | |
| 168 | prNumber: pullRequests.number, | |
| 169 | headBranch: pullRequests.headBranch, | |
| 170 | state: pullRequests.state, | |
| 171 | mergedAt: pullRequests.mergedAt, | |
| 172 | repoId: repositories.id, | |
| 173 | repoName: repositories.name, | |
| 174 | ownerUsername: users.username, | |
| 175 | ownerId: repositories.ownerId, | |
| 176 | }) | |
| 177 | .from(pullRequests) | |
| 178 | .innerJoin(repositories, eq(repositories.id, pullRequests.repositoryId)) | |
| 179 | .innerJoin(users, eq(users.id, repositories.ownerId)) | |
| 180 | .where(eq(pullRequests.id, pullRequestId)) | |
| 181 | .limit(1); | |
| 182 | const row = rows[0]; | |
| 183 | if (!row) return null; | |
| 184 | return { | |
| 185 | prId: row.prId, | |
| 186 | prNumber: row.prNumber, | |
| 187 | headBranch: row.headBranch, | |
| 188 | isClosed: row.state !== "open", | |
| 189 | isMerged: row.mergedAt !== null, | |
| 190 | repoId: row.repoId, | |
| 191 | repoName: row.repoName, | |
| 192 | ownerUsername: row.ownerUsername, | |
| 193 | ownerId: row.ownerId, | |
| 194 | }; | |
| 195 | } catch (err) { | |
| 196 | console.error("[fix-agent] resolvePrContext:", err); | |
| 197 | return null; | |
| 198 | } | |
| 199 | } | |
| 200 | ||
| 201 | /** | |
| 202 | * Pick the author_id for the PR comment. Preference: | |
| 203 | * 1. a "bot" user named FIX_AGENT_BOT_USERNAME, | |
| 204 | * 2. the repo owner. | |
| 205 | */ | |
| 206 | async function resolveFixAgentAuthorId( | |
| 207 | ownerId: string | |
| 208 | ): Promise<string | null> { | |
| 209 | try { | |
| 210 | const [bot] = await db | |
| 211 | .select({ id: users.id }) | |
| 212 | .from(users) | |
| 213 | .where(eq(users.username, FIX_AGENT_BOT_USERNAME)) | |
| 214 | .limit(1); | |
| 215 | if (bot?.id) return bot.id; | |
| 216 | } catch (err) { | |
| 217 | console.error("[fix-agent] bot author lookup:", err); | |
| 218 | } | |
| 219 | return ownerId; | |
| 220 | } | |
| 221 | ||
| 222 | // --------------------------------------------------------------------------- | |
| 223 | // Entry point | |
| 224 | // --------------------------------------------------------------------------- | |
| 225 | ||
| 226 | /** | |
| 227 | * Run the fix agent for a single PR. Never throws. | |
| 228 | * | |
| 229 | * Lifecycle: | |
| 230 | * 1. Open an `agent_runs` row (kind = "fix"). | |
| 231 | * 2. Resolve the PR; short-circuit if closed/merged. | |
| 232 | * 3. Call Gatetest `runAndRepair` against the PR head branch. | |
| 233 | * 4. On any result post a PR comment summarising the repairs. | |
| 234 | * 5. Cost: 3¢ flat. | |
| 235 | */ | |
| 236 | export async function runFixAgent( | |
| 237 | args: RunFixAgentArgs | |
| 238 | ): Promise<RunFixAgentResult> { | |
| 239 | if ( | |
| 240 | !args || | |
| 241 | typeof args.repositoryId !== "string" || | |
| 242 | !args.repositoryId || | |
| 243 | typeof args.pullRequestId !== "string" || | |
| 244 | !args.pullRequestId | |
| 245 | ) { | |
| 246 | return { | |
| 247 | ok: false, | |
| 248 | summary: "invalid args: missing repositoryId or pullRequestId", | |
| 249 | runId: null, | |
| 250 | }; | |
| 251 | } | |
| 252 | ||
| 253 | const trigger = args.triggerBy ? "manual" : "pr.review_comment"; | |
| 254 | ||
| 255 | const run = await startAgentRun({ | |
| 256 | repositoryId: args.repositoryId, | |
| 257 | kind: "fix", | |
| 258 | trigger, | |
| 259 | triggerRef: args.pullRequestId, | |
| 260 | }); | |
| 261 | if (!run) { | |
| 262 | return { | |
| 263 | ok: false, | |
| 264 | summary: "could not open agent_runs row", | |
| 265 | runId: null, | |
| 266 | }; | |
| 267 | } | |
| 268 | ||
| 269 | let finalSummary = "not started"; | |
| 270 | ||
| 271 | await executeAgentRun(run.id, async (ctx: AgentExecutorContext) => { | |
| 272 | await ctx.appendLog( | |
| 273 | `[fix-agent] starting run for PR ${args.pullRequestId} (trigger=${trigger})` | |
| 274 | ); | |
| 275 | ||
| 276 | const pr = await resolvePrContext(args.pullRequestId); | |
| 277 | if (!pr) { | |
| 278 | finalSummary = "PR lookup failed"; | |
| 279 | await ctx.appendLog("[fix-agent] PR lookup failed; aborting"); | |
| 280 | return { ok: false, summary: finalSummary }; | |
| 281 | } | |
| 282 | if (pr.repoId !== args.repositoryId) { | |
| 283 | finalSummary = "PR does not belong to repository"; | |
| 284 | await ctx.appendLog( | |
| 285 | `[fix-agent] PR ${pr.prId} is in repo ${pr.repoId}, expected ${args.repositoryId}` | |
| 286 | ); | |
| 287 | return { ok: false, summary: finalSummary }; | |
| 288 | } | |
| 289 | if (pr.isClosed || pr.isMerged) { | |
| 290 | finalSummary = "PR is closed/merged; skipped"; | |
| 291 | await ctx.appendLog(`[fix-agent] ${finalSummary}`); | |
| 292 | return { ok: true, summary: finalSummary }; | |
| 293 | } | |
| 294 | ||
| 295 | const repoSlug = `${pr.ownerUsername}/${pr.repoName}`; | |
| 296 | await ctx.appendLog( | |
| 297 | `[fix-agent] calling gatetest.runAndRepair for ${repoSlug}@${pr.headBranch}` | |
| 298 | ); | |
| 299 | ||
| 300 | const result = await runAndRepair({ | |
| 301 | repo: repoSlug, | |
| 302 | ref: pr.headBranch, | |
| 303 | targetGlob: args.targetGlob || undefined, | |
| 304 | }); | |
| 305 | await ctx.recordCost(0, 0, FIX_AGENT_COST_CENTS); | |
| 306 | ||
| 307 | if (result.offline) { | |
| 308 | finalSummary = buildFixAgentSummary({ | |
| 309 | offline: true, | |
| 310 | passed: false, | |
| 311 | failedBefore: 0, | |
| 312 | failedAfter: 0, | |
| 313 | repairs: 0, | |
| 314 | }); | |
| 315 | await ctx.appendLog(`[fix-agent] ${finalSummary}`); | |
| 316 | return { ok: true, summary: finalSummary }; | |
| 317 | } | |
| 318 | ||
| 319 | await ctx.appendLog( | |
| 320 | `[fix-agent] gatetest returned: passed=${result.passed}, failedBefore=${result.failedBefore}, failedAfter=${result.failedAfter}, repairs=${result.repairs.length}` | |
| 321 | ); | |
| 322 | ||
| 323 | // If nothing to say, short-circuit before spamming the PR. | |
| 324 | if ( | |
| 325 | result.repairs.length === 0 && | |
| 326 | result.unfixable.length === 0 && | |
| 327 | result.failedBefore === 0 | |
| 328 | ) { | |
| 329 | finalSummary = buildFixAgentSummary({ | |
| 330 | offline: false, | |
| 331 | passed: true, | |
| 332 | failedBefore: 0, | |
| 333 | failedAfter: 0, | |
| 334 | repairs: 0, | |
| 335 | }); | |
| 336 | return { ok: true, summary: finalSummary }; | |
| 337 | } | |
| 338 | ||
| 339 | const authorId = await resolveFixAgentAuthorId(pr.ownerId); | |
| 340 | if (!authorId) { | |
| 341 | finalSummary = "no viable author_id for comment"; | |
| 342 | await ctx.appendLog(`[fix-agent] ${finalSummary}`); | |
| 343 | return { ok: false, summary: finalSummary }; | |
| 344 | } | |
| 345 | ||
| 346 | const body = renderFixAgentComment({ | |
| 347 | passed: result.passed, | |
| 348 | totalTests: result.totalTests, | |
| 349 | failedBefore: result.failedBefore, | |
| 350 | failedAfter: result.failedAfter, | |
| 351 | repairs: result.repairs, | |
| 352 | unfixable: result.unfixable, | |
| 353 | }); | |
| 354 | ||
| 355 | try { | |
| 356 | await db.insert(prComments).values({ | |
| 357 | pullRequestId: pr.prId, | |
| 358 | authorId, | |
| 359 | body, | |
| 360 | isAiReview: true, | |
| 361 | }); | |
| 362 | } catch (err) { | |
| 363 | finalSummary = `comment insert failed: ${(err as Error).message}`; | |
| 364 | await ctx.appendLog(`[fix-agent] ${finalSummary}`); | |
| 365 | return { ok: false, summary: finalSummary }; | |
| 366 | } | |
| 367 | ||
| 368 | finalSummary = buildFixAgentSummary({ | |
| 369 | offline: false, | |
| 370 | passed: result.passed, | |
| 371 | failedBefore: result.failedBefore, | |
| 372 | failedAfter: result.failedAfter, | |
| 373 | repairs: result.repairs.length, | |
| 374 | }); | |
| 375 | await ctx.appendLog(`[fix-agent] ${finalSummary}`); | |
| 376 | return { ok: true, summary: finalSummary }; | |
| 377 | }); | |
| 378 | ||
| 379 | return { ok: true, summary: finalSummary, runId: run.id }; | |
| 380 | } | |
| 381 | ||
| 382 | export const __internal = { | |
| 383 | FIX_AGENT_COST_CENTS, | |
| 384 | FIX_AGENT_MAX_REPAIRS_IN_COMMENT, | |
| 385 | resolvePrContext, | |
| 386 | resolveFixAgentAuthorId, | |
| 387 | }; |