CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
ai-ci-healer.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.
| 9336f45 | 1 | /** |
| 2 | * AI CI Healer — autonomous failure → root-cause → patch PR loop. | |
| 3 | * | |
| 4 | * When a `workflow_runs` row lands in status="failure", this module: | |
| 5 | * 1. Pulls the run + all its `workflow_jobs` (especially the failed ones' | |
| 6 | * `logs` column). | |
| 7 | * 2. Asks Claude to identify the root cause + whether it's fixable from | |
| 8 | * inside this repo. | |
| 9 | * 3. If patchable, hands the suggested fixes off to the existing | |
| 10 | * `generatePatchForGateTestFinding` (the finding-shape maps cleanly | |
| 11 | * onto its `GateTestFinding[]` API — both surfaces want | |
| 12 | * `{ path, description, severity }`). | |
| 13 | * 4. Records an `ai.ci.healed` or `ai.ci.gave_up` audit row keyed on the | |
| 14 | * run id so the autopilot poller doesn't re-process the same failure | |
| 15 | * every 5 minutes. | |
| 16 | * | |
| 17 | * Degrades silently when ANTHROPIC_API_KEY is unset (autopilot also gates | |
| 18 | * on the env var, but the lib double-checks so it's safe to call directly). | |
| 19 | * Everything is wrapped in try/catch — `analyzeFailedWorkflowRun` returns | |
| 20 | * `null` rather than throwing on any failure. | |
| 21 | * | |
| 22 | * Marker convention: | |
| 23 | * - Successful patch open → audit action `ai.ci.healed`, targetId = runId. | |
| 24 | * - Claude says unfixable → audit action `ai.ci.gave_up`, targetId = runId. | |
| 25 | * The autopilot poller skips any run that already has either marker, so we | |
| 26 | * never retry forever. | |
| 27 | */ | |
| 28 | ||
| 29 | import { and, desc, eq, gte, lt, sql } from "drizzle-orm"; | |
| 30 | import type Anthropic from "@anthropic-ai/sdk"; | |
| 31 | import { db } from "../db"; | |
| 32 | import { | |
| 33 | auditLog, | |
| 34 | repositories, | |
| 35 | workflowJobs, | |
| 36 | workflowRuns, | |
| 37 | workflows, | |
| 38 | } from "../db/schema"; | |
| 39 | import { | |
| 40 | MODEL_SONNET, | |
| 41 | extractText, | |
| 42 | getAnthropic, | |
| 43 | isAiAvailable, | |
| 44 | parseJsonResponse, | |
| 45 | } from "./ai-client"; | |
| 46 | import { audit } from "./notify"; | |
| 47 | import { | |
| 48 | generatePatchForGateTestFinding, | |
| 49 | type GateTestFinding, | |
| 50 | } from "./ai-patch-generator"; | |
| 0b49751 | 51 | import { assertAiQuota, AiQuotaExceededError } from "./billing"; |
| 9336f45 | 52 | |
| 53 | // --------------------------------------------------------------------------- | |
| 54 | // Tunables | |
| 55 | // --------------------------------------------------------------------------- | |
| 56 | ||
| 57 | /** Cap per-job log slice we ship to Claude — protect the prompt budget. */ | |
| 58 | const LOG_SNIPPET_BYTES = 8 * 1024; | |
| 59 | ||
| 60 | /** Hard cap on runs processed per autopilot tick — runaway protection. */ | |
| 61 | const HEAL_CAP_PER_TICK = 5; | |
| 62 | ||
| 63 | /** Only consider runs that finished at least this long ago. Gives the | |
| 64 | * workflow runner time to flush logs + final job rows before we sample. */ | |
| 65 | const HEAL_MIN_AGE_MS = 60 * 1_000; | |
| 66 | ||
| 67 | /** Don't bother healing ancient failures — older than this and we assume | |
| 68 | * the human has already triaged or the SHA has been rewritten. */ | |
| 69 | const HEAL_MAX_AGE_MS = 24 * 60 * 60 * 1_000; | |
| 70 | ||
| 71 | // --------------------------------------------------------------------------- | |
| 72 | // Public surface | |
| 73 | // --------------------------------------------------------------------------- | |
| 74 | ||
| 75 | export interface SuggestedFix { | |
| 76 | /** Relative path inside the repo that the AI thinks needs touching. */ | |
| 77 | path: string; | |
| 78 | /** What's wrong / what the fix should look like (one-liner). */ | |
| 79 | description: string; | |
| 80 | /** Severity inherited from the AI's confidence call. */ | |
| 81 | severity?: "low" | "medium" | "high" | "critical"; | |
| 82 | } | |
| 83 | ||
| 84 | export interface CiHealAnalysis { | |
| 85 | /** Plain-English root cause, 1-3 sentences. */ | |
| 86 | rootCause: string; | |
| 87 | /** Concrete fix proposals (may be empty if Claude can't pinpoint files). */ | |
| 88 | suggestedFixes: SuggestedFix[]; | |
| 89 | /** Convenience: the paths from `suggestedFixes` deduped. */ | |
| 90 | patchablePaths: string[]; | |
| 91 | } | |
| 92 | ||
| 93 | export interface CiHealerTickSummary { | |
| 94 | considered: number; | |
| 95 | healed: number; | |
| 96 | gaveUp: number; | |
| 97 | skipped: number; | |
| 98 | } | |
| 99 | ||
| 100 | /** | |
| 101 | * Claude's response shape. Kept loose so we tolerate minor schema drift. | |
| 102 | */ | |
| 103 | interface ClaudeCiResponse { | |
| 104 | rootCause?: string; | |
| 105 | fixable?: boolean; | |
| 106 | suggestedFixes?: Array<{ | |
| 107 | path?: string; | |
| 108 | description?: string; | |
| 109 | severity?: string; | |
| 110 | }>; | |
| 111 | /** Optional: Claude's reason it gave up. We just persist it for ops. */ | |
| 112 | unfixableReason?: string; | |
| 113 | } | |
| 114 | ||
| 115 | // --------------------------------------------------------------------------- | |
| 116 | // Helpers | |
| 117 | // --------------------------------------------------------------------------- | |
| 118 | ||
| 119 | function truncate(s: string | null | undefined, max: number): string { | |
| 120 | if (!s) return ""; | |
| 121 | if (s.length <= max) return s; | |
| 122 | return s.slice(0, max) + "\n…(truncated)"; | |
| 123 | } | |
| 124 | ||
| 125 | function normaliseSeverity(s: unknown): SuggestedFix["severity"] | undefined { | |
| 126 | if (typeof s !== "string") return undefined; | |
| 127 | const v = s.toLowerCase(); | |
| 128 | if (v === "low" || v === "medium" || v === "high" || v === "critical") { | |
| 129 | return v; | |
| 130 | } | |
| 131 | return undefined; | |
| 132 | } | |
| 133 | ||
| 134 | /** | |
| 135 | * Map our AI-derived `SuggestedFix` set onto the | |
| 136 | * `GateTestFinding[]` shape that `generatePatchForGateTestFinding` | |
| 137 | * already accepts. The patch generator only needs `{path, description, | |
| 138 | * severity}` — exactly what we already have. Each fix becomes one | |
| 139 | * candidate finding; the generator stops at the first one that produces a | |
| 140 | * non-empty patch set so a bad suggestion doesn't drown a good one. | |
| 141 | */ | |
| 142 | export function fixesToFindings( | |
| 143 | fixes: SuggestedFix[], | |
| 144 | runShortId: string | |
| 145 | ): GateTestFinding[] { | |
| 146 | return fixes | |
| 147 | .filter((f) => f.path && f.path.trim().length > 0) | |
| 148 | .map((f, i) => ({ | |
| 149 | id: `ci-heal-${runShortId}-${i}`, | |
| 150 | ruleId: "ci-failure", | |
| 151 | path: f.path.trim(), | |
| 152 | severity: f.severity || "high", | |
| 153 | title: "CI failure auto-heal", | |
| 154 | description: f.description || "CI failure", | |
| 155 | })); | |
| 156 | } | |
| 157 | ||
| 158 | /** | |
| 159 | * Build the Claude prompt. Pure function so tests can pin the shape. | |
| 160 | */ | |
| 161 | export function buildCiHealPrompt(args: { | |
| 162 | repoFullName: string; | |
| 163 | commitSha: string; | |
| 164 | workflowYaml: string; | |
| 165 | failedJobs: Array<{ name: string; conclusion: string | null; logs: string }>; | |
| 166 | }): string { | |
| 167 | const jobsBlock = args.failedJobs | |
| 168 | .map( | |
| 169 | (j) => | |
| 170 | `### Job: ${j.name} (${j.conclusion || "failure"})\n\`\`\`\n${truncate( | |
| 171 | j.logs, | |
| 172 | LOG_SNIPPET_BYTES | |
| 173 | )}\n\`\`\`` | |
| 174 | ) | |
| 175 | .join("\n\n"); | |
| 176 | return [ | |
| 177 | "You are GlueCron's CI healer. A workflow run just failed. Decide whether the failure is a code bug fixable in THIS repository, and if so propose concrete file edits.", | |
| 178 | "", | |
| 179 | `**Repository:** ${args.repoFullName}`, | |
| 180 | `**Commit:** ${args.commitSha.slice(0, 12)}`, | |
| 181 | "", | |
| 182 | "## Workflow YAML", | |
| 183 | "```yaml", | |
| 184 | truncate(args.workflowYaml, 4_000), | |
| 185 | "```", | |
| 186 | "", | |
| 187 | "## Failed job logs", | |
| 188 | jobsBlock || "(no failed-job logs available)", | |
| 189 | "", | |
| 190 | "Respond ONLY with JSON of this exact shape:", | |
| 191 | "{", | |
| 192 | ' "rootCause": "1-3 sentence diagnosis (what failed, why)",', | |
| 193 | ' "fixable": true | false,', | |
| 194 | ' "suggestedFixes": [', | |
| 195 | ' { "path": "src/foo.ts", "description": "what to change", "severity": "high" }', | |
| 196 | " ],", | |
| 197 | ' "unfixableReason": "(only if fixable=false) why the human must intervene"', | |
| 198 | "}", | |
| 199 | "", | |
| 200 | "Rules:", | |
| 201 | "- `fixable` MUST be false if the failure is an env/infra/external-service problem (missing secret, registry down, network, GitHub Actions runner image, etc.) — anything you can't fix by editing files in this repo.", | |
| 202 | "- `suggestedFixes` must be empty when fixable=false.", | |
| 203 | "- Only suggest paths you can identify with high confidence from the logs or YAML. Do not guess at random files.", | |
| 204 | "- Keep `description` short — the patch generator will be invoked with this finding to produce the actual diff.", | |
| 205 | ].join("\n"); | |
| 206 | } | |
| 207 | ||
| 208 | interface FailedJobRow { | |
| 209 | name: string; | |
| 210 | conclusion: string | null; | |
| 211 | logs: string; | |
| 212 | } | |
| 213 | ||
| 214 | async function loadFailedJobs(runId: string): Promise<FailedJobRow[]> { | |
| 215 | try { | |
| 216 | const rows = await db | |
| 217 | .select({ | |
| 218 | name: workflowJobs.name, | |
| 219 | conclusion: workflowJobs.conclusion, | |
| 220 | logs: workflowJobs.logs, | |
| 221 | status: workflowJobs.status, | |
| 222 | }) | |
| 223 | .from(workflowJobs) | |
| 224 | .where(eq(workflowJobs.runId, runId)); | |
| 225 | return rows | |
| 226 | .filter((r) => r.status === "failure" || r.conclusion === "failure") | |
| 227 | .map((r) => ({ | |
| 228 | name: r.name, | |
| 229 | conclusion: r.conclusion, | |
| 230 | logs: r.logs || "", | |
| 231 | })); | |
| 232 | } catch (err) { | |
| 233 | console.error("[ai-ci-healer] loadFailedJobs failed:", err); | |
| 234 | return []; | |
| 235 | } | |
| 236 | } | |
| 237 | ||
| 238 | /** | |
| 239 | * Has this run already been processed (success or give-up)? We use the | |
| 240 | * audit log as the marker store so we don't need a schema change. | |
| 241 | */ | |
| 242 | async function hasMarker(runId: string): Promise<boolean> { | |
| 243 | try { | |
| 244 | const [row] = await db | |
| 245 | .select({ id: auditLog.id }) | |
| 246 | .from(auditLog) | |
| 247 | .where( | |
| 248 | and( | |
| 249 | eq(auditLog.targetType, "workflow_run"), | |
| 250 | eq(auditLog.targetId, runId), | |
| 251 | sql`${auditLog.action} IN ('ai.ci.healed', 'ai.ci.gave_up')` | |
| 252 | ) | |
| 253 | ) | |
| 254 | .limit(1); | |
| 255 | return !!row; | |
| 256 | } catch (err) { | |
| 257 | console.warn("[ai-ci-healer] hasMarker query failed:", err); | |
| 258 | // Fail closed — if we can't check, skip to avoid duplicate work. | |
| 259 | return true; | |
| 260 | } | |
| 261 | } | |
| 262 | ||
| 263 | // --------------------------------------------------------------------------- | |
| 264 | // analyzeFailedWorkflowRun — public entry point #1 | |
| 265 | // --------------------------------------------------------------------------- | |
| 266 | ||
| 267 | export interface AnalyzeOptions { | |
| 268 | /** Test-only Anthropic client injection. */ | |
| 269 | client?: Pick<Anthropic, "messages">; | |
| 270 | } | |
| 271 | ||
| 272 | /** | |
| 273 | * Diagnose a failed run. Returns `null` when: | |
| 274 | * - The run doesn't exist or isn't actually a failure. | |
| 275 | * - ANTHROPIC_API_KEY is unset AND no client was injected. | |
| 276 | * - Claude says the failure isn't fixable from this repo (env/infra). | |
| 277 | * - Any DB / network step blows up (logged, swallowed). | |
| 278 | */ | |
| 279 | export async function analyzeFailedWorkflowRun( | |
| 280 | runId: string, | |
| 281 | opts: AnalyzeOptions = {} | |
| 282 | ): Promise<CiHealAnalysis | null> { | |
| 283 | // Resolve client lazily — tests inject, production reads env. | |
| 284 | let client: Pick<Anthropic, "messages">; | |
| 285 | if (opts.client) { | |
| 286 | client = opts.client; | |
| 287 | } else { | |
| 288 | if (!isAiAvailable()) return null; | |
| 289 | try { | |
| 290 | client = getAnthropic(); | |
| 291 | } catch { | |
| 292 | return null; | |
| 293 | } | |
| 294 | } | |
| 295 | ||
| 296 | // Load run row. | |
| 297 | let run: typeof workflowRuns.$inferSelect | null = null; | |
| 298 | try { | |
| 299 | const [row] = await db | |
| 300 | .select() | |
| 301 | .from(workflowRuns) | |
| 302 | .where(eq(workflowRuns.id, runId)) | |
| 303 | .limit(1); | |
| 304 | run = row || null; | |
| 305 | } catch (err) { | |
| 306 | console.error("[ai-ci-healer] loadRun failed:", err); | |
| 307 | return null; | |
| 308 | } | |
| 309 | if (!run || run.status !== "failure") return null; | |
| 310 | ||
| 311 | // Load workflow + repo (for YAML + naming context). | |
| 312 | let workflowYaml = ""; | |
| 313 | let repoFullName = "unknown/unknown"; | |
| 314 | try { | |
| 315 | const [w] = await db | |
| 316 | .select({ yaml: workflows.yaml }) | |
| 317 | .from(workflows) | |
| 318 | .where(eq(workflows.id, run.workflowId)) | |
| 319 | .limit(1); | |
| 320 | if (w?.yaml) workflowYaml = w.yaml; | |
| 321 | } catch (err) { | |
| 322 | console.warn("[ai-ci-healer] load workflow failed:", err); | |
| 323 | } | |
| 324 | try { | |
| 325 | const [r] = await db | |
| 326 | .select({ | |
| 327 | name: repositories.name, | |
| 328 | ownerId: repositories.ownerId, | |
| 329 | }) | |
| 330 | .from(repositories) | |
| 331 | .where(eq(repositories.id, run.repositoryId)) | |
| 332 | .limit(1); | |
| 333 | if (r) { | |
| 334 | repoFullName = `${r.ownerId.slice(0, 8)}/${r.name}`; | |
| 335 | } | |
| 336 | } catch (err) { | |
| 337 | console.warn("[ai-ci-healer] load repo failed:", err); | |
| 338 | } | |
| 339 | ||
| 340 | const failedJobs = await loadFailedJobs(runId); | |
| 341 | ||
| 342 | // Ask Claude. | |
| 343 | let parsed: ClaudeCiResponse | null = null; | |
| 344 | try { | |
| 345 | const message = await client.messages.create({ | |
| 346 | model: MODEL_SONNET, | |
| 347 | max_tokens: 2048, | |
| 348 | messages: [ | |
| 349 | { | |
| 350 | role: "user", | |
| 351 | content: buildCiHealPrompt({ | |
| 352 | repoFullName, | |
| 353 | commitSha: run.commitSha || "(unknown)", | |
| 354 | workflowYaml, | |
| 355 | failedJobs, | |
| 356 | }), | |
| 357 | }, | |
| 358 | ], | |
| 359 | }); | |
| 0c3eee5 | 360 | try { |
| 361 | const { recordAiCost, extractUsage } = await import("./ai-cost-tracker"); | |
| 362 | const usage = extractUsage(message); | |
| 363 | await recordAiCost({ | |
| 364 | repositoryId: run.repositoryId ?? null, | |
| 365 | model: MODEL_SONNET, | |
| 366 | inputTokens: usage.input, | |
| 367 | outputTokens: usage.output, | |
| 368 | category: "ci_healer", | |
| 369 | sourceId: runId, | |
| 370 | sourceKind: "workflow_run", | |
| 371 | }); | |
| 372 | } catch { | |
| 373 | /* swallow — best-effort */ | |
| 374 | } | |
| 9336f45 | 375 | parsed = parseJsonResponse<ClaudeCiResponse>(extractText(message)); |
| 376 | } catch (err) { | |
| 377 | console.warn( | |
| 378 | "[ai-ci-healer] Claude call failed:", | |
| 379 | err instanceof Error ? err.message : err | |
| 380 | ); | |
| 381 | return null; | |
| 382 | } | |
| 383 | if (!parsed) return null; | |
| 384 | ||
| 385 | const rootCause = | |
| 386 | typeof parsed.rootCause === "string" && parsed.rootCause.trim() | |
| 387 | ? parsed.rootCause.trim() | |
| 388 | : "(no root cause provided)"; | |
| 389 | ||
| 390 | // "Not fixable" branch — return null so the caller can mark `ai.ci.gave_up`. | |
| 391 | if (parsed.fixable === false) { | |
| 392 | return null; | |
| 393 | } | |
| 394 | ||
| 395 | const rawFixes = Array.isArray(parsed.suggestedFixes) | |
| 396 | ? parsed.suggestedFixes | |
| 397 | : []; | |
| 398 | const suggestedFixes: SuggestedFix[] = rawFixes | |
| 399 | .filter( | |
| 400 | (f): f is { path: string; description?: string; severity?: string } => | |
| 401 | !!f && typeof f.path === "string" && f.path.trim().length > 0 | |
| 402 | ) | |
| 403 | .map((f) => ({ | |
| 404 | path: f.path.trim(), | |
| 405 | description: | |
| 406 | typeof f.description === "string" && f.description.trim() | |
| 407 | ? f.description.trim() | |
| 408 | : rootCause, | |
| 409 | severity: normaliseSeverity(f.severity), | |
| 410 | })); | |
| 411 | ||
| 412 | // Claude said fixable but produced zero usable paths → treat as | |
| 413 | // unfixable so we don't loop. | |
| 414 | if (suggestedFixes.length === 0) return null; | |
| 415 | ||
| 416 | const patchablePaths = Array.from(new Set(suggestedFixes.map((f) => f.path))); | |
| 417 | ||
| 418 | return { rootCause, suggestedFixes, patchablePaths }; | |
| 419 | } | |
| 420 | ||
| 421 | // --------------------------------------------------------------------------- | |
| 422 | // healOneRun — drives one run end-to-end. Public so callers (autopilot, | |
| 423 | // tests, manual retrigger) share the same pipeline. | |
| 424 | // --------------------------------------------------------------------------- | |
| 425 | ||
| 426 | export interface HealOneOptions extends AnalyzeOptions { | |
| 427 | /** Test override — pin the patch generator output. */ | |
| 428 | generatePatch?: typeof generatePatchForGateTestFinding; | |
| 429 | } | |
| 430 | ||
| 431 | export interface HealOneResult { | |
| 432 | outcome: "healed" | "gave_up" | "skipped"; | |
| 433 | prNumber?: number; | |
| 434 | branch?: string; | |
| 435 | reason?: string; | |
| 436 | } | |
| 437 | ||
| 438 | export async function healOneRun( | |
| 439 | runId: string, | |
| 440 | opts: HealOneOptions = {} | |
| 441 | ): Promise<HealOneResult> { | |
| 442 | if (process.env.AUTOPILOT_DISABLED === "1") { | |
| 443 | return { outcome: "skipped", reason: "autopilot disabled" }; | |
| 444 | } | |
| 445 | if (!opts.client && !isAiAvailable()) { | |
| 446 | return { outcome: "skipped", reason: "ANTHROPIC_API_KEY missing" }; | |
| 447 | } | |
| 448 | ||
| 449 | // Skip if already processed (audit marker present). | |
| 450 | if (await hasMarker(runId)) { | |
| 451 | return { outcome: "skipped", reason: "already processed" }; | |
| 452 | } | |
| 453 | ||
| 0b49751 | 454 | // Look up the run for audit metadata (repo + sha) and owner for quota check. |
| 9336f45 | 455 | let repositoryId: string | null = null; |
| 456 | let commitSha: string | null = null; | |
| 0b49751 | 457 | let repoOwnerId: string | null = null; |
| 9336f45 | 458 | try { |
| 459 | const [row] = await db | |
| 460 | .select({ | |
| 461 | repositoryId: workflowRuns.repositoryId, | |
| 462 | commitSha: workflowRuns.commitSha, | |
| 463 | }) | |
| 464 | .from(workflowRuns) | |
| 465 | .where(eq(workflowRuns.id, runId)) | |
| 466 | .limit(1); | |
| 467 | if (row) { | |
| 468 | repositoryId = row.repositoryId; | |
| 469 | commitSha = row.commitSha; | |
| 470 | } | |
| 471 | } catch (err) { | |
| 472 | console.warn("[ai-ci-healer] post-analyze run lookup failed:", err); | |
| 473 | } | |
| 474 | ||
| 0b49751 | 475 | // Resolve repo owner for the quota check. |
| 476 | if (repositoryId) { | |
| 477 | try { | |
| 478 | const [repoRow] = await db | |
| 479 | .select({ ownerId: repositories.ownerId }) | |
| 480 | .from(repositories) | |
| 481 | .where(eq(repositories.id, repositoryId)) | |
| 482 | .limit(1); | |
| 483 | if (repoRow) repoOwnerId = repoRow.ownerId; | |
| 484 | } catch { | |
| 485 | /* tolerate */ | |
| 486 | } | |
| 487 | } | |
| 488 | ||
| 489 | // Hard quota gate — skip silently when the repo owner's AI budget is | |
| 490 | // exhausted. We don't fail the CI run; the autopilot marker is not written | |
| 491 | // so the healer will retry on the next tick once budget resets. | |
| 492 | if (repoOwnerId) { | |
| 493 | try { | |
| 494 | await assertAiQuota(repoOwnerId); | |
| 495 | } catch (err) { | |
| 496 | if (err instanceof AiQuotaExceededError) { | |
| 497 | console.log( | |
| 498 | `[ai-ci-healer] skipping run=${runId}: AI quota exceeded for owner=${repoOwnerId}` | |
| 499 | ); | |
| 500 | return { outcome: "skipped", reason: "AI quota exceeded" }; | |
| 501 | } | |
| 502 | // Unexpected error — log and proceed (fail open). | |
| 503 | console.warn("[ai-ci-healer] assertAiQuota failed unexpectedly:", err); | |
| 504 | } | |
| 505 | } | |
| 506 | ||
| 507 | const analysis = await analyzeFailedWorkflowRun(runId, { | |
| 508 | client: opts.client, | |
| 509 | }); | |
| 510 | ||
| 9336f45 | 511 | if (!analysis) { |
| 512 | await audit({ | |
| 513 | userId: null, | |
| 514 | repositoryId, | |
| 515 | action: "ai.ci.gave_up", | |
| 516 | targetType: "workflow_run", | |
| 517 | targetId: runId, | |
| 518 | metadata: { commitSha, reason: "unfixable or analysis returned null" }, | |
| 519 | }); | |
| 520 | return { outcome: "gave_up", reason: "unfixable" }; | |
| 521 | } | |
| 522 | ||
| 523 | if (!repositoryId || !commitSha) { | |
| 524 | // No base sha → can't seed a patch branch. Mark gave_up so we don't loop. | |
| 525 | await audit({ | |
| 526 | userId: null, | |
| 527 | repositoryId, | |
| 528 | action: "ai.ci.gave_up", | |
| 529 | targetType: "workflow_run", | |
| 530 | targetId: runId, | |
| 531 | metadata: { | |
| 532 | commitSha, | |
| 533 | reason: "missing repositoryId or commitSha for patch branch", | |
| 534 | }, | |
| 535 | }); | |
| 536 | return { outcome: "gave_up", reason: "missing base sha" }; | |
| 537 | } | |
| 538 | ||
| 539 | const generator = opts.generatePatch ?? generatePatchForGateTestFinding; | |
| 540 | const findings = fixesToFindings(analysis.suggestedFixes, runId.slice(0, 8)); | |
| 541 | ||
| 542 | const patch = await generator({ | |
| 543 | repositoryId, | |
| 544 | baseSha: commitSha, | |
| 545 | findings, | |
| 546 | client: opts.client, | |
| 547 | }); | |
| 548 | ||
| 549 | if (!patch) { | |
| 550 | await audit({ | |
| 551 | userId: null, | |
| 552 | repositoryId, | |
| 553 | action: "ai.ci.gave_up", | |
| 554 | targetType: "workflow_run", | |
| 555 | targetId: runId, | |
| 556 | metadata: { | |
| 557 | commitSha, | |
| 558 | reason: "patch generator returned null", | |
| 559 | rootCause: analysis.rootCause, | |
| 560 | patchablePaths: analysis.patchablePaths, | |
| 561 | }, | |
| 562 | }); | |
| 563 | return { outcome: "gave_up", reason: "patch generator returned null" }; | |
| 564 | } | |
| 565 | ||
| 566 | await audit({ | |
| 567 | userId: null, | |
| 568 | repositoryId, | |
| 569 | action: "ai.ci.healed", | |
| 570 | targetType: "workflow_run", | |
| 571 | targetId: runId, | |
| 572 | metadata: { | |
| 573 | commitSha, | |
| 574 | rootCause: analysis.rootCause, | |
| 575 | patchablePaths: analysis.patchablePaths, | |
| 576 | prNumber: patch.prNumber, | |
| 577 | branch: patch.branch, | |
| 578 | }, | |
| 579 | }); | |
| 580 | ||
| 581 | return { | |
| 582 | outcome: "healed", | |
| 583 | prNumber: patch.prNumber, | |
| 584 | branch: patch.branch, | |
| 585 | }; | |
| 586 | } | |
| 587 | ||
| 588 | // --------------------------------------------------------------------------- | |
| 589 | // runCiHealerTick — public autopilot entry point | |
| 590 | // --------------------------------------------------------------------------- | |
| 591 | ||
| 592 | export interface CiHealerTickDeps { | |
| 593 | /** Inject a candidate finder for tests. */ | |
| 594 | findCandidates?: (limit: number) => Promise<{ id: string }[]>; | |
| 595 | /** Inject the per-run handler for tests. */ | |
| 596 | healOne?: (runId: string) => Promise<HealOneResult>; | |
| 597 | /** Inject the per-tick cap. */ | |
| 598 | cap?: number; | |
| 599 | } | |
| 600 | ||
| 601 | /** | |
| 602 | * Find failed workflow runs that: | |
| 603 | * - finished at least HEAL_MIN_AGE_MS ago (let the runner flush logs) | |
| 604 | * - finished less than HEAL_MAX_AGE_MS ago (don't chase ancient failures) | |
| 605 | * - do NOT yet have an ai.ci.healed / ai.ci.gave_up audit marker | |
| 606 | * | |
| 607 | * The `hasMarker` filter is applied per-row in `healOneRun` rather than | |
| 608 | * the SQL select — keeps the query simple and the index on (status, | |
| 609 | * createdAt) doing most of the work. | |
| 610 | */ | |
| 611 | async function defaultFindCandidates( | |
| 612 | limit: number | |
| 613 | ): Promise<{ id: string }[]> { | |
| 614 | const now = Date.now(); | |
| 615 | const cutoffNew = new Date(now - HEAL_MIN_AGE_MS); | |
| 616 | const cutoffOld = new Date(now - HEAL_MAX_AGE_MS); | |
| 617 | try { | |
| 618 | const rows = await db | |
| 619 | .select({ id: workflowRuns.id }) | |
| 620 | .from(workflowRuns) | |
| 621 | .where( | |
| 622 | and( | |
| 623 | eq(workflowRuns.status, "failure"), | |
| 624 | lt(workflowRuns.createdAt, cutoffNew), | |
| 625 | gte(workflowRuns.createdAt, cutoffOld) | |
| 626 | ) | |
| 627 | ) | |
| 628 | .orderBy(desc(workflowRuns.createdAt)) | |
| 629 | .limit(limit); | |
| 630 | return rows; | |
| 631 | } catch (err) { | |
| 632 | console.error("[ai-ci-healer] candidate query failed:", err); | |
| 633 | return []; | |
| 634 | } | |
| 635 | } | |
| 636 | ||
| 637 | /** | |
| 638 | * One autopilot tick: scan recent failures, heal what we can. Returns a | |
| 639 | * counts summary. Never throws. | |
| 640 | * | |
| 641 | * No-op when: | |
| 642 | * - AUTOPILOT_DISABLED=1 | |
| 643 | * - ANTHROPIC_API_KEY is unset | |
| 644 | */ | |
| 645 | export async function runCiHealerTick( | |
| 646 | deps: CiHealerTickDeps = {} | |
| 647 | ): Promise<CiHealerTickSummary> { | |
| 648 | if (process.env.AUTOPILOT_DISABLED === "1") { | |
| 649 | return { considered: 0, healed: 0, gaveUp: 0, skipped: 0 }; | |
| 650 | } | |
| 651 | if (!isAiAvailable()) { | |
| 652 | return { considered: 0, healed: 0, gaveUp: 0, skipped: 0 }; | |
| 653 | } | |
| 654 | ||
| 655 | const findCandidates = deps.findCandidates ?? defaultFindCandidates; | |
| 656 | const healOne = deps.healOne ?? ((id: string) => healOneRun(id)); | |
| 657 | const cap = deps.cap ?? HEAL_CAP_PER_TICK; | |
| 658 | ||
| 659 | let candidates: { id: string }[] = []; | |
| 660 | try { | |
| 661 | candidates = await findCandidates(cap); | |
| 662 | } catch (err) { | |
| 663 | console.error("[ai-ci-healer] findCandidates threw:", err); | |
| 664 | return { considered: 0, healed: 0, gaveUp: 0, skipped: 0 }; | |
| 665 | } | |
| 666 | ||
| 667 | let healed = 0; | |
| 668 | let gaveUp = 0; | |
| 669 | let skipped = 0; | |
| 670 | for (const cand of candidates) { | |
| 671 | try { | |
| 672 | const res = await healOne(cand.id); | |
| 673 | if (res.outcome === "healed") healed += 1; | |
| 674 | else if (res.outcome === "gave_up") gaveUp += 1; | |
| 675 | else skipped += 1; | |
| 676 | } catch (err) { | |
| 677 | skipped += 1; | |
| 678 | console.error( | |
| 679 | `[ai-ci-healer] per-run failure for run=${cand.id}:`, | |
| 680 | err instanceof Error ? err.message : err | |
| 681 | ); | |
| 682 | } | |
| 683 | } | |
| 684 | ||
| 685 | return { considered: candidates.length, healed, gaveUp, skipped }; | |
| 686 | } | |
| 687 | ||
| 688 | // --------------------------------------------------------------------------- | |
| 689 | // Test-only re-exports | |
| 690 | // --------------------------------------------------------------------------- | |
| 691 | ||
| 692 | export const __test = { | |
| 693 | loadFailedJobs, | |
| 694 | hasMarker, | |
| 695 | defaultFindCandidates, | |
| 696 | normaliseSeverity, | |
| 697 | truncate, | |
| 698 | HEAL_MIN_AGE_MS, | |
| 699 | HEAL_MAX_AGE_MS, | |
| 700 | HEAL_CAP_PER_TICK, | |
| 701 | }; |