CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

feat(specs): failed spec-to-PR runs queue for the repair agent in one click #5444

Merged⚡ AI-generatedXSccantynz wants to mergefeat/spec-failure-queuemainopened 24d ago
3 changed files+152−38
Addedsrc/lib/repair-queue.ts+69−0View fileUnifiedSplit
1/**
2 * The internal repair queue — shared by every surface that can hand work to
3 * the owner's subscription-backed Claude Code agent instead of spending
4 * Anthropic API credits (owner decision 2026-08-08).
5 *
6 * A queued repair is an ordinary open issue labelled `ai:repair` whose body
7 * carries the full work specification. The drain protocol: an agent finds
8 * open ai:repair issues, implements each on a branch, opens a PR, and closes
9 * the issue from the PR.
10 */
11
12import { db } from "../db";
13import { issues, issueLabels } from "../db/schema";
14import { ensureLabel } from "./ensure-label";
15
16export const REPAIR_LABEL = "ai:repair";
17
18export interface QueueRepairArgs {
19 repositoryId: string;
20 authorId: string;
21 /** Issue title; "Health repair: …" / "Spec: …" prefixes read well in lists. */
22 title: string;
23 /** The full spec the agent needs to do the work without further context. */
24 specBody: string;
25}
26
27/**
28 * File the repair issue + label. Returns the issue number, or null when the
29 * insert fails — callers surface their own error message.
30 */
31export async function queueRepairIssue(
32 args: QueueRepairArgs
33): Promise<number | null> {
34 try {
35 const [created] = await db
36 .insert(issues)
37 .values({
38 repositoryId: args.repositoryId,
39 authorId: args.authorId,
40 title: args.title.slice(0, 255),
41 body: [
42 args.specBody,
43 ``,
44 `---`,
45 `_Queued for the internal repair agent (runs on the owner's Claude subscription)._`,
46 `_Agent: implement this on a branch and open a PR; close this issue from the PR._`,
47 ].join("\n"),
48 state: "open",
49 })
50 .returning({ id: issues.id, number: issues.number });
51 if (!created) return null;
52
53 const labelId = await ensureLabel({
54 repositoryId: args.repositoryId,
55 name: REPAIR_LABEL,
56 color: "#1f5f57",
57 description: "Queued for the internal repair agent",
58 });
59 if (labelId) {
60 await db
61 .insert(issueLabels)
62 .values({ issueId: created.id, labelId })
63 .onConflictDoNothing();
64 }
65 return created.number;
66 } catch {
67 return null;
68 }
69}
Modifiedsrc/routes/health.tsx+9−37View fileUnifiedSplit
2626import { resolveRepoAccess, satisfiesAccess } from "../middleware/repo-access";
2727import { createSpecPR } from "../lib/spec-to-pr";
2828import { internalAiMode } from "../lib/ai-client";
29import { db } from "../db";
30import { issues, issueLabels } from "../db/schema";
31import { ensureLabel } from "../lib/ensure-label";
29import { queueRepairIssue } from "../lib/repair-queue";
3230
3331const health = new Hono<AuthEnv>();
3432
524522 // Code agent drains — it implements the fix and opens the PR. "api" mode
525523 // (admin-switchable) calls the Anthropic API directly.
526524 if (internalAiMode() === "agent") {
527 try {
528 const [created] = await db
529 .insert(issues)
530 .values({
531 repositoryId: repoRow.id,
532 authorId: user.id,
533 title: `Health repair: ${action}`.slice(0, 255),
534 body: [
535 spec,
536 ``,
537 `---`,
538 `_Queued from the health page for the internal repair agent._`,
539 `_Agent: implement this on a branch and open a PR; close this issue from the PR._`,
540 ].join("\n"),
541 state: "open",
542 })
543 .returning({ id: issues.id, number: issues.number });
544 if (!created) return fail("Could not queue the repair.");
545 const labelId = await ensureLabel({
546 repositoryId: repoRow.id,
547 name: "ai:repair",
548 color: "#1f5f57",
549 description: "Queued for the internal repair agent",
550 });
551 if (labelId) {
552 await db
553 .insert(issueLabels)
554 .values({ issueId: created.id, labelId })
555 .onConflictDoNothing();
556 }
557 return c.redirect(`/${owner}/${repo}/issues/${created.number}`);
558 } catch {
559 return fail("Could not queue the repair.");
560 }
525 const issueNumber = await queueRepairIssue({
526 repositoryId: repoRow.id,
527 authorId: user.id,
528 title: `Health repair: ${action}`,
529 specBody: spec,
530 });
531 if (issueNumber === null) return fail("Could not queue the repair.");
532 return c.redirect(`/${owner}/${repo}/issues/${issueNumber}`);
561533 }
562534
563535 const result = await createSpecPR({
Modifiedsrc/routes/specs.tsx+74−1View fileUnifiedSplit
6464 error: string | null;
6565 startedAt: number; // Date.now()
6666 completedAt: number | null;
67 // Retained so a failed job can be re-routed to the internal repair queue
68 // ("Queue for the repair agent" on the error card) without re-typing.
69 spec: string;
70 repoId: string;
71 userId: string;
6772}
6873
6974const specJobs = new Map<string, SpecJob>();
959964 error: null,
960965 startedAt: Date.now(),
961966 completedAt: null,
967 spec,
968 repoId: resolved.repoId,
969 userId: user.id,
962970 };
963971 specJobs.set(jobId, job);
964972
11431151 color: var(--red);
11441152 text-decoration: underline;
11451153 }
1154 .sp-queue-btn {
1155 font-size: 13px;
1156 font-weight: 600;
1157 padding: 6px 14px;
1158 border-radius: 8px;
1159 border: 1px solid var(--accent);
1160 color: var(--accent);
1161 background: transparent;
1162 cursor: pointer;
1163 }
1164 .sp-queue-btn:hover { background: var(--accent); color: var(--bg-elevated); }
11461165`;
11471166
11481167/** Inline JS for the progress page — polls /status every 2s. ~18 lines. */
12721291 class={`sp-error-card${job.stage === "error" ? " is-visible" : ""}`}
12731292 >
12741293 <p id="sp-error-msg">{job.error || ""}</p>
1275 <a href={`/${owner}/${repo}/spec`}>Try again</a>
1294 <div style="display: flex; gap: 14px; align-items: center">
1295 <form
1296 method="post"
1297 action={`/${owner}/${repo}/spec/${job.id}/queue`}
1298 style="margin: 0"
1299 >
1300 <button type="submit" class="sp-queue-btn">
1301 Queue for the repair agent
1302 </button>
1303 </form>
1304 <a href={`/${owner}/${repo}/spec`}>Try again</a>
1305 </div>
1306 <p style="font-size: 12px; color: var(--text-muted); margin: 8px 0 0">
1307 Queuing files your spec as an <code>ai:repair</code> issue — the
1308 internal agent implements it and opens the PR, no AI balance needed.
1309 </p>
12761310 </div>
12771311
12781312 <script dangerouslySetInnerHTML={{ __html: PROGRESS_POLL_JS }} />
13091343 );
13101344});
13111345
1346// POST /:owner/:repo/spec/:jobId/queue — file a FAILED job's spec as an
1347// ai:repair issue for the internal repair agent (subscription-backed, no
1348// API balance needed). The spec is taken from the retained job, so nothing
1349// needs re-typing.
1350specs.post("/:owner/:repo/spec/:jobId/queue", softAuth, requireAuth, async (c) => {
1351 const { owner, repo, jobId } = c.req.param();
1352 const user = c.get("user")!;
1353
1354 const resolved = await resolveRepo(owner, repo);
1355 if (!resolved) return c.notFound();
1356 if (!hasWriteAccess(resolved, user.id)) return c.text("Forbidden", 403);
1357
1358 const job = specJobs.get(jobId);
1359 if (!job || !job.spec) {
1360 // Expired from the 10-minute sweep — back to the form to resubmit.
1361 return c.redirect(`/${owner}/${repo}/spec`);
1362 }
1363 if (job.stage !== "error") {
1364 return c.redirect(`/${owner}/${repo}/spec/${jobId}/progress`);
1365 }
1366
1367 const { queueRepairIssue } = await import("../lib/repair-queue");
1368 const firstLine =
1369 job.spec
1370 .split("\n")
1371 .map((l) => l.replace(/^#+\s*/, "").trim())
1372 .find(Boolean) || "spec";
1373 const issueNumber = await queueRepairIssue({
1374 repositoryId: job.repoId,
1375 authorId: user.id,
1376 title: `Spec repair: ${firstLine}`,
1377 specBody: job.spec,
1378 });
1379 if (issueNumber === null) {
1380 return c.redirect(`/${owner}/${repo}/spec/${jobId}/progress`);
1381 }
1382 return c.redirect(`/${owner}/${repo}/issues/${issueNumber}`);
1383});
1384
13121385// GET /:owner/:repo/spec/:jobId/progress/events — SSE stream
13131386specs.get("/:owner/:repo/spec/:jobId/progress/events", softAuth, requireAuth, async (c) => {
13141387 const { owner, repo, jobId } = c.req.param();
13151388
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts