CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

feat(ci): fix the free failures for free — deterministic tier before the model #5578

MergedXSccantynz wants to mergefeat/mechanical-tier-firstmainopened 2d ago
2 changed files+167−0
Modifiedsrc/__tests__/ai-ci-healer-diagnosis.test.ts+53−0View fileUnifiedSplit
202202 expect(inClause).not.toContain("ai.ci.deferred");
203203 });
204204});
205
206describe("tier 0 — repair without a model", () => {
207 /**
208 * The owner's requirement: fixing a CI failure must not depend on the
209 * Anthropic bill being paid.
210 *
211 * classifyFailure() already recognises lockfile drift, formatting and import
212 * order, and tryMechanicalRepair() already fixes all three by running the
213 * formatter — no model, no key, no balance. Until now nothing on the healer
214 * path called either: a lockfile mismatch went straight to Claude, and today
215 * straight to a 400. This wires the free tier in ahead of the paid one.
216 */
217 it("tries the deterministic tier before anything that costs money", async () => {
218 const src = await Bun.file(
219 require("path").resolve("src/lib/ai-ci-healer.ts")
220 ).text();
221 const mech = src.indexOf("tryMechanicalTier(");
222 const quota = src.indexOf("assertAiQuota(");
223 const analyze = src.indexOf("await analyzeFailedWorkflowRun(");
224 expect(mech).toBeGreaterThan(-1);
225 // Ahead of the AI call...
226 expect(mech).toBeLessThan(analyze);
227 // ...and ahead of the AI *budget* gate, which exists to protect spend this
228 // tier never makes. A free fix must not be refused for lack of AI budget.
229 expect(mech).toBeLessThan(quota);
230 });
231
232 it("refuses to bot-commit to the default branch", async () => {
233 // tryMechanicalRepair pushes straight to the branch. On a feature branch
234 // that is the point — the push starts a fresh CI run. On main it would be
235 // an unreviewed bot commit to the trunk, which is how auto-repair earned
236 // its reputation. Fixing a red trunk stays a human's call.
237 const src = await Bun.file(
238 require("path").resolve("src/lib/ai-ci-healer.ts")
239 ).text();
240 const fn = src.slice(src.indexOf("async function tryMechanicalTier"));
241 const body = fn.slice(0, fn.indexOf("\n}\n"));
242 expect(body).toContain("defaultBranch");
243 expect(body).toMatch(/return null; \/\/ never bot-commit to the trunk/);
244 });
245
246 it("falls through to AI when no mechanical pattern matches", async () => {
247 const src = await Bun.file(
248 require("path").resolve("src/lib/ai-ci-healer.ts")
249 ).text();
250 const fn = src.slice(src.indexOf("async function tryMechanicalTier"));
251 const body = fn.slice(0, fn.indexOf("\n}\n"));
252 // Returning null (not a HealOneResult) is what lets the caller continue.
253 expect(body).toMatch(/if \(!classifyFailure\(failureText\)\) return null;/);
254 // And a thrown mechanical tier must not cost us the AI tier either.
255 expect(body).toContain("catch");
256 });
257});
Modifiedsrc/lib/ai-ci-healer.ts+114−0View fileUnifiedSplit
3232import {
3333 auditLog,
3434 repositories,
35 users,
3536 workflowJobs,
3637 workflowRuns,
3738 workflows,
4950 type GateTestFinding,
5051} from "./ai-patch-generator";
5152import { assertAiQuota, AiQuotaExceededError } from "./billing";
53import {
54 classifyFailure,
55 tryMechanicalRepair,
56} from "./auto-repair-mechanical";
5257
5358// ---------------------------------------------------------------------------
5459// Tunables
520525 generatePatch?: typeof generatePatchForGateTestFinding;
521526}
522527
528/**
529 * Tier 0 — repair without a model.
530 *
531 * Returns a HealOneResult when it fixed the run (caller stops), or null to
532 * fall through to the AI tier. Never throws: a broken deterministic tier must
533 * not cost us the AI tier as well.
534 *
535 * REFUSES THE DEFAULT BRANCH. tryMechanicalRepair() pushes its commit straight
536 * to the branch, which is the right behaviour for a feature branch — the push
537 * starts a fresh CI run, which is the whole point — and the wrong behaviour
538 * for `main`, where it would be an unreviewed bot commit to the trunk. Fixing
539 * a red default branch stays a human's call.
540 */
541async function tryMechanicalTier(
542 runId: string,
543 repositoryId: string | null,
544 ref: string | null,
545 commitSha: string | null
546): Promise<HealOneResult | null> {
547 if (!repositoryId || !ref) return null;
548 const branch = ref.replace(/^refs\/heads\//, "");
549 if (!branch) return null;
550
551 try {
552 const [repoRow] = await db
553 .select({
554 name: repositories.name,
555 ownerId: repositories.ownerId,
556 defaultBranch: repositories.defaultBranch,
557 })
558 .from(repositories)
559 .where(eq(repositories.id, repositoryId))
560 .limit(1);
561 if (!repoRow) return null;
562
563 if (branch === (repoRow.defaultBranch || "main")) {
564 return null; // never bot-commit to the trunk
565 }
566
567 const [ownerRow] = await db
568 .select({ username: users.username })
569 .from(users)
570 .where(eq(users.id, repoRow.ownerId))
571 .limit(1);
572 if (!ownerRow?.username) return null;
573
574 const failedJobs = await loadFailedJobs(runId);
575 const failureText = failedJobs.map((j) => j.logs || "").join("\n");
576 if (!classifyFailure(failureText)) return null; // not mechanically fixable
577
578 const result = await tryMechanicalRepair(
579 ownerRow.username,
580 repoRow.name,
581 branch,
582 failureText
583 );
584 if (!result.success || !result.commitSha) {
585 console.log(
586 `[ai-ci-healer] mechanical tier declined run=${runId}: ${result.summary}`
587 );
588 return null; // fall through to AI
589 }
590
591 await audit({
592 userId: null,
593 repositoryId,
594 action: "ai.ci.healed",
595 targetType: "workflow_run",
596 targetId: runId,
597 metadata: {
598 commitSha,
599 tier: "mechanical",
600 repairCommit: result.commitSha,
601 filesChanged: result.filesChanged,
602 summary: result.summary,
603 },
604 });
605 console.log(
606 `[ai-ci-healer] mechanical tier fixed run=${runId} on ${branch}: ${result.summary}`
607 );
608 return { outcome: "healed", branch, reason: `mechanical: ${result.summary}` };
609 } catch (err) {
610 console.warn("[ai-ci-healer] mechanical tier failed:", err);
611 return null;
612 }
613}
614
523615/**
524616 * Record that a run was DEFERRED, not judged.
525617 *
589681 let repositoryId: string | null = null;
590682 let commitSha: string | null = null;
591683 let repoOwnerId: string | null = null;
684 let branchRef: string | null = null;
592685 try {
593686 const [row] = await db
594687 .select({
595688 repositoryId: workflowRuns.repositoryId,
596689 commitSha: workflowRuns.commitSha,
690 ref: workflowRuns.ref,
597691 })
598692 .from(workflowRuns)
599693 .where(eq(workflowRuns.id, runId))
601695 if (row) {
602696 repositoryId = row.repositoryId;
603697 commitSha = row.commitSha;
698 branchRef = row.ref;
604699 }
605700 } catch (err) {
606701 console.warn("[ai-ci-healer] post-analyze run lookup failed:", err);
620715 }
621716 }
622717
718 // ── Tier 0: deterministic repair. No model, no API key, no bill. ──────
719 //
720 // The platform must not need the Anthropic account to be in credit in order
721 // to fix a CI failure. Lockfile drift, formatting and import order are
722 // decided by running the formatter, not by asking a model what it thinks —
723 // classifyFailure() already recognises all three, and tryMechanicalRepair()
724 // already fixes them. Until now nothing on this path called either, so a
725 // lockfile mismatch went straight to Claude and, today, straight to a 400.
726 //
727 // Deliberately ahead of the quota gate below: that gate exists to protect an
728 // AI budget this tier never spends.
729 const mechanical = await tryMechanicalTier(
730 runId,
731 repositoryId,
732 branchRef,
733 commitSha
734 );
735 if (mechanical) return mechanical;
736
623737 // Hard quota gate — skip silently when the repo owner's AI budget is
624738 // exhausted. We don't fail the CI run; the autopilot marker is not written
625739 // so the healer will retry on the next tick once budget resets.
626740
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts