Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

ai-loop.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.

ai-loop.tsBlame535 lines · 1 contributor
f5d020fClaude1/**
2 * Autonomous Issue-to-Merged-PR Loop (ai-loop).
3 *
4 * After spec-to-pr creates a PR, this module drives a self-healing cycle:
5 * 1. Check if the latest gate run for the PR is green or red.
6 * 2. Green → call performMerge and mark the PR as merged.
7 * 3. Red and attempts < MAX_ATTEMPTS → call triggerCiAutofix, poll for a
8 * new gate run (up to 2 minutes), then loop.
9 * 4. Attempts exhausted → post a failure comment and give up.
10 *
11 * Idempotency:
12 * - Before starting, check for a <!-- gluecron:ai-loop:v1 --> marker in
13 * existing PR comments. If present: skip (already handled).
14 * - Each attempt is annotated with <!-- gluecron:ai-loop:attempt:N -->.
15 *
16 * Safe-default: the env var AI_LOOP_ENABLED must equal "1" for fire-and-
17 * forget callers that pass through ai-build-tasks.ts. The `runAutonomousLoop`
18 * export itself has no such guard so tests and targeted callers can invoke it
19 * unconditionally.
20 *
21 * Guard: isAiAvailable() is checked at the top of runAutonomousLoop; callers
22 * may also check it before invoking fire-and-forget. When the API key is
23 * absent the function returns immediately with {success:false}.
24 */
25
26import { and, desc, eq, sql } from "drizzle-orm";
27import { db } from "../db";
28import { gateRuns, prComments, pullRequests, repositories, users } from "../db/schema";
29import { isAiAvailable } from "./ai-client";
30import { performMerge } from "./pr-merge";
31import { triggerCiAutofix } from "./ci-autofix";
32import { getBotUserIdOrFallback } from "./bot-user";
a621550Claude33// Local copy to avoid import cycle with ai-build-tasks.ts
34const AI_BUILD_MARKER = "<!-- gluecron:ai-build:v1 -->";
f5d020fClaude35
36// ---------------------------------------------------------------------------
37// Public types
38// ---------------------------------------------------------------------------
39
40export interface LoopResult {
41 success: boolean;
42 attempts: number;
43 mergedAt?: Date;
44 failReason?: string;
45}
46
47// ---------------------------------------------------------------------------
48// Constants
49// ---------------------------------------------------------------------------
50
51/** Stable marker embedded in the initial "loop started" comment. */
52export const AI_LOOP_MARKER = "<!-- gluecron:ai-loop:v1 -->";
53/** Per-attempt progress marker prefix. */
54const AI_LOOP_ATTEMPT_PREFIX = "<!-- gluecron:ai-loop:attempt:";
55
56/** Maximum fix-and-retry cycles before giving up. */
a621550Claude57const MAX_ATTEMPTS: number = 3;
f5d020fClaude58
59/** How long to poll for a new gate run after triggering autofix (ms). */
60const POLL_TIMEOUT_MS = 2 * 60 * 1000;
61
62/** Interval between polls (ms). */
63const POLL_INTERVAL_MS = 10 * 1000;
64
65// ---------------------------------------------------------------------------
66// Internal helpers
67// ---------------------------------------------------------------------------
68
69/**
70 * Return true if any PR comment contains the ai-loop:v1 idempotency marker.
71 */
72async function hasLoopMarker(prId: string): Promise<boolean> {
73 try {
74 const rows = await db
75 .select({ id: prComments.id })
76 .from(prComments)
77 .where(
78 and(
79 eq(prComments.pullRequestId, prId),
80 sql`${prComments.body} LIKE ${"%" + AI_LOOP_MARKER + "%"}`
81 )
82 )
83 .limit(1);
84 return rows.length > 0;
85 } catch {
86 // Conservative: assume already handled on DB error.
87 return true;
88 }
89}
90
91/**
92 * Post a comment on the PR authored by the bot (or fallback to the PR author).
93 * Never throws.
94 */
95async function postComment(
96 prId: string,
97 fallbackAuthorId: string,
98 body: string
99): Promise<void> {
100 try {
101 const authorId = await getBotUserIdOrFallback(fallbackAuthorId);
102 await db.insert(prComments).values({
103 pullRequestId: prId,
104 authorId,
105 body,
106 isAiReview: true,
107 });
108 } catch (err) {
109 console.error("[ai-loop] postComment failed:", err);
110 }
111}
112
113/**
114 * Load the latest gate run for this PR. Returns null when none exists.
115 */
116async function loadLatestGateRun(prId: string): Promise<{
117 id: string;
118 status: string;
119 summary: string | null;
120 details: string | null;
121 createdAt: Date;
122} | null> {
123 try {
124 const rows = await db
125 .select({
126 id: gateRuns.id,
127 status: gateRuns.status,
128 summary: gateRuns.summary,
129 details: gateRuns.details,
130 createdAt: gateRuns.createdAt,
131 })
132 .from(gateRuns)
133 .where(eq(gateRuns.pullRequestId, prId))
134 .orderBy(desc(gateRuns.createdAt))
135 .limit(1);
136 return rows[0] ?? null;
137 } catch {
138 return null;
139 }
140}
141
142/**
143 * Update the ai_loop_attempts and ai_loop_status columns on the PR row.
144 * Best-effort — failures are logged but not rethrown.
145 */
146async function updatePrLoopState(
147 prId: string,
148 attempts: number,
149 status: "running" | "merged" | "failed"
150): Promise<void> {
151 try {
152 await db
153 .update(pullRequests)
154 .set({
155 aiLoopAttempts: attempts,
156 aiLoopStatus: status,
157 updatedAt: new Date(),
158 })
159 .where(eq(pullRequests.id, prId));
160 } catch (err) {
161 console.error("[ai-loop] updatePrLoopState failed:", err);
162 }
163}
164
165/**
166 * Poll until a gate run newer than `afterDate` appears for this PR (or times out).
167 * Returns the new gate run, or null on timeout.
168 */
169async function pollForNewGateRun(
170 prId: string,
171 afterDate: Date
172): Promise<{ id: string; status: string } | null> {
173 const deadline = Date.now() + POLL_TIMEOUT_MS;
174 while (Date.now() < deadline) {
175 await new Promise<void>((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
176 try {
177 const rows = await db
178 .select({ id: gateRuns.id, status: gateRuns.status })
179 .from(gateRuns)
180 .where(
181 and(
182 eq(gateRuns.pullRequestId, prId),
183 sql`${gateRuns.createdAt} > ${afterDate}`
184 )
185 )
186 .orderBy(desc(gateRuns.createdAt))
187 .limit(1);
188 if (rows.length > 0) return rows[0];
189 } catch {
190 // continue polling
191 }
192 }
193 return null;
194}
195
196// ---------------------------------------------------------------------------
197// Core export
198// ---------------------------------------------------------------------------
199
200/**
201 * Drive the autonomous fix-and-merge loop for a single PR.
202 *
203 * Flow:
204 * • Guard: isAiAvailable() → no-op return when ANTHROPIC_API_KEY missing.
205 * • Idempotency: check for AI_LOOP_MARKER in existing PR comments.
206 * • Load PR + repo metadata needed for performMerge.
207 * • Loop up to MAX_ATTEMPTS:
208 * - Fetch latest gate run.
209 * - No gate run yet → wait for one (poll up to 2 min).
210 * - Gate is green (passed/skipped) → performMerge → done.
211 * - Gate is red → post attempt comment, triggerCiAutofix, poll for
212 * new gate run, loop.
213 * - Gate is pending/running → wait for it to settle (poll 2 min).
214 * • Attempts exhausted → post failure comment, set aiLoopStatus='failed'.
215 */
216export async function runAutonomousLoop(
217 prId: string,
218 repoId: string
219): Promise<LoopResult> {
220 if (!isAiAvailable()) {
221 return { success: false, attempts: 0, failReason: "ANTHROPIC_API_KEY not set" };
222 }
223
224 // Load PR row.
225 let pr: {
226 id: string;
227 number: number;
228 title: string;
229 body: string | null;
230 baseBranch: string;
231 headBranch: string;
232 state: string;
233 isDraft: boolean;
234 authorId: string;
235 repositoryId: string;
236 aiLoopAttempts: number;
237 aiLoopStatus: string | null;
238 } | undefined;
239
240 try {
241 const rows = await db
242 .select({
243 id: pullRequests.id,
244 number: pullRequests.number,
245 title: pullRequests.title,
246 body: pullRequests.body,
247 baseBranch: pullRequests.baseBranch,
248 headBranch: pullRequests.headBranch,
249 state: pullRequests.state,
250 isDraft: pullRequests.isDraft,
251 authorId: pullRequests.authorId,
252 repositoryId: pullRequests.repositoryId,
253 aiLoopAttempts: pullRequests.aiLoopAttempts,
254 aiLoopStatus: pullRequests.aiLoopStatus,
255 })
256 .from(pullRequests)
257 .where(eq(pullRequests.id, prId))
258 .limit(1);
259 pr = rows[0];
260 } catch (err) {
261 return {
262 success: false,
263 attempts: 0,
264 failReason: `DB load failed: ${err instanceof Error ? err.message : String(err)}`,
265 };
266 }
267
268 if (!pr) {
269 return { success: false, attempts: 0, failReason: "PR not found" };
270 }
271 if (pr.state !== "open") {
272 return {
273 success: false,
274 attempts: 0,
275 failReason: `PR is not open (state=${pr.state})`,
276 };
277 }
278 // Already handled by another loop run.
279 if (pr.aiLoopStatus === "running" || pr.aiLoopStatus === "merged") {
280 return {
281 success: pr.aiLoopStatus === "merged",
282 attempts: pr.aiLoopAttempts,
283 failReason:
284 pr.aiLoopStatus === "running"
285 ? "Another loop run is already in progress"
286 : undefined,
287 };
288 }
289
290 // Idempotency: skip if the loop marker already exists.
291 if (await hasLoopMarker(prId)) {
292 return {
293 success: false,
294 attempts: 0,
295 failReason: "Loop already started (marker present)",
296 };
297 }
298
299 // Load repo + owner for performMerge.
300 let repoRow: { name: string; ownerUsername: string } | undefined;
301 try {
302 const rows = await db
303 .select({
304 name: repositories.name,
305 ownerUsername: users.username,
306 })
307 .from(repositories)
308 .innerJoin(users, eq(users.id, repositories.ownerId))
309 .where(eq(repositories.id, repoId))
310 .limit(1);
311 repoRow = rows[0];
312 } catch {
313 /* fall through — caught below */
314 }
315
316 if (!repoRow) {
317 return { success: false, attempts: 0, failReason: "Repo not found" };
318 }
319
320 // Mark loop as started: post the idempotency marker comment and update DB.
321 await postComment(
322 prId,
323 pr.authorId,
324 `${AI_LOOP_MARKER}\nThe autonomous AI loop has started for this PR. It will attempt to fix any CI failures and merge automatically (up to ${MAX_ATTEMPTS} attempts).`
325 );
326 await updatePrLoopState(prId, 0, "running");
327
328 let attempts = 0;
329
330 // ---------------------------------------------------------------------------
331 // Main retry loop
332 // ---------------------------------------------------------------------------
333 while (attempts < MAX_ATTEMPTS) {
334 // Re-fetch PR state in case it was closed/merged externally.
335 try {
336 const rows = await db
337 .select({ state: pullRequests.state, isDraft: pullRequests.isDraft })
338 .from(pullRequests)
339 .where(eq(pullRequests.id, prId))
340 .limit(1);
341 const current = rows[0];
342 if (!current || current.state !== "open") {
343 return {
344 success: current?.state === "merged",
345 attempts,
346 failReason:
347 current?.state !== "merged"
348 ? `PR no longer open (state=${current?.state ?? "unknown"})`
349 : undefined,
350 };
351 }
352 // Refresh isDraft flag in case someone changed it.
353 pr = { ...pr, isDraft: current.isDraft };
354 } catch {
355 // Continue with stale data — best effort.
356 }
357
358 // Fetch the latest gate run.
359 let gateRun = await loadLatestGateRun(prId);
360
361 // If no gate run exists yet, wait for one.
362 if (!gateRun) {
363 const found = await pollForNewGateRun(prId, new Date(0));
364 if (!found) {
365 // Still nothing — treat as a failure to progress.
366 break;
367 }
368 gateRun = { ...found, summary: null, details: null, createdAt: new Date() };
369 }
370
371 // If gate is still pending/running, wait for it to settle.
372 if (gateRun.status === "pending" || gateRun.status === "running") {
373 const settled = await pollForNewGateRun(prId, new Date(gateRun.createdAt.getTime() - 1));
374 if (settled) {
375 gateRun = { ...gateRun, ...settled };
376 }
377 // If still not settled, we'll try to evaluate with what we have.
378 }
379
380 const gateGreen =
381 gateRun.status === "passed" ||
382 gateRun.status === "skipped" ||
383 gateRun.status === "repaired";
384
385 if (gateGreen) {
386 // Gate is green — attempt to merge.
387 const mergeResult = await performMerge({
388 pr: {
389 id: pr.id,
390 number: pr.number,
391 title: pr.title,
392 body: pr.body,
393 baseBranch: pr.baseBranch,
394 headBranch: pr.headBranch,
395 repositoryId: pr.repositoryId,
396 authorId: pr.authorId,
397 state: "open",
398 isDraft: pr.isDraft,
399 },
400 ownerName: repoRow.ownerUsername,
401 repoName: repoRow.name,
402 actorUserId: pr.authorId,
403 hasConflicts: false,
404 });
405
406 if (mergeResult.ok) {
407 const mergedAt = new Date();
408 await updatePrLoopState(prId, attempts, "merged");
409 await postComment(
410 prId,
411 pr.authorId,
412 `${AI_LOOP_MARKER}\nThe autonomous AI loop successfully merged this PR after ${attempts === 0 ? "0 fix attempts (gate was already green)" : `${attempts} fix attempt${attempts === 1 ? "" : "s"}`}.`
413 );
414 return { success: true, attempts, mergedAt };
415 }
416
417 // Merge failed despite green gate — this is unexpected; give up.
418 const reason = mergeResult.error || "unknown merge error";
419 await updatePrLoopState(prId, attempts, "failed");
420 await postComment(
421 prId,
422 pr.authorId,
423 `${AI_LOOP_MARKER}\n**AI Loop: merge failed**\n\nThe gate was green but the merge failed: ${reason}\n\nManual intervention required.`
424 );
425 return { success: false, attempts, failReason: `Merge failed: ${reason}` };
426 }
427
428 // Gate is red — attempt a fix.
429 attempts += 1;
430 await updatePrLoopState(prId, attempts, "running");
431
432 const attemptMarker = `${AI_LOOP_ATTEMPT_PREFIX}${attempts} -->`;
433 await postComment(
434 prId,
435 pr.authorId,
436 `${attemptMarker}\n**AI Loop: fix attempt ${attempts}/${MAX_ATTEMPTS}**\n\nGate run \`${gateRun.id}\` reported status \`${gateRun.status}\`. Triggering CI autofix…`
437 );
438
439 // Trigger the autofix (fire-and-forget inside ci-autofix, but we await
440 // the wrapper because it does the Claude call synchronously).
441 await triggerCiAutofix(gateRun.id);
442
443 // Wait for a new gate run to appear (autofix triggers a new push → new run).
444 const newRun = await pollForNewGateRun(prId, gateRun.createdAt);
445 if (!newRun) {
446 // Autofix didn't produce a new gate run within the timeout.
447 if (attempts >= MAX_ATTEMPTS) break;
448 // Try the next attempt anyway — maybe the push just didn't start a new run.
449 }
450 }
451
452 // Exhausted all attempts.
453 await updatePrLoopState(prId, attempts, "failed");
454 await postComment(
455 prId,
456 pr.authorId,
457 `${AI_LOOP_MARKER}\n**AI Loop: exhausted ${MAX_ATTEMPTS} fix attempts**\n\nThe autonomous loop was unable to repair CI failures after ${MAX_ATTEMPTS} attempt${MAX_ATTEMPTS > 1 ? "s" : ""}. Manual intervention required.\n\nCC: @${repoRow.ownerUsername}`
458 );
459
460 return {
461 success: false,
462 attempts,
463 failReason: `Exhausted ${MAX_ATTEMPTS} fix attempts`,
464 };
465}
466
467// ---------------------------------------------------------------------------
468// Autopilot sweep helper
469// ---------------------------------------------------------------------------
470
471/**
472 * Scan for open PRs whose body contains the AI_BUILD_MARKER (created by the
473 * ai-build flow) and that have no AI_LOOP_MARKER comment yet. Runs up to
474 * `cap` per tick. Called by the autopilot `ai-loop-sweep` task.
475 *
476 * Never throws.
477 */
478export async function runAiLoopSweepOnce(cap = 5): Promise<{
479 considered: number;
480 started: number;
481 skipped: number;
482}> {
483 if (!isAiAvailable()) {
484 return { considered: 0, started: 0, skipped: 0 };
485 }
486
487 let candidates: { id: string; repositoryId: string }[] = [];
488 try {
489 candidates = await db
490 .select({ id: pullRequests.id, repositoryId: pullRequests.repositoryId })
491 .from(pullRequests)
492 .where(
493 and(
494 eq(pullRequests.state, "open"),
495 sql`${pullRequests.body} LIKE ${"%" + AI_BUILD_MARKER + "%"}`
496 )
497 )
498 .limit(cap * 3); // over-fetch so we can filter idempotent ones in JS
499 } catch (err) {
500 console.error("[ai-loop] sweep candidate query failed:", err);
501 return { considered: 0, started: 0, skipped: 0 };
502 }
503
504 let considered = 0;
505 let started = 0;
506 let skipped = 0;
507
508 for (const cand of candidates) {
509 if (started >= cap) break;
510 considered += 1;
511
512 // Skip if loop already has a marker comment.
513 const already = await hasLoopMarker(cand.id);
514 if (already) {
515 skipped += 1;
516 continue;
517 }
518
519 // Fire-and-forget — the loop is long-running (up to 6 minutes).
520 try {
521 void runAutonomousLoop(cand.id, cand.repositoryId).catch((err) => {
522 console.error(
523 `[ai-loop] runAutonomousLoop threw for pr=${cand.id}:`,
524 err
525 );
526 });
527 started += 1;
528 } catch (err) {
529 console.error(`[ai-loop] sweep: failed to start loop for pr=${cand.id}:`, err);
530 skipped += 1;
531 }
532 }
533
534 return { considered, started, skipped };
535}