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.
| 2b9055e | 1 | /** |
| 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 | ||
| 21 | import { and, eq, ilike, sql } from "drizzle-orm"; | |
| 22 | import { db } from "../db"; | |
| 23 | import { | |
| 24 | issueComments, | |
| 25 | issueLabels, | |
| 26 | issues, | |
| 27 | labels, | |
| 28 | pullRequests, | |
| 29 | repositories, | |
| 30 | users, | |
| 31 | } from "../db/schema"; | |
| 32 | import { extractClosingRefsMulti } from "./close-keywords"; | |
| 33 | import { buildSpecFromIssue } from "../routes/specs"; | |
| f5d020f | 34 | import { runAutonomousLoop } from "./ai-loop"; |
| 2b9055e | 35 | |
| 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 | */ | |
| 41 | export const AI_BUILD_MARKER = "<!-- gluecron:ai-build:v1 -->"; | |
| 42 | ||
| 43 | const DEFAULT_MAX_ISSUES_PER_TICK = 20; | |
| 44 | ||
| 45 | export 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 | ||
| 60 | export 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 | ||
| 73 | export 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 | ||
| 98 | export 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 | */ | |
| 110 | async 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 | */ | |
| 160 | async 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 | */ | |
| 185 | async 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 | */ | |
| 214 | async 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 | */ | |
| 246 | async 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 | */ | |
| 266 | export 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 | ); | |
| f5d020f | 337 | } 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 | }); | |
| 2b9055e | 370 | } |
| 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. */ | |
| 391 | export const __test = { | |
| 392 | defaultFindCandidates, | |
| 393 | defaultHasDispatchMarker, | |
| 394 | defaultHasOpenLinkedPr, | |
| 395 | defaultDispatcher, | |
| 396 | defaultPostMarkerComment, | |
| 397 | DEFAULT_MAX_ISSUES_PER_TICK, | |
| 398 | }; |