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-build-tasks.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-build-tasks.tsBlame398 lines · 1 contributor
2b9055eClaude1/**
2 * Block K3 — AI-build dispatcher (issues tagged `ai:build`).
3 *
4 * Walks open issues whose label set includes a (case-insensitive) `ai:build`
5 * label and, for each one, dispatches a Spec-to-PR build off the existing
6 * `src/lib/spec-to-pr.ts` pipeline. Cheap idempotency: every dispatched
7 * issue gets a marker comment containing `AI_BUILD_MARKER`; on subsequent
8 * ticks that marker tells us "already handled, skip".
9 *
10 * Every dispatch is fire-and-forget — failures are logged and swallowed so
11 * one bad issue can't wedge the autopilot tick.
12 *
13 * Inputs are dependency-injected so the autopilot test suite can exercise
14 * the loop without touching the DB or the AI client.
15 *
16 * NOT in this module:
17 * - The Spec-to-PR pipeline itself (lives in `src/lib/spec-to-pr.ts`).
18 * - The autopilot wrapper / timing concerns (lives in `src/lib/autopilot.ts`).
19 */
20
21import { and, eq, ilike, sql } from "drizzle-orm";
22import { db } from "../db";
23import {
24 issueComments,
25 issueLabels,
26 issues,
27 labels,
28 pullRequests,
29 repositories,
30 users,
31} from "../db/schema";
32import { extractClosingRefsMulti } from "./close-keywords";
33import { buildSpecFromIssue } from "../routes/specs";
f5d020fClaude34import { runAutonomousLoop } from "./ai-loop";
2b9055eClaude35
36/**
37 * Stable marker baked into the issue comment so subsequent ticks can detect
38 * "already dispatched" without race conditions. Versioned so we can bump
39 * the contract later without re-dispatching every old issue.
40 */
41export const AI_BUILD_MARKER = "<!-- gluecron:ai-build:v1 -->";
42
43const DEFAULT_MAX_ISSUES_PER_TICK = 20;
44
45export interface AiBuildCandidate {
46 issueId: string;
47 issueNumber: number;
48 issueTitle: string;
49 issueBody: string | null;
50 repositoryId: string;
51 authorUserId: string;
52 /** Resolved repo owner username; null if the row is somehow orphaned. */
53 ownerUsername: string | null;
54 /** Repo name (for logging/branch naming downstream). */
55 repoName: string;
56 /** Default branch — passed to spec-to-PR as `baseRef`. */
57 defaultBranch: string;
58}
59
60export interface SpecDispatcher {
61 /**
62 * Same signature as `createSpecPR` in `src/lib/spec-to-pr.ts`.
63 * Returns `ok:false` when ANTHROPIC_API_KEY is unset (gracefully).
64 */
65 (args: {
66 repoId: string;
67 spec: string;
68 baseRef: string;
69 userId: string;
70 }): Promise<{ ok: true; prNumber: number } | { ok: false; error: string }>;
71}
72
73export interface AiBuildTaskDeps {
74 /** Override how candidates are sourced (DI for tests). */
75 findCandidates?: (limit: number) => Promise<AiBuildCandidate[]>;
76 /** Override whether a candidate is already marker-tagged (DI for tests). */
77 hasDispatchMarker?: (issueId: string) => Promise<boolean>;
78 /**
79 * Override whether an open PR already closes this issue via close-keywords.
80 * Returns true if some open PR's title/body references `closes #N`.
81 */
82 hasOpenLinkedPr?: (
83 repositoryId: string,
84 issueNumber: number
85 ) => Promise<boolean>;
86 /** Inject the dispatcher (real one lives in spec-to-pr.ts). */
87 dispatcher?: SpecDispatcher;
88 /** Inject the marker-comment writer (DI for tests). */
89 postMarkerComment?: (
90 issueId: string,
91 authorUserId: string,
92 body: string
93 ) => Promise<void>;
94 /** Override the per-tick cap. */
95 maxIssuesPerTick?: number;
96}
97
98export interface AiBuildTaskSummary {
99 queued: number;
100 skipped: number;
101}
102
103/**
104 * Default implementation of `findCandidates`. Joins open issues to their
105 * label set, filters on a case-insensitive `ai:build` label name, and
106 * resolves repo metadata + owner so the dispatcher has everything it needs.
107 *
108 * Skips archived repos. Cap is applied at the SQL layer.
109 */
110async function defaultFindCandidates(
111 limit: number
112): Promise<AiBuildCandidate[]> {
113 try {
114 const rows = await db
115 .select({
116 issueId: issues.id,
117 issueNumber: issues.number,
118 issueTitle: issues.title,
119 issueBody: issues.body,
120 repositoryId: issues.repositoryId,
121 authorUserId: issues.authorId,
122 ownerUsername: users.username,
123 repoName: repositories.name,
124 defaultBranch: repositories.defaultBranch,
125 })
126 .from(issues)
127 .innerJoin(repositories, eq(repositories.id, issues.repositoryId))
128 .leftJoin(users, eq(users.id, repositories.ownerId))
129 .innerJoin(issueLabels, eq(issueLabels.issueId, issues.id))
130 .innerJoin(labels, eq(labels.id, issueLabels.labelId))
131 .where(
132 and(
133 eq(issues.state, "open"),
134 eq(repositories.isArchived, false),
135 ilike(labels.name, "ai:build")
136 )
137 )
138 .limit(limit);
139 return rows.map((r) => ({
140 issueId: r.issueId,
141 issueNumber: r.issueNumber,
142 issueTitle: r.issueTitle,
143 issueBody: r.issueBody,
144 repositoryId: r.repositoryId,
145 authorUserId: r.authorUserId,
146 ownerUsername: r.ownerUsername ?? null,
147 repoName: r.repoName,
148 defaultBranch: r.defaultBranch || "main",
149 }));
150 } catch (err) {
151 console.error("[autopilot] ai-build: candidate query failed:", err);
152 return [];
153 }
154}
155
156/**
157 * Default implementation of `hasDispatchMarker`. Looks for ANY comment on
158 * the issue whose body contains `AI_BUILD_MARKER`.
159 */
160async function defaultHasDispatchMarker(issueId: string): Promise<boolean> {
161 try {
162 const rows = await db
163 .select({ id: issueComments.id })
164 .from(issueComments)
165 .where(
166 and(
167 eq(issueComments.issueId, issueId),
168 sql`${issueComments.body} LIKE ${"%" + AI_BUILD_MARKER + "%"}`
169 )
170 )
171 .limit(1);
172 return rows.length > 0;
173 } catch {
174 // Be conservative: on DB error, treat as already-dispatched so we don't
175 // spam the issue on a transient outage.
176 return true;
177 }
178}
179
180/**
181 * Default implementation of `hasOpenLinkedPr`. Scans open PRs in the same
182 * repo and uses `extractClosingRefsMulti` against title + body to see if
183 * any of them references the issue with a closing keyword.
184 */
185async function defaultHasOpenLinkedPr(
186 repositoryId: string,
187 issueNumber: number
188): Promise<boolean> {
189 try {
190 const rows = await db
191 .select({ title: pullRequests.title, body: pullRequests.body })
192 .from(pullRequests)
193 .where(
194 and(
195 eq(pullRequests.repositoryId, repositoryId),
196 eq(pullRequests.state, "open")
197 )
198 );
199 for (const r of rows) {
200 const refs = extractClosingRefsMulti([r.title, r.body]);
201 if (refs.includes(issueNumber)) return true;
202 }
203 return false;
204 } catch {
205 return false;
206 }
207}
208
209/**
210 * Default dispatcher. Dynamic-imports `createSpecPR` from `spec-to-pr.ts`
211 * the same way `src/routes/specs.tsx` does, so we tolerate optional builds
212 * where the module might be absent or missing the export.
213 */
214async function defaultDispatcher(args: {
215 repoId: string;
216 spec: string;
217 baseRef: string;
218 userId: string;
219}): Promise<{ ok: true; prNumber: number } | { ok: false; error: string }> {
220 try {
221 const mod: any = await import("./spec-to-pr");
222 const fn = mod && (mod.createSpecPR || mod.default?.createSpecPR);
223 if (typeof fn !== "function") {
224 return { ok: false, error: "createSpecPR not exported by spec-to-pr.ts" };
225 }
226 const res = await fn(args);
227 // Normalise — createSpecPR may return additional fields; we only need ok/prNumber.
228 if (res && res.ok) return { ok: true, prNumber: res.prNumber };
229 return {
230 ok: false,
231 error: (res && "error" in res && res.error) || "unknown error",
232 };
233 } catch (err) {
234 return {
235 ok: false,
236 error: err instanceof Error ? err.message : String(err),
237 };
238 }
239}
240
241/**
242 * Default marker-comment writer. Posts a single issue comment authored by
243 * the issue's own author so we don't need a separate "autopilot" user
244 * (avoids dependency on a system user existing).
245 */
246async function defaultPostMarkerComment(
247 issueId: string,
248 authorUserId: string,
249 body: string
250): Promise<void> {
251 try {
252 await db.insert(issueComments).values({
253 issueId,
254 authorId: authorUserId,
255 body,
256 });
257 } catch (err) {
258 console.error("[autopilot] ai-build: marker insert failed:", err);
259 }
260}
261
262/**
263 * One iteration of the ai-build dispatcher. Returns a summary suitable
264 * for the autopilot tick log. Never throws.
265 */
266export async function runAiBuildTaskOnce(
267 deps: AiBuildTaskDeps = {}
268): Promise<AiBuildTaskSummary> {
269 const limit = deps.maxIssuesPerTick ?? DEFAULT_MAX_ISSUES_PER_TICK;
270 const findCandidates = deps.findCandidates ?? defaultFindCandidates;
271 const hasDispatchMarker = deps.hasDispatchMarker ?? defaultHasDispatchMarker;
272 const hasOpenLinkedPr = deps.hasOpenLinkedPr ?? defaultHasOpenLinkedPr;
273 const dispatcher = deps.dispatcher ?? defaultDispatcher;
274 const postMarkerComment = deps.postMarkerComment ?? defaultPostMarkerComment;
275
276 let candidates: AiBuildCandidate[] = [];
277 try {
278 candidates = await findCandidates(limit);
279 } catch (err) {
280 console.error("[autopilot] ai-build: findCandidates threw:", err);
281 return { queued: 0, skipped: 0 };
282 }
283
284 let queued = 0;
285 let skipped = 0;
286
287 for (const cand of candidates) {
288 try {
289 // Sanity: we need an owner to build the on-disk path further down.
290 if (!cand.ownerUsername) {
291 skipped += 1;
292 continue;
293 }
294
295 // Skip if there's already an open PR that closes this issue via
296 // close-keyword convention.
297 if (await hasOpenLinkedPr(cand.repositoryId, cand.issueNumber)) {
298 skipped += 1;
299 continue;
300 }
301
302 // Skip if we've already dispatched (marker comment present).
303 if (await hasDispatchMarker(cand.issueId)) {
304 skipped += 1;
305 continue;
306 }
307
308 const spec = buildSpecFromIssue({
309 number: cand.issueNumber,
310 title: cand.issueTitle,
311 body: cand.issueBody,
312 });
313
314 // Post the marker BEFORE dispatching. This way, if the dispatcher is
315 // slow or transiently fails, a subsequent tick won't double-fire.
316 // The marker is the source of truth for idempotency.
317 await postMarkerComment(
318 cand.issueId,
319 cand.authorUserId,
320 `${AI_BUILD_MARKER}\nQueued an AI-build off this issue's spec. The PR (if any) will reference this issue via "Closes #${cand.issueNumber}".`
321 );
322
323 // Fire the dispatcher. Errors are swallowed — the marker has already
324 // been posted so we won't retry. (Operators can delete the marker
325 // comment to force a re-run.)
326 try {
327 const res = await dispatcher({
328 repoId: cand.repositoryId,
329 spec,
330 baseRef: cand.defaultBranch,
331 userId: cand.authorUserId,
332 });
333 if (!res.ok) {
334 console.error(
335 `[autopilot] ai-build: dispatcher failed for issue=${cand.issueId}: ${res.error}`
336 );
f5d020fClaude337 } else if (process.env.AI_LOOP_ENABLED === "1") {
338 // Fire-and-forget: resolve the PR UUID from prNumber + repoId, then
339 // start the autonomous loop. Errors are swallowed.
340 const prNumber = res.prNumber;
341 const repoId = cand.repositoryId;
342 Promise.resolve().then(async () => {
343 try {
344 const rows = await db
345 .select({ id: pullRequests.id })
346 .from(pullRequests)
347 .where(
348 and(
349 eq(pullRequests.repositoryId, repoId),
350 eq(pullRequests.number, prNumber)
351 )
352 )
353 .limit(1);
354 const prId = rows[0]?.id;
355 if (prId) {
356 await runAutonomousLoop(prId, repoId);
357 }
358 } catch (loopErr) {
359 console.error(
360 `[autopilot] ai-build: ai-loop fire-and-forget failed for issue=${cand.issueId}:`,
361 loopErr
362 );
363 }
364 }).catch((err) => {
365 console.error(
366 `[autopilot] ai-build: ai-loop promise rejected for issue=${cand.issueId}:`,
367 err
368 );
369 });
2b9055eClaude370 }
371 } catch (err) {
372 console.error(
373 `[autopilot] ai-build: dispatcher threw for issue=${cand.issueId}:`,
374 err
375 );
376 }
377 queued += 1;
378 } catch (err) {
379 console.error(
380 `[autopilot] ai-build: per-issue failure for issue=${cand.issueId}:`,
381 err
382 );
383 skipped += 1;
384 }
385 }
386
387 return { queued, skipped };
388}
389
390/** Test-only surface. */
391export const __test = {
392 defaultFindCandidates,
393 defaultHasDispatchMarker,
394 defaultHasOpenLinkedPr,
395 defaultDispatcher,
396 defaultPostMarkerComment,
397 DEFAULT_MAX_ISSUES_PER_TICK,
398};