Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit01e93d2unknown_key

feat: add self-healing loop — continuous test, auto-fix, resubmit

feat: add self-healing loop — continuous test, auto-fix, resubmit

The heal loop (src/lib/heal-loop.ts) runs test suites in a worktree,
parses failures (TypeScript errors, test failures, lint issues, build
errors), sends failure context to Claude for repair, applies patches,
commits, and retries — up to 3 attempts until green.

SSE events broadcast at each step (heal:attempt, heal:repaired,
heal:green, heal:exhausted) for live UI progress. Results recorded
in gate_runs for the flywheel to learn from.

Trigger via POST /:owner/:repo/gates/heal (web) or
POST /api/repos/:owner/:repo/heal (API).

https://claude.ai/code/session_01M6EgoTaZ6HAZiVu77xp11g
Claude committed on April 16, 2026Parent: d0bc8c2
2 files changed+515001e93d2497a00167f6f67aae755e77cdaa744679
2 changed files+515−0
Addedsrc/lib/heal-loop.ts+419−0View fileUnifiedSplit
1/**
2 * Self-healing loop — GateTest integration.
3 *
4 * Continuously runs tests on a branch, interprets failures, auto-repairs,
5 * resubmits, and repeats until green or max attempts reached. This is the
6 * "nothing broken ships" guarantee taken to its logical conclusion.
7 *
8 * Flow:
9 * 1. Run GateTest scan (or local test suite)
10 * 2. If green → done
11 * 3. If red → parse failure output
12 * 4. Send failure context to Claude for repair
13 * 5. Apply patches, commit, update ref
14 * 6. Repeat from step 1 (max 3 attempts)
15 *
16 * The loop runs asynchronously and broadcasts SSE events at each step
17 * so the UI can show live progress.
18 */
19
20import { spawn } from "bun";
21import { readFile, writeFile, mkdir } from "fs/promises";
22import { join } from "path";
23import { getRepoPath } from "../git/repository";
24import { broadcast } from "./sse";
25import { config } from "./config";
26import { db } from "../db";
27import { gateRuns, repositories, users } from "../db/schema";
28import { eq, and } from "drizzle-orm";
29
30const MAX_HEAL_ATTEMPTS = 3;
31
32interface HealResult {
33 success: boolean;
34 attempts: number;
35 repairs: Array<{
36 attempt: number;
37 failureType: string;
38 filesChanged: string[];
39 commitSha?: string;
40 }>;
41 finalStatus: "green" | "red" | "max_attempts" | "error";
42 error?: string;
43}
44
45interface TestFailure {
46 type: "test" | "lint" | "typecheck" | "build" | "security" | "unknown";
47 message: string;
48 file?: string;
49 line?: number;
50 details: string;
51}
52
53async function exec(
54 cmd: string[],
55 opts?: { cwd?: string; env?: Record<string, string>; timeout?: number }
56): Promise<{ stdout: string; stderr: string; exitCode: number }> {
57 const proc = spawn(cmd, {
58 cwd: opts?.cwd,
59 env: { ...process.env, ...opts?.env },
60 stdout: "pipe",
61 stderr: "pipe",
62 });
63
64 let timeoutId: ReturnType<typeof setTimeout> | null = null;
65 if (opts?.timeout) {
66 timeoutId = setTimeout(() => proc.kill(), opts.timeout);
67 }
68
69 const [stdout, stderr] = await Promise.all([
70 new Response(proc.stdout).text(),
71 new Response(proc.stderr).text(),
72 ]);
73 const exitCode = await proc.exited;
74 if (timeoutId) clearTimeout(timeoutId);
75 return { stdout, stderr, exitCode };
76}
77
78const AUTHOR_ENV = {
79 GIT_AUTHOR_NAME: "GlueCron AI",
80 GIT_AUTHOR_EMAIL: "ai@gluecron.com",
81 GIT_COMMITTER_NAME: "GlueCron AI",
82 GIT_COMMITTER_EMAIL: "ai@gluecron.com",
83};
84
85/**
86 * Parse test/build output to identify specific failures.
87 */
88function parseFailures(stdout: string, stderr: string): TestFailure[] {
89 const combined = stdout + "\n" + stderr;
90 const failures: TestFailure[] = [];
91
92 // TypeScript errors: src/file.ts(10,5): error TS1234: message
93 const tsErrors = combined.matchAll(/^(.+?)\((\d+),\d+\):\s*error\s+TS\d+:\s*(.+)$/gm);
94 for (const m of tsErrors) {
95 failures.push({
96 type: "typecheck",
97 message: m[3],
98 file: m[1],
99 line: parseInt(m[2]),
100 details: m[0],
101 });
102 }
103
104 // Test failures: ✗ test name ... expected X received Y
105 const testFails = combined.matchAll(/(?:FAIL|✗|×)\s+(.+?)(?:\n[\s\S]*?(?:expected|Error|assert)[\s\S]*?)(?=\n(?:FAIL|✗|×|PASS|✓|\d+ pass)|\n\n)/gm);
106 for (const m of testFails) {
107 failures.push({
108 type: "test",
109 message: m[1].trim(),
110 details: m[0].slice(0, 500),
111 });
112 }
113
114 // ESLint errors
115 const lintErrors = combined.matchAll(/^(.+?):(\d+):\d+\s+error\s+(.+?)\s+/gm);
116 for (const m of lintErrors) {
117 failures.push({
118 type: "lint",
119 message: m[3],
120 file: m[1],
121 line: parseInt(m[2]),
122 details: m[0],
123 });
124 }
125
126 // Build errors
127 if (combined.includes("Build failed") || combined.includes("Cannot find module")) {
128 const buildMatch = combined.match(/(?:Build failed|Cannot find module[^\n]+)/);
129 failures.push({
130 type: "build",
131 message: buildMatch?.[0] || "Build error",
132 details: combined.slice(0, 500),
133 });
134 }
135
136 if (failures.length === 0 && (stdout.includes("fail") || stderr.includes("error"))) {
137 failures.push({
138 type: "unknown",
139 message: "Unrecognized failure pattern",
140 details: combined.slice(0, 500),
141 });
142 }
143
144 return failures;
145}
146
147/**
148 * Ask Claude to generate repair patches for identified failures.
149 */
150async function generateRepairPatches(
151 worktreePath: string,
152 failures: TestFailure[]
153): Promise<Array<{ path: string; content: string; reason: string }>> {
154 if (!config.anthropicApiKey) return [];
155
156 const { getAnthropic, MODEL_SONNET, extractText } = await import("./ai-client");
157 const client = getAnthropic();
158
159 // Group failures by file
160 const byFile = new Map<string, TestFailure[]>();
161 for (const f of failures) {
162 const file = f.file || "unknown";
163 if (!byFile.has(file)) byFile.set(file, []);
164 byFile.get(file)!.push(f);
165 }
166
167 const patches: Array<{ path: string; content: string; reason: string }> = [];
168
169 for (const [file, fileFailures] of byFile) {
170 if (file === "unknown") continue;
171
172 let original: string;
173 try {
174 original = await readFile(join(worktreePath, file), "utf8");
175 } catch {
176 continue;
177 }
178
179 const failureText = fileFailures
180 .map((f, i) => `${i + 1}. [${f.type}] ${f.message}${f.line ? ` (line ${f.line})` : ""}\n ${f.details}`)
181 .join("\n\n");
182
183 try {
184 const message = await client.messages.create({
185 model: MODEL_SONNET,
186 max_tokens: 8192,
187 messages: [
188 {
189 role: "user",
190 content: `Fix the following failures in "${file}". Output ONLY the corrected file content — no prose, no code fences, no explanation.
191
192Failures:
193${failureText}
194
195Current file content:
196${original.slice(0, 50000)}`,
197 },
198 ],
199 });
200
201 const fixed = extractText(message).replace(/^```[\w]*\n?/, "").replace(/\n?```$/, "");
202 if (fixed && fixed !== original && fixed.length > 10) {
203 patches.push({
204 path: file,
205 content: fixed,
206 reason: `Fix ${fileFailures.length} ${fileFailures[0].type} failure${fileFailures.length > 1 ? "s" : ""}`,
207 });
208 }
209 } catch (err) {
210 console.error(`[heal-loop] Claude repair failed for ${file}:`, err);
211 }
212 }
213
214 return patches;
215}
216
217/**
218 * Run the test suite in a worktree.
219 */
220async function runTests(
221 worktreePath: string
222): Promise<{ passed: boolean; stdout: string; stderr: string }> {
223 // Check if there's a package.json with test script
224 try {
225 const pkg = JSON.parse(await readFile(join(worktreePath, "package.json"), "utf8"));
226 const testCmd = pkg.scripts?.test;
227 if (testCmd) {
228 const result = await exec(["bun", "test"], { cwd: worktreePath, timeout: 120_000 });
229 return { passed: result.exitCode === 0, stdout: result.stdout, stderr: result.stderr };
230 }
231 } catch {}
232
233 // Fallback: try bun test directly
234 const result = await exec(["bun", "test"], { cwd: worktreePath, timeout: 120_000 });
235 return { passed: result.exitCode === 0, stdout: result.stdout, stderr: result.stderr };
236}
237
238/**
239 * Run the self-healing loop on a branch.
240 *
241 * Called after a push or PR merge attempt that failed gate checks.
242 * The loop attempts to fix failures and push repairs back to the branch.
243 */
244export async function runHealLoop(
245 owner: string,
246 repo: string,
247 branch: string,
248 opts: {
249 repositoryId?: string;
250 pullRequestId?: string;
251 triggerSource?: string;
252 } = {}
253): Promise<HealResult> {
254 const repoDir = getRepoPath(owner, repo);
255 const sseChannel = opts.repositoryId ? `gate:${opts.repositoryId}` : null;
256
257 const result: HealResult = {
258 success: false,
259 attempts: 0,
260 repairs: [],
261 finalStatus: "error",
262 };
263
264 for (let attempt = 1; attempt <= MAX_HEAL_ATTEMPTS; attempt++) {
265 result.attempts = attempt;
266
267 // Broadcast SSE: healing attempt starting
268 if (sseChannel) {
269 broadcast(sseChannel, "heal:attempt", {
270 attempt,
271 maxAttempts: MAX_HEAL_ATTEMPTS,
272 branch,
273 status: "running",
274 });
275 }
276
277 // Create worktree
278 const wtPath = join(repoDir, `_heal_${Date.now()}_${attempt}`);
279 const wt = await exec(["git", "worktree", "add", wtPath, branch], { cwd: repoDir });
280 if (wt.exitCode !== 0) {
281 result.error = `Worktree creation failed: ${wt.stderr}`;
282 result.finalStatus = "error";
283 break;
284 }
285
286 try {
287 // Install deps if needed
288 const pkgExists = await readFile(join(wtPath, "package.json"), "utf8").catch(() => null);
289 if (pkgExists) {
290 await exec(["bun", "install", "--frozen-lockfile"], { cwd: wtPath, timeout: 60_000 });
291 }
292
293 // Run tests
294 const testResult = await runTests(wtPath);
295
296 if (testResult.passed) {
297 result.success = true;
298 result.finalStatus = "green";
299
300 if (sseChannel) {
301 broadcast(sseChannel, "heal:green", {
302 attempt,
303 branch,
304 totalRepairs: result.repairs.length,
305 });
306 }
307 break;
308 }
309
310 // Tests failed — parse failures
311 const failures = parseFailures(testResult.stdout, testResult.stderr);
312 console.log(`[heal-loop] Attempt ${attempt}: ${failures.length} failures detected`);
313
314 if (failures.length === 0) {
315 result.finalStatus = "red";
316 result.error = "Tests failed but no parseable failures found";
317
318 if (sseChannel) {
319 broadcast(sseChannel, "heal:unparseable", { attempt, branch });
320 }
321 break;
322 }
323
324 // Generate repair patches
325 const patches = await generateRepairPatches(wtPath, failures);
326 if (patches.length === 0) {
327 result.finalStatus = "red";
328 result.error = "Could not generate repair patches";
329
330 if (sseChannel) {
331 broadcast(sseChannel, "heal:no_patches", { attempt, branch, failures: failures.length });
332 }
333 break;
334 }
335
336 // Apply patches
337 for (const patch of patches) {
338 const fullPath = join(wtPath, patch.path);
339 await mkdir(join(fullPath, ".."), { recursive: true }).catch(() => {});
340 await writeFile(fullPath, patch.content, "utf8");
341 }
342
343 // Commit repair
344 await exec(["git", "add", "-A"], { cwd: wtPath });
345 const commitMsg = `fix(heal): auto-repair attempt ${attempt}/${MAX_HEAL_ATTEMPTS}\n\n${patches.map((p) => `- ${p.path}: ${p.reason}`).join("\n")}\n\n[GlueCron self-healing loop]`;
346 const commit = await exec(["git", "commit", "-m", commitMsg], { cwd: wtPath, env: AUTHOR_ENV });
347
348 if (commit.exitCode !== 0) {
349 result.error = `Commit failed: ${commit.stderr}`;
350 continue;
351 }
352
353 const { stdout: sha } = await exec(["git", "rev-parse", "HEAD"], { cwd: wtPath });
354 await exec(["git", "update-ref", `refs/heads/${branch}`, sha.trim()], { cwd: repoDir });
355
356 result.repairs.push({
357 attempt,
358 failureType: failures[0].type,
359 filesChanged: patches.map((p) => p.path),
360 commitSha: sha.trim(),
361 });
362
363 if (sseChannel) {
364 broadcast(sseChannel, "heal:repaired", {
365 attempt,
366 branch,
367 sha: sha.trim(),
368 filesChanged: patches.map((p) => p.path),
369 });
370 }
371 } finally {
372 // Cleanup worktree
373 await exec(["git", "worktree", "remove", "--force", wtPath], { cwd: repoDir }).catch(() => {});
374 }
375 }
376
377 if (!result.success && result.attempts >= MAX_HEAL_ATTEMPTS) {
378 result.finalStatus = "max_attempts";
379 if (sseChannel) {
380 broadcast(sseChannel, "heal:exhausted", {
381 attempts: result.attempts,
382 branch,
383 repairs: result.repairs.length,
384 });
385 }
386 }
387
388 // Record the heal loop result in gate_runs
389 if (opts.repositoryId) {
390 try {
391 await db.insert(gateRuns).values({
392 repositoryId: opts.repositoryId,
393 pullRequestId: opts.pullRequestId,
394 commitSha: "heal-loop",
395 ref: `refs/heads/${branch}`,
396 gateName: "Self-heal loop",
397 status: result.success ? "passed" : "failed",
398 summary: `${result.attempts} attempt${result.attempts === 1 ? "" : "s"}, ${result.repairs.length} repair${result.repairs.length === 1 ? "" : "s"}${result.finalStatus}`,
399 details: JSON.stringify(result),
400 repairAttempted: result.repairs.length > 0,
401 repairSucceeded: result.success,
402 repairCommitSha: result.repairs[result.repairs.length - 1]?.commitSha,
403 completedAt: new Date(),
404 });
405 } catch (err) {
406 console.error("[heal-loop] Failed to record gate run:", err);
407 }
408 }
409
410 console.log(`[heal-loop] Complete: ${result.finalStatus} after ${result.attempts} attempts, ${result.repairs.length} repairs`);
411 return result;
412}
413
414/**
415 * Quick check: is the heal loop available? (needs AI for repair generation)
416 */
417export function isHealLoopEnabled(): boolean {
418 return !!config.anthropicApiKey;
419}
Modifiedsrc/routes/gates.tsx+96−0View fileUnifiedSplit
503503 }
504504);
505505
506// ---------- Self-healing loop trigger ----------
507
508gates.post(
509 "/:owner/:repo/gates/heal",
510 requireAuth,
511 async (c) => {
512 const user = c.get("user")!;
513 const { owner, repo } = c.req.param();
514 const branch = (await c.req.parseBody())["branch"] as string || "main";
515
516 const [ownerRow] = await db
517 .select()
518 .from(users)
519 .where(eq(users.username, owner))
520 .limit(1);
521 if (!ownerRow) return c.notFound();
522 const [repoRow] = await db
523 .select()
524 .from(repositories)
525 .where(
526 and(
527 eq(repositories.ownerId, ownerRow.id),
528 eq(repositories.name, repo)
529 )
530 )
531 .limit(1);
532 if (!repoRow) return c.notFound();
533 if (user.id !== repoRow.ownerId) {
534 return c.text("Only the repository owner can trigger the heal loop", 403);
535 }
536
537 const { runHealLoop, isHealLoopEnabled } = await import("../lib/heal-loop");
538 if (!isHealLoopEnabled()) {
539 return c.redirect(
540 `/${owner}/${repo}/gates?error=${encodeURIComponent("Self-healing requires ANTHROPIC_API_KEY")}`
541 );
542 }
543
544 // Fire async — don't block the response
545 runHealLoop(owner, repo, branch, {
546 repositoryId: repoRow.id,
547 triggerSource: "manual",
548 }).catch((err) => console.error("[heal-loop] Error:", err));
549
550 return c.redirect(
551 `/${owner}/${repo}/gates?success=${encodeURIComponent(`Self-healing loop started on ${branch}`)}`
552 );
553 }
554);
555
556// ---------- Heal loop API ----------
557
558gates.post(
559 "/api/repos/:owner/:repo/heal",
560 requireAuth,
561 async (c) => {
562 const user = c.get("user")!;
563 const { owner, repo } = c.req.param();
564 const body = await c.req.json().catch(() => ({}));
565 const branch = (body as any).branch || "main";
566
567 const [ownerRow] = await db
568 .select()
569 .from(users)
570 .where(eq(users.username, owner))
571 .limit(1);
572 if (!ownerRow) return c.json({ error: "Not found" }, 404);
573 const [repoRow] = await db
574 .select()
575 .from(repositories)
576 .where(
577 and(
578 eq(repositories.ownerId, ownerRow.id),
579 eq(repositories.name, repo)
580 )
581 )
582 .limit(1);
583 if (!repoRow) return c.json({ error: "Not found" }, 404);
584 if (user.id !== repoRow.ownerId) {
585 return c.json({ error: "Forbidden" }, 403);
586 }
587
588 const { runHealLoop, isHealLoopEnabled } = await import("../lib/heal-loop");
589 if (!isHealLoopEnabled()) {
590 return c.json({ error: "ANTHROPIC_API_KEY not configured" }, 503);
591 }
592
593 const result = await runHealLoop(owner, repo, branch, {
594 repositoryId: repoRow.id,
595 triggerSource: "api",
596 });
597
598 return c.json(result);
599 }
600);
601
506602export default gates;
507603