Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

autopilot.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.

autopilot.tsBlame1636 lines · 3 contributors
2b821b7Claude1/**
2 * Autopilot — self-sufficiency loop.
3 *
4 * Runs existing platform-maintenance tasks (mirror sync, merge queue progress,
5 * weekly digests, advisory rescans) on an interval so the host runs itself
6 * without an external cron. All sub-tasks are injected so tests can stub them
7 * without touching the DB; the default task set wires real helpers from the
8 * locked libs. Nothing here throws — every sub-task and the outer tick are
9 * try/caught so a single failure never blocks the others.
10 */
11
2b9055eClaude12import { and, eq, gte, sql } from "drizzle-orm";
2b821b7Claude13import { db } from "../db";
2b9055eClaude14import {
15 mergeQueueEntries,
16 prComments,
17 pullRequests,
18 repoDependencies,
19 repositories,
20 users,
21} from "../db/schema";
2b821b7Claude22import { syncAllDue } from "./mirrors";
23import { peekHead } from "./merge-queue";
24import { sendDigestsToAll } from "./email-digest";
25import { scanRepositoryForAlerts } from "./advisories";
a76d984Claude26import { releaseExpiredWaitTimers } from "./environments";
665c8bfClaude27import { runScheduledWorkflowsTick } from "./scheduled-workflows";
2b9055eClaude28import {
29 evaluateAutoMerge,
30 recordAutoMergeAttempt,
31 type AutoMergeContext,
32 type AutoMergeDecision,
33} from "./auto-merge";
34import { matchProtection } from "./branch-protection";
35import { performMerge, type PerformMergeResult } from "./pr-merge";
36import { audit } from "./notify";
37import { runAiBuildTaskOnce } from "./ai-build-tasks";
46d6165Claude38import {
39 sendSleepModeDigestForUser,
40 SLEEP_MODE_USER_CAP_PER_TICK,
41 SLEEP_MODE_COOLDOWN_HOURS,
42} from "./sleep-mode";
534f04aClaude43import {
44 runStalePrSweepOnce,
45 runStaleIssueSweepOnce,
46} from "./stale-sweep";
47import { computePrRiskForPullRequest } from "./pr-risk";
48import { prRiskScores } from "../db/schema";
c63b860Claude49import { purgeScheduledAccounts } from "./account-deletion";
cd4f63bTest User50import { purgeExpiredPlaygroundAccounts } from "./playground";
b1be050CC LABS App51import {
52 runSyntheticChecks,
53 persistChecks,
54 latestStatusByCheck,
55 type SyntheticCheckResult,
56} from "./synthetic-monitor";
e9aa4d8Claude57import { aiProactiveMonitorTick } from "./ai-proactive-monitor";
9336f45Claude58import { runCiHealerTick } from "./ai-ci-healer";
a686079Claude59import {
60 runDailyStandupTaskOnce,
61 runWeeklyStandupTaskOnce,
62} from "./ai-standup";
950ef90Claude63import { runSpecToPrTaskOnce } from "./autopilot-spec-to-pr";
45f3b73Claude64import { runMigrationWatcherTaskOnce } from "./migration-assistant";
3c03977Claude65import { sweepStale as sweepStalePrLive } from "./pr-live";
424eb72Claude66import { runAutoReleaseNotesTaskOnce } from "./ai-release-notes";
1d4ff60Claude67import { runPrTestGeneratorTaskOnce } from "./autopilot-pr-test-generator";
79ed944Claude68import { runAdvancementScan } from "./advancement-scanner";
69import { expireOldSandboxes } from "./pr-sandbox";
9b3a183Claude70import { expireIdleEnvs } from "./dev-env";
44ed968Claude71import { expireOldPreviews } from "./branch-previews";
a7460bfClaude72import { getBotUserIdOrFallback } from "./bot-user";
f65f600Claude73import { runOnboardingDripTaskOnce } from "./onboarding-drip";
cc34156Claude74import { sendSmartDigestsToAll } from "./smart-digest";
2b821b7Claude75
76export interface AutopilotTaskResult {
77 name: string;
78 ok: boolean;
79 durationMs: number;
80 error?: string;
81}
82
83export interface AutopilotTickResult {
84 startedAt: string;
85 finishedAt: string;
86 tasks: AutopilotTaskResult[];
87}
88
89export interface AutopilotTask {
90 name: string;
91 run: () => Promise<void>;
92}
93
94export interface StartAutopilotOpts {
95 intervalMs?: number;
96 now?: () => number;
97 tasks?: AutopilotTask[];
98}
99
100export interface RunTickOpts {
101 tasks?: AutopilotTask[];
102 now?: () => number;
103}
104
105const DEFAULT_INTERVAL_MS = 5 * 60 * 1000;
106const ADVISORY_RESCAN_BATCH = 5;
2b9055eClaude107/** K3 — recency window for auto-merge candidate selection. */
108const AUTO_MERGE_LOOKBACK_HOURS = 24;
109/** K3 — hard cap on PRs evaluated per tick (runaway protection). */
110const AUTO_MERGE_MAX_PER_TICK = 50;
111/** K3 — stable marker for the auto-merge audit comment. */
112const AUTO_MERGE_COMMENT_MARKER = "<!-- gluecron:auto-merge:v1 -->";
534f04aClaude113/** M3 — hard cap on PRs scored per tick (runaway protection). */
114const PR_RISK_RESCORE_MAX_PER_TICK = 20;
115/** M3 — recency window for the pr-risk-rescore sweep. */
116const PR_RISK_RESCORE_LOOKBACK_HOURS = 1;
e9aa4d8Claude117/** Proactive monitor cadence — Claude scans platform telemetry hourly. */
118const PROACTIVE_MONITOR_INTERVAL_MS = 60 * 60 * 1000;
119let _lastProactiveMonitorAt = 0;
950ef90Claude120/** Spec-to-PR cadence — autopilot scans `.gluecron/specs/*.md` every 2 minutes. */
121const SPEC_TO_PR_INTERVAL_MS = 2 * 60 * 1000;
122let _lastSpecToPrAt = 0;
45f3b73Claude123/**
124 * Migration watcher cadence. The lookup is cheap (registry calls per
125 * declared dep) but we still throttle to every 6 hours so we don't hammer
126 * npm and don't propose more than ~one PR per repo per day.
127 */
128const MIGRATION_WATCHER_INTERVAL_MS = 6 * 60 * 60 * 1000;
129let _lastMigrationWatcherAt = 0;
424eb72Claude130/**
131 * Auto-release-notes cadence. Cheap once tags are rare; we still throttle
132 * to every 10 minutes so freshly-pushed tags (whose release row was just
133 * created by `POST /:owner/:repo/releases`) get notes within ~one tick
134 * without us scanning the table every 5 minutes.
135 */
136const AUTO_RELEASE_NOTES_INTERVAL_MS = 10 * 60 * 1000;
137let _lastAutoReleaseNotesAt = 0;
1d4ff60Claude138/**
139 * PR test generator cadence. Cheap when opted-in repos are quiet; the
140 * task itself short-circuits via the per-PR `ai:added-tests` marker so
141 * we never re-process the same PR. 5-minute cadence aligns with the
142 * "freshly opened PR" window the task uses for candidate selection.
143 */
144const PR_TEST_GENERATOR_INTERVAL_MS = 5 * 60 * 1000;
145let _lastPrTestGeneratorAt = 0;
79ed944Claude146/**
147 * PR sandbox cleanup cadence (migration 0067). Runs every 30 minutes —
148 * sandboxes default to a 4h TTL so finer-grained cleanup isn't needed,
149 * and skipping most of the 5-min outer ticks keeps the loop cheap.
150 */
151const PR_SANDBOX_CLEANUP_INTERVAL_MS = 30 * 60 * 1000;
152let _lastPrSandboxCleanupAt = 0;
9b3a183Claude153/**
154 * Dev-env idle sweep cadence (migration 0072). Runs on every autopilot
155 * tick (5 minutes) because per-env idle timeouts can be as short as a
156 * few minutes — we don't want a 30-min cleanup window stranding a user
157 * who set idle_minutes=5. The lib itself is a single SQL UPDATE so this
158 * is cheap regardless of repo count.
159 */
160const DEV_ENV_IDLE_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
161let _lastDevEnvIdleSweepAt = 0;
f5ad215Claude162/**
163 * AI dependency auto-updater cadence (migration 0077). Once per day is
164 * plenty — the task itself caps at 10 repos and 2 candidates per repo,
165 * keeping npm registry traffic low. Skips when DEP_UPDATER_ENABLED env
166 * flag is not set to "1".
167 */
168const DEP_UPDATE_SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000;
169let _lastDepUpdateSweepAt = 0;
79ed944Claude170/**
171 * Advancement scanner cadence. Designed to run weekly on Mondays at
172 * 08:00 UTC. The task itself is the cheap gate (checks both day-of-week
173 * and minimum interval since last run) so we don't bake any cron-style
174 * triggers into the autopilot loop. Each tick (5min) probes the gate;
175 * the gate is satisfied at most once per 6 days, keeping the cadence
176 * effectively weekly even if Monday-08:00 happens to be missed.
177 */
178const ADVANCEMENT_SCAN_MIN_INTERVAL_MS = 6 * 24 * 60 * 60 * 1000;
179let _lastAdvancementScanAt = 0;
180/** Hour of day (UTC) the advancement scan prefers. Configurable via env. */
181function advancementScanHourUtc(): number {
182 const raw = process.env.ADVANCEMENT_SCAN_HOUR_UTC;
183 if (!raw) return 8;
184 const n = Number(raw);
185 if (!Number.isFinite(n) || n < 0 || n > 23) return 8;
186 return Math.floor(n);
187}
188/** Day of week (0=Sun..6=Sat, UTC) the advancement scan prefers. */
189function advancementScanDayOfWeek(): number {
190 const raw = process.env.ADVANCEMENT_SCAN_DOW_UTC;
191 if (!raw) return 1; // Monday
192 const n = Number(raw);
193 if (!Number.isFinite(n) || n < 0 || n > 6) return 1;
194 return Math.floor(n);
195}
2b821b7Claude196
cc34156Claude197/**
198 * Smart-digest cadence. Designed to run once per day at 07:00 UTC.
199 * The task itself checks `lastSmartDigestSentAt` per user (20h cooldown),
200 * so even if the outer loop fires multiple times near 07:00, only one
201 * digest is ever sent per day per user.
202 */
203const SMART_DIGEST_INTERVAL_MS = 22 * 60 * 60 * 1000; // 22h between outer checks
204let _lastSmartDigestAt = 0;
205
2b821b7Claude206/**
207 * Default task set. Each task is a thin wrapper around an existing locked
208 * helper — no gate/merge logic is duplicated here.
209 */
210export function defaultTasks(): AutopilotTask[] {
211 return [
212 {
213 name: "mirror-sync",
214 run: async () => {
215 await syncAllDue();
216 },
217 },
218 {
219 name: "merge-queue",
220 run: async () => {
221 await processMergeQueues();
222 },
223 },
224 {
225 name: "weekly-digest",
226 run: async () => {
227 await sendDigestsToAll();
228 },
229 },
230 {
231 name: "advisory-rescan",
232 run: async () => {
233 await rescanAdvisoriesBatch(ADVISORY_RESCAN_BATCH);
234 },
235 },
a76d984Claude236 {
237 name: "wait-timer-release",
238 run: async () => {
239 await releaseExpiredWaitTimers();
240 },
241 },
665c8bfClaude242 {
243 name: "scheduled-workflows",
244 run: async () => {
245 await runScheduledWorkflowsTick();
246 },
247 },
2b9055eClaude248 {
249 name: "auto-merge-sweep",
250 run: async () => {
251 await runAutoMergeSweep();
252 },
253 },
254 {
255 name: "ai-build-from-issues",
256 run: async () => {
257 const summary = await runAiBuildTaskOnce();
258 console.log(
259 `[autopilot] ai-build: queued=${summary.queued} skipped=${summary.skipped}`
260 );
261 },
262 },
46d6165Claude263 {
264 name: "sleep-mode-digest",
265 run: async () => {
266 const summary = await runSleepModeDigestTaskOnce();
267 console.log(
268 `[autopilot] sleep-mode-digest: sent=${summary.sent} skipped=${summary.skipped}`
269 );
270 },
271 },
534f04aClaude272 {
273 name: "stale-pr-sweep",
274 run: async () => {
275 // Two-stage gate: poke at 7d stale, close at 14d after poke
276 // (when the repo opts in via `auto_close_stale_prs`).
277 // Wrapped in try/catch so a finder crash never wedges the tick.
278 try {
279 const summary = await runStalePrSweepOnce();
280 console.log(
281 `[autopilot] stale-pr-sweep: poked=${summary.poked} closed=${summary.closed}`
282 );
283 } catch (err) {
284 console.error("[autopilot] stale-pr-sweep: threw:", err);
285 }
286 },
287 },
288 {
289 name: "stale-issue-sweep",
290 run: async () => {
291 // Mirror of stale-pr-sweep with the issue thresholds (30d/60d).
292 try {
293 const summary = await runStaleIssueSweepOnce();
294 console.log(
295 `[autopilot] stale-issue-sweep: poked=${summary.poked} closed=${summary.closed}`
296 );
297 } catch (err) {
298 console.error("[autopilot] stale-issue-sweep: threw:", err);
299 }
300 },
301 },
302 {
303 name: "pr-risk-rescore",
304 run: async () => {
305 const summary = await runPrRiskRescoreTaskOnce();
306 console.log(
307 `[autopilot] pr-risk-rescore: scored=${summary.scored} skipped=${summary.skipped}`
308 );
309 },
310 },
c63b860Claude311
312 {
313 // Block P5 — Hard-delete users whose 30-day grace period expired.
314 name: "account-purge",
315 run: async () => {
316 try {
317 const summary = await purgeScheduledAccounts({ cap: 50 });
318 console.log(
319 `[autopilot] account-purge: purged=${summary.purged} errors=${summary.errors}`
320 );
321 } catch (err) {
322 console.error("[autopilot] account-purge: threw:", err);
323 }
324 },
325 },
cd4f63bTest User326 {
327 // Block Q3 — Hard-delete anonymous playground accounts past their
328 // 24h TTL. CASCADE handles repos, sessions, issues. Per-user
329 // try/catch in the lib so one FK violation can't stall the queue.
330 name: "playground-purge",
331 run: async () => {
332 try {
333 const summary = await purgeExpiredPlaygroundAccounts({ cap: 50 });
334 console.log(
335 `[autopilot] playground-purge: purged=${summary.purged} errors=${summary.errors}`
336 );
337 } catch (err) {
338 console.error("[autopilot] playground-purge: threw:", err);
339 }
340 },
341 },
e9aa4d8Claude342 {
343 // Proactive AI monitor — hourly. Claude reads 24h of audit_log +
344 // platformDeploys + workflowRuns and opens issues on anomalies
345 // (degraded deploy times, recurring failures, suspicious audit
346 // patterns). Skips when ANTHROPIC_API_KEY is unset (the lib
347 // itself short-circuits, but the cadence gate avoids redundant
348 // work on every 5-min tick too).
349 name: "ai-proactive-monitor",
350 run: async () => {
351 if (!process.env.ANTHROPIC_API_KEY) return;
352 const now = Date.now();
353 if (now - _lastProactiveMonitorAt < PROACTIVE_MONITOR_INTERVAL_MS) {
354 return;
355 }
356 _lastProactiveMonitorAt = now;
357 try {
358 const summary = await aiProactiveMonitorTick();
359 console.log(
360 `[autopilot] ai-proactive-monitor: opened=${summary.opened} considered=${summary.considered} dedup=${summary.skippedDedupe}`
361 );
362 } catch (err) {
363 console.error("[autopilot] ai-proactive-monitor: threw:", err);
364 }
365 },
366 },
9336f45Claude367 {
368 // AI CI Healer — autonomous CI failure → root-cause → patch PR loop.
369 // Polls every tick (5 min) for failed workflow_runs that finished
370 // at least HEAL_MIN_AGE_MS ago and haven't been processed yet.
950ef90Claude371 // Skips when ANTHROPIC_API_KEY is unset.
9336f45Claude372 name: "ci-healer",
373 run: async () => {
374 if (!process.env.ANTHROPIC_API_KEY) return;
375 try {
376 const summary = await runCiHealerTick();
377 console.log(
378 `[autopilot] ci-healer: considered=${summary.considered} healed=${summary.healed} gaveUp=${summary.gaveUp} skipped=${summary.skipped}`
379 );
380 } catch (err) {
381 console.error("[autopilot] ci-healer: threw:", err);
382 }
383 },
384 },
950ef90Claude385 {
386 // Spec-to-PR autopilot — picks up `.gluecron/specs/*.md` files whose
387 // front-matter status is `ready`, asks Claude to implement the spec,
388 // opens a draft PR tagged `ai:spec-implementation`. Cadence-gated
389 // to every 2 minutes.
390 name: "spec-to-pr",
391 run: async () => {
392 if (!process.env.ANTHROPIC_API_KEY) return;
393 const now = Date.now();
394 if (now - _lastSpecToPrAt < SPEC_TO_PR_INTERVAL_MS) return;
395 _lastSpecToPrAt = now;
396 try {
397 const summary = await runSpecToPrTaskOnce();
398 console.log(
399 `[autopilot] spec-to-pr: considered=${summary.considered} dispatched=${summary.dispatched} skipped=${summary.skipped} failed=${summary.failed}`
400 );
401 } catch (err) {
402 console.error("[autopilot] spec-to-pr: threw:", err);
403 }
404 },
405 },
b1be050CC LABS App406 {
407 // BLOCK S4 — Synthetic monitor.
408 //
409 // Runs the URL-only smoke suite (see src/lib/synthetic-monitor.ts),
410 // records the outcome into `synthetic_checks`, and on a
411 // green->red transition fires a webhook to MONITOR_ALERT_WEBHOOK_URL
412 // (when configured) so the owner finds out instantly that the live
413 // site is broken. Wrapped in try/catch — the monitor must never
414 // wedge the tick.
415 name: "synthetic-monitor",
416 run: async () => {
417 try {
418 const summary = await runSyntheticMonitorTaskOnce();
419 console.log(
420 `[autopilot] synthetic-monitor: green=${summary.green} red=${summary.red} transitions=${summary.transitions}`
421 );
422 } catch (err) {
423 console.error("[autopilot] synthetic-monitor: threw:", err);
424 }
425 },
426 },
45f3b73Claude427 {
428 // Migration watcher — scans each repo's package.json for deps that
429 // are at least one major version behind and asks Claude to draft an
430 // upgrade PR. Cadence-gated to every 6 hours; the lib itself
431 // enforces a per-repo + per-{dep,version} 7-day dedupe so we never
432 // re-propose the same migration twice in a single window. Skips
433 // entirely when ANTHROPIC_API_KEY is unset OR the
434 // MIGRATION_WATCHER_ENABLED env flag is off.
435 name: "migration-watcher",
436 run: async () => {
437 if (!process.env.ANTHROPIC_API_KEY) return;
438 const now = Date.now();
439 if (now - _lastMigrationWatcherAt < MIGRATION_WATCHER_INTERVAL_MS) {
440 return;
441 }
442 _lastMigrationWatcherAt = now;
443 try {
444 const summary = await runMigrationWatcherTaskOnce();
445 console.log(
446 `[autopilot] migration-watcher: considered=${summary.considered} proposed=${summary.proposed} throttled=${summary.skippedThrottle} disabled=${summary.skippedNotEnabled} errors=${summary.errors}`
447 );
448 } catch (err) {
449 console.error("[autopilot] migration-watcher: threw:", err);
450 }
451 },
452 },
a686079Claude453 {
454 // AI Standup — daily Claude-generated team brief.
455 // Fires at the user's configured UTC hour (default 09:00). Skips
456 // entirely when ANTHROPIC_API_KEY is unset (the lib still has a
457 // deterministic fallback, but we keep this task quiet unless the
458 // operator has wired AI). Per-user dedupe via `hasStandupForToday`.
459 name: "daily-standup",
460 run: async () => {
461 if (!process.env.ANTHROPIC_API_KEY) return;
462 try {
463 const summary = await runDailyStandupTaskOnce();
464 console.log(
465 `[autopilot] daily-standup: sent=${summary.sent} skipped=${summary.skipped} errors=${summary.errors}`
466 );
467 } catch (err) {
468 console.error("[autopilot] daily-standup: threw:", err);
469 }
470 },
471 },
3c03977Claude472 {
473 // PR live co-editing — transition stale `pr_live_sessions` rows
474 // to 'idle' (>60s) and 'left' (>5m) so the presence pill on the
475 // PR detail page never claims a ghost user is still editing.
476 // Cheap pure-SQL UPDATE; runs every tick.
477 name: "pr-live-cleanup",
478 run: async () => {
479 try {
480 const summary = await sweepStalePrLive();
481 if (summary.idled > 0 || summary.left > 0) {
482 console.log(
483 `[autopilot] pr-live-cleanup: idled=${summary.idled} left=${summary.left}`
484 );
485 }
486 } catch (err) {
487 console.error("[autopilot] pr-live-cleanup: threw:", err);
488 }
489 },
490 },
a686079Claude491 {
492 // AI Standup — weekly Claude-generated team brief. Mondays only.
493 name: "weekly-standup",
494 run: async () => {
495 if (!process.env.ANTHROPIC_API_KEY) return;
496 try {
497 const summary = await runWeeklyStandupTaskOnce();
498 console.log(
499 `[autopilot] weekly-standup: sent=${summary.sent} skipped=${summary.skipped} errors=${summary.errors}`
500 );
501 } catch (err) {
502 console.error("[autopilot] weekly-standup: threw:", err);
503 }
504 },
505 },
1d4ff60Claude506 {
507 // PR test generator — when a fresh PR opens against a repo that's
508 // opted in (`autoGenerateTests=true`) and is not itself AI-generated,
509 // ask Claude to write tests for the new code and push a commit onto
510 // the same branch. Skips PRs without source-file changes; idempotent
511 // via the `ai:added-tests` marker comment. Skips entirely when
512 // ANTHROPIC_API_KEY is unset.
513 name: "pr-test-generator",
514 run: async () => {
515 if (!process.env.ANTHROPIC_API_KEY) return;
516 const now = Date.now();
517 if (now - _lastPrTestGeneratorAt < PR_TEST_GENERATOR_INTERVAL_MS) {
518 return;
519 }
520 _lastPrTestGeneratorAt = now;
521 try {
522 const summary = await runPrTestGeneratorTaskOnce();
523 if (summary.considered > 0) {
524 console.log(
525 `[autopilot] pr-test-generator: considered=${summary.considered} dispatched=${summary.dispatched} skipped=${summary.skipped} failed=${summary.failed}`
526 );
527 }
528 } catch (err) {
529 console.error("[autopilot] pr-test-generator: threw:", err);
530 }
531 },
532 },
79ed944Claude533 {
534 // Advancement scanner — weekly Claude-driven scan for "what we
535 // should ship next". Probes:
536 // 1. Newer Claude models vs the one wired in ai-client.ts
537 // 2. Stack dependencies that are at least one major behind
538 // 3. Self-improvement opportunities in the last 7d of telemetry
539 // 4. Trending dev-platform features competitors shipped
540 // Cadence-gated to Mondays 08:00 UTC (configurable via
541 // ADVANCEMENT_SCAN_HOUR_UTC + ADVANCEMENT_SCAN_DOW_UTC) and
542 // throttled to at most one scan per 6 days. Skips entirely when
543 // ANTHROPIC_API_KEY is unset — the offline probes are still
544 // useful but most of the value comes from the Claude calls.
545 // The lib itself enforces per-finding (sha256 of title) dedupe
546 // for 30 days so repeated runs never re-file the same advancement.
547 name: "advancement-scanner",
548 run: async () => {
549 if (!process.env.ANTHROPIC_API_KEY) return;
550 if (process.env.ADVANCEMENT_SCAN_DISABLED === "1") return;
551 const now = new Date();
552 const nowMs = now.getTime();
553 const targetHour = advancementScanHourUtc();
554 const targetDow = advancementScanDayOfWeek();
555 const dueByCadence =
556 nowMs - _lastAdvancementScanAt >= ADVANCEMENT_SCAN_MIN_INTERVAL_MS;
557 const dueByClock =
558 now.getUTCDay() === targetDow &&
559 now.getUTCHours() === targetHour;
560 if (!dueByCadence || !dueByClock) return;
561 _lastAdvancementScanAt = nowMs;
562 try {
563 const summary = await runAdvancementScan();
564 console.log(
565 `[autopilot] advancement-scanner: findings=${summary.findings.length} issues=${summary.openedIssues} prs=${summary.openedPrs} dedup=${summary.skippedDedupe} errors=${summary.errors}`
566 );
567 } catch (err) {
568 console.error("[autopilot] advancement-scanner: threw:", err);
569 }
570 },
571 },
424eb72Claude572 {
573 // Auto-release-notes — backfills `releases.body` with Claude-generated
574 // polished changelogs for any semver-tagged release whose body is
575 // empty / too short. Cadence-gated to every 10 minutes; the lib
576 // itself caps the per-tick batch and is no-op when no candidates
577 // exist. Falls back to a deterministic bucketed summary when
578 // ANTHROPIC_API_KEY is unset, so the body still ends up populated.
579 name: "auto-release-notes",
580 run: async () => {
581 const now = Date.now();
582 if (now - _lastAutoReleaseNotesAt < AUTO_RELEASE_NOTES_INTERVAL_MS) {
583 return;
584 }
585 _lastAutoReleaseNotesAt = now;
586 try {
587 const summary = await runAutoReleaseNotesTaskOnce();
588 if (summary.considered > 0 || summary.filled > 0) {
589 console.log(
590 `[autopilot] auto-release-notes: considered=${summary.considered} filled=${summary.filled} skipped=${summary.skipped} errors=${summary.errors}`
591 );
592 }
593 } catch (err) {
594 console.error("[autopilot] auto-release-notes: threw:", err);
595 }
596 },
597 },
79ed944Claude598 {
599 // PR sandbox cleanup — migration 0067. Tears down every PR sandbox
600 // whose `expires_at` has passed. Cadence-gated to every 30 minutes
601 // so the every-5-min outer loop doesn't pay the cost on most ticks.
602 // The lib itself is a pure SQL UPDATE — safe + cheap to call even
603 // when there's nothing to do.
604 name: "pr-sandbox-cleanup",
605 run: async () => {
606 const now = Date.now();
607 if (now - _lastPrSandboxCleanupAt < PR_SANDBOX_CLEANUP_INTERVAL_MS) {
608 return;
609 }
610 _lastPrSandboxCleanupAt = now;
611 try {
612 const expired = await expireOldSandboxes();
613 if (expired > 0) {
614 console.log(`[autopilot] pr-sandbox-cleanup: expired=${expired}`);
615 }
616 } catch (err) {
617 console.error("[autopilot] pr-sandbox-cleanup: threw:", err);
618 }
619 },
620 },
9b3a183Claude621 {
622 // Dev-env idle sweep — migration 0072. Stops every dev env whose
623 // `last_active_at + idle_minutes` has slipped into the past. Runs
624 // on every tick (cadence-gated to 5min so a faster outer loop
625 // doesn't hammer it). The lib itself is fail-soft + per-row idle
626 // aware, so this is cheap + correct even when half the rows are
627 // already 'stopped'.
628 name: "dev-env-idle-sweep",
629 run: async () => {
630 const now = Date.now();
631 if (now - _lastDevEnvIdleSweepAt < DEV_ENV_IDLE_SWEEP_INTERVAL_MS) {
632 return;
633 }
634 _lastDevEnvIdleSweepAt = now;
635 try {
636 const stopped = await expireIdleEnvs();
637 if (stopped > 0) {
638 console.log(`[autopilot] dev-env-idle-sweep: stopped=${stopped}`);
639 }
640 } catch (err) {
641 console.error("[autopilot] dev-env-idle-sweep: threw:", err);
642 }
643 },
644 },
44ed968Claude645 {
646 // Preview expiry — migration 0062. Flips every branch-preview row
647 // whose `expires_at` is in the past to status='expired'. Runs on
648 // every tick; the lib itself is a cheap SQL UPDATE and is a no-op
649 // when there's nothing to expire, so this never adds meaningful
650 // overhead.
651 name: "preview-expiry",
652 run: async () => {
653 try {
654 const expired = await expireOldPreviews();
655 if (expired > 0) {
656 console.log(`[autopilot] preview-expiry: expired=${expired}`);
657 }
658 } catch (err) {
659 console.error("[autopilot] preview-expiry: threw:", err);
660 }
661 },
662 },
f65f600Claude663 {
664 // Onboarding drip — sends pending T+1d and T+3d drip emails to new
665 // users. The T+0 "welcome" email is sent immediately at registration
666 // via src/routes/auth.tsx. This task handles the delayed emails.
667 // Idempotent via the per-user `onboarding_emails_sent` jsonb column
b12f20dClaude668 // (migration 0081). Silently skips when email is not configured.
f65f600Claude669 name: "onboarding-drip",
670 run: async () => {
671 try {
672 const summary = await runOnboardingDripTaskOnce();
673 if (summary.sent > 0 || summary.errors > 0) {
674 console.log(
675 `[autopilot] onboarding-drip: sent=${summary.sent} skipped=${summary.skipped} errors=${summary.errors}`
676 );
677 }
678 } catch (err) {
679 console.error("[autopilot] onboarding-drip: threw:", err);
680 }
681 },
682 },
f5ad215Claude683 {
684 // AI dependency auto-updater (migration 0077). Once per day: scans
685 // up to 10 repos with depUpdaterEnabled=true, checks for patch/minor
686 // npm updates, applies them, runs GateTest, and either auto-merges
687 // (green) or opens a PR with an AI migration guide (red). Skips
688 // when DEP_UPDATER_ENABLED env flag is not set to "1".
689 name: "dep-update-sweep",
690 run: async () => {
691 if (process.env.DEP_UPDATER_ENABLED !== "1") return;
692 const now = Date.now();
693 if (now - _lastDepUpdateSweepAt < DEP_UPDATE_SWEEP_INTERVAL_MS) {
694 return;
695 }
696 _lastDepUpdateSweepAt = now;
697 try {
698 const summary = await runDepUpdateSweepOnce();
699 console.log(
700 `[autopilot] dep-update-sweep: repos=${summary.repos} runs=${summary.runs} merged=${summary.merged} prs=${summary.prs} skipped=${summary.skipped} errors=${summary.errors}`
701 );
702 } catch (err) {
703 console.error("[autopilot] dep-update-sweep: threw:", err);
b271465Claude704 }
705 },
706 },
707 {
cc34156Claude708 // Smart morning digest — AI-curated daily developer queue.
709 // Fires once per day at 07:00 UTC (or whenever the 22h outer gate
710 // next opens after 07:00). Per-user 20h cooldown in sendSmartDigest
711 // ensures no user receives more than one digest per day even if the
712 // outer gate fires multiple times. Requires ANTHROPIC_API_KEY but
713 // degrades gracefully to a rule-based prioritisation when unset.
714 name: "smart-digest",
715 run: async () => {
716 const now = Date.now();
717 const nowDate = new Date(now);
718 // Only fire at hour=7 UTC or if last run was >22h ago (catch-up)
719 const isDigestHour = nowDate.getUTCHours() === 7;
720 const pastCooldown = now - _lastSmartDigestAt >= SMART_DIGEST_INTERVAL_MS;
721 if (!isDigestHour && !pastCooldown) return;
722 _lastSmartDigestAt = now;
723 try {
724 await sendSmartDigestsToAll();
725 console.log("[autopilot] smart-digest: completed");
726 } catch (err) {
727 console.error("[autopilot] smart-digest: threw:", err);
f5ad215Claude728 }
729 },
730 },
2b821b7Claude731 ];
732}
733
b1be050CC LABS App734// ---------------------------------------------------------------------------
735// BLOCK S4 — synthetic-monitor task
736// ---------------------------------------------------------------------------
737
738export interface SyntheticMonitorTaskDeps {
739 /** Override the suite runner (DI for tests). */
740 runChecks?: () => Promise<SyntheticCheckResult[]>;
741 /** Override the persistence step (DI for tests). */
742 persist?: (results: SyntheticCheckResult[]) => Promise<void>;
743 /** Override the previous-state loader (DI for tests). */
744 loadPrevious?: () => Promise<Record<string, SyntheticCheckResult>>;
745 /** Override the webhook poster (DI for tests). */
746 postAlert?: (url: string, payload: unknown) => Promise<void>;
747 /** Override the alert-webhook URL lookup (defaults to env). */
748 alertUrl?: () => string;
749}
750
751export interface SyntheticMonitorTaskSummary {
752 green: number;
753 red: number;
754 yellow: number;
755 transitions: number;
756}
757
758async function defaultPostAlert(
759 url: string,
760 payload: unknown
761): Promise<void> {
762 try {
763 await fetch(url, {
764 method: "POST",
765 headers: { "Content-Type": "application/json" },
766 body: JSON.stringify(payload),
767 });
768 } catch (err) {
769 console.error("[autopilot] synthetic-monitor: alert webhook failed:", err);
770 }
771}
772
773/**
774 * One iteration of the synthetic-monitor task. Runs the checks, persists
775 * them, compares against the prior state, and fires a webhook on each
776 * green->red transition (red->red repeats stay quiet so we don't spam
777 * the channel). Never throws.
778 */
779export async function runSyntheticMonitorTaskOnce(
780 deps: SyntheticMonitorTaskDeps = {}
781): Promise<SyntheticMonitorTaskSummary> {
782 const runChecks = deps.runChecks ?? (() => runSyntheticChecks());
783 const persist = deps.persist ?? persistChecks;
784 const loadPrevious =
785 deps.loadPrevious ??
786 (async () => {
787 const latest = await latestStatusByCheck();
788 // Strip the `checkedAt` from the result shape so the diff loop
789 // compares the canonical SyntheticCheckResult fields only.
790 const out: Record<string, SyntheticCheckResult> = {};
791 for (const [k, v] of Object.entries(latest)) {
792 const { checkedAt: _unused, ...rest } = v;
793 void _unused;
794 out[k] = rest;
795 }
796 return out;
797 });
798 const postAlert = deps.postAlert ?? defaultPostAlert;
799 const alertUrl =
800 deps.alertUrl ?? (() => process.env.MONITOR_ALERT_WEBHOOK_URL || "");
801
802 let previous: Record<string, SyntheticCheckResult> = {};
803 try {
804 previous = await loadPrevious();
805 } catch (err) {
806 console.error(
807 "[autopilot] synthetic-monitor: loadPrevious threw:",
808 err
809 );
810 previous = {};
811 }
812
813 const results = await runChecks();
814 await persist(results);
815
816 let green = 0;
817 let red = 0;
818 let yellow = 0;
819 let transitions = 0;
820 const url = alertUrl();
821
822 for (const r of results) {
823 if (r.status === "green") green += 1;
824 else if (r.status === "red") red += 1;
825 else yellow += 1;
826
827 const prior = previous[r.name];
828 // green->red transition: prior was green (or absent and current is red
829 // after a green is also a transition — but absent-before is treated as
830 // green to avoid spamming on a fresh DB). We only alert on the
831 // green->red edge so red->red doesn't re-fire.
832 const priorWasGreen = !prior || prior.status === "green";
833 if (priorWasGreen && r.status === "red") {
834 transitions += 1;
835 if (url) {
836 await postAlert(url, {
837 check: r.name,
838 status: r.status,
839 statusCode: r.statusCode ?? null,
840 durationMs: r.durationMs,
841 error: r.error ?? null,
842 checkedAt: new Date().toISOString(),
843 });
844 }
845 }
846 }
847
848 return { green, red, yellow, transitions };
849}
850
46d6165Claude851// ---------------------------------------------------------------------------
852// L1 — sleep-mode-digest
853// ---------------------------------------------------------------------------
854
855export interface SleepModeDigestCandidate {
856 userId: string;
857 digestHourUtc: number;
e1fc7dbClaude858 /** Independent cooldown anchor for the sleep-mode digest (migration 0077). */
859 lastSleepDigestSentAt: Date | null;
46d6165Claude860}
861
862export interface SleepModeDigestTaskDeps {
863 /** Override the candidate finder. */
864 findCandidates?: (cap: number) => Promise<SleepModeDigestCandidate[]>;
865 /** Override the send-one-user helper (DI for tests). */
866 sendOne?: (userId: string) => Promise<{ ok: boolean; reason?: string }>;
867 /** Override the wall clock (DI for tests). */
868 now?: () => Date;
869 /** Override the per-tick cap. */
870 cap?: number;
871 /** Override the cooldown hours. */
872 cooldownHours?: number;
873}
874
875export interface SleepModeDigestTaskSummary {
876 sent: number;
877 skipped: number;
878}
879
880/**
881 * Default candidate-finder. Returns enabled users whose
e1fc7dbClaude882 * `lastSleepDigestSentAt` is older than the cooldown OR null. The hour-match
46d6165Claude883 * filter is applied in JS by `runSleepModeDigestTaskOnce` so it stays
884 * timezone-independent of any SQL `extract(hour ...)` behaviour.
e1fc7dbClaude885 * Uses the dedicated `last_sleep_digest_sent_at` column (migration 0077) so
886 * the cooldown is independent of the weekly digest timer.
46d6165Claude887 */
888async function defaultFindSleepModeCandidates(
889 cap: number
890): Promise<SleepModeDigestCandidate[]> {
891 try {
892 const rows = await db
893 .select({
894 userId: users.id,
895 digestHourUtc: users.sleepModeDigestHourUtc,
e1fc7dbClaude896 lastSleepDigestSentAt: users.lastSleepDigestSentAt,
46d6165Claude897 })
898 .from(users)
899 .where(eq(users.sleepModeEnabled, true))
900 .limit(cap);
901 return rows.map((r) => ({
902 userId: r.userId,
903 digestHourUtc: r.digestHourUtc,
e1fc7dbClaude904 lastSleepDigestSentAt: r.lastSleepDigestSentAt,
46d6165Claude905 }));
906 } catch (err) {
907 console.error("[autopilot] sleep-mode-digest: candidate query failed:", err);
908 return [];
909 }
910}
911
912/**
913 * One iteration of the sleep-mode-digest task. Never throws.
914 *
915 * Per-user filters (applied in JS so we can DI a clock):
e1fc7dbClaude916 * 1. `lastSleepDigestSentAt` is null OR older than cooldown (23h).
46d6165Claude917 * 2. `now.getUTCHours() === digestHourUtc` — fires once at the user's
918 * configured local UTC hour.
919 *
920 * Caps at `SLEEP_MODE_USER_CAP_PER_TICK` (100) users per tick.
921 */
922export async function runSleepModeDigestTaskOnce(
923 deps: SleepModeDigestTaskDeps = {}
924): Promise<SleepModeDigestTaskSummary> {
925 const findCandidates =
926 deps.findCandidates ?? defaultFindSleepModeCandidates;
927 const sendOne = deps.sendOne ?? sendSleepModeDigestForUser;
928 const now = deps.now ?? (() => new Date());
929 const cap = deps.cap ?? SLEEP_MODE_USER_CAP_PER_TICK;
930 const cooldownHours = deps.cooldownHours ?? SLEEP_MODE_COOLDOWN_HOURS;
931
932 let candidates: SleepModeDigestCandidate[] = [];
933 try {
934 candidates = await findCandidates(cap);
935 } catch (err) {
936 console.error("[autopilot] sleep-mode-digest: findCandidates threw:", err);
937 return { sent: 0, skipped: 0 };
938 }
939
940 const nowDate = now();
941 const currentHour = nowDate.getUTCHours();
942 const cooldownMs = cooldownHours * 60 * 60 * 1000;
943
944 let sent = 0;
945 let skipped = 0;
946
947 for (const cand of candidates) {
948 try {
949 // Hour-match: must equal the user's configured UTC delivery hour.
950 if (cand.digestHourUtc !== currentHour) {
951 skipped += 1;
952 continue;
953 }
954 // Cooldown: skip if we sent within the last cooldown window.
955 if (
e1fc7dbClaude956 cand.lastSleepDigestSentAt &&
957 nowDate.getTime() - new Date(cand.lastSleepDigestSentAt).getTime() <
46d6165Claude958 cooldownMs
959 ) {
960 skipped += 1;
961 continue;
962 }
963 const result = await sendOne(cand.userId);
964 if (result.ok) sent += 1;
965 else skipped += 1;
966 } catch (err) {
967 skipped += 1;
968 console.error(
969 `[autopilot] sleep-mode-digest: per-user failure for user=${cand.userId}:`,
970 err
971 );
972 }
973 }
974
975 return { sent, skipped };
976}
977
534f04aClaude978// ---------------------------------------------------------------------------
979// M3 — pr-risk-rescore
980// ---------------------------------------------------------------------------
981
982export interface PrRiskRescoreCandidate {
983 pullRequestId: string;
984 headBranch: string;
985 updatedAt: Date;
986}
987
988export interface PrRiskRescoreTaskDeps {
989 /** Override candidate finder for tests. */
990 findCandidates?: (
991 lookbackHours: number,
992 cap: number
993 ) => Promise<PrRiskRescoreCandidate[]>;
994 /** Override score computation for tests. */
995 scoreOne?: (prId: string) => Promise<{ ok: boolean }>;
996 /** Override per-tick cap. */
997 cap?: number;
998 /** Override lookback. */
999 lookbackHours?: number;
1000}
1001
1002export interface PrRiskRescoreTaskSummary {
1003 scored: number;
1004 skipped: number;
1005}
1006
1007/**
1008 * Default candidate-finder. Returns open, non-draft PRs from non-archived
1009 * repos whose `updated_at` falls inside the lookback window. The "scored
1010 * at all" filter is applied as a second pass via `defaultFilterNeedsScoring`.
1011 */
1012async function defaultFindPrRiskCandidates(
1013 lookbackHours: number,
1014 cap: number
1015): Promise<PrRiskRescoreCandidate[]> {
1016 const cutoff = new Date(Date.now() - lookbackHours * 60 * 60 * 1000);
1017 try {
1018 const rows = await db
1019 .select({
1020 pullRequestId: pullRequests.id,
1021 headBranch: pullRequests.headBranch,
1022 updatedAt: pullRequests.updatedAt,
1023 })
1024 .from(pullRequests)
1025 .innerJoin(
1026 repositories,
1027 eq(repositories.id, pullRequests.repositoryId)
1028 )
1029 .where(
1030 and(
1031 eq(pullRequests.state, "open"),
1032 eq(pullRequests.isDraft, false),
1033 eq(repositories.isArchived, false),
1034 gte(pullRequests.updatedAt, cutoff)
1035 )
1036 )
1037 .orderBy(sql`${pullRequests.updatedAt} DESC`)
1038 .limit(cap);
1039 return rows.map((r) => ({
1040 pullRequestId: r.pullRequestId,
1041 headBranch: r.headBranch,
1042 updatedAt: r.updatedAt,
1043 }));
1044 } catch (err) {
1045 console.error("[autopilot] pr-risk-rescore: candidate query failed:", err);
1046 return [];
1047 }
1048}
1049
1050/**
1051 * Drop candidates that already have ANY cached score row. The unique
1052 * constraint on (pull_request_id, commit_sha) handles the "score-the-
1053 * same-SHA-twice" case at persist time; this filter just keeps the work
1054 * list small enough to fit under the per-tick cap when many PRs are
1055 * being pushed concurrently.
1056 */
1057async function defaultFilterNeedsScoring(
1058 candidates: PrRiskRescoreCandidate[]
1059): Promise<PrRiskRescoreCandidate[]> {
1060 if (candidates.length === 0) return [];
1061 try {
1062 const rows = await db
1063 .select({ pullRequestId: prRiskScores.pullRequestId })
1064 .from(prRiskScores);
1065 const scoredIds = new Set(rows.map((r) => r.pullRequestId));
1066 return candidates.filter((c) => !scoredIds.has(c.pullRequestId));
1067 } catch (err) {
1068 console.error("[autopilot] pr-risk-rescore: filter query failed:", err);
1069 // Fail-open: better to score everything than silently skip.
1070 return candidates;
1071 }
1072}
1073
1074/**
1075 * One iteration of the pr-risk-rescore task. Never throws. Compute risk
1076 * for up to `cap` recently-touched open PRs that have no cached score
1077 * yet, so reviewers usually see a populated card on first visit.
1078 */
1079export async function runPrRiskRescoreTaskOnce(
1080 deps: PrRiskRescoreTaskDeps = {}
1081): Promise<PrRiskRescoreTaskSummary> {
1082 const findCandidates = deps.findCandidates ?? defaultFindPrRiskCandidates;
1083 const scoreOne =
1084 deps.scoreOne ??
1085 (async (prId: string) => {
1086 try {
1087 const result = await computePrRiskForPullRequest(prId);
1088 return { ok: result !== null };
1089 } catch {
1090 return { ok: false };
1091 }
1092 });
1093 const cap = deps.cap ?? PR_RISK_RESCORE_MAX_PER_TICK;
1094 const lookbackHours =
1095 deps.lookbackHours ?? PR_RISK_RESCORE_LOOKBACK_HOURS;
1096
1097 let candidates: PrRiskRescoreCandidate[] = [];
1098 try {
1099 candidates = await findCandidates(lookbackHours, cap);
1100 } catch (err) {
1101 console.error("[autopilot] pr-risk-rescore: findCandidates threw:", err);
1102 return { scored: 0, skipped: 0 };
1103 }
1104
1105 // Only score PRs missing a cached row. Skip filter when the caller
1106 // injected a custom finder (tests pass already-filtered lists).
1107 const needsScoring =
1108 deps.findCandidates === undefined
1109 ? await defaultFilterNeedsScoring(candidates)
1110 : candidates;
1111
1112 let scored = 0;
1113 let skipped = 0;
1114 for (const cand of needsScoring.slice(0, cap)) {
1115 try {
1116 const result = await scoreOne(cand.pullRequestId);
1117 if (result.ok) scored += 1;
1118 else skipped += 1;
1119 } catch (err) {
1120 skipped += 1;
1121 console.error(
1122 `[autopilot] pr-risk-rescore: per-PR failure for pr=${cand.pullRequestId}:`,
1123 err
1124 );
1125 }
1126 }
1127 if (needsScoring.length > cap) {
1128 skipped += needsScoring.length - cap;
1129 }
1130
1131 return { scored, skipped };
1132}
1133
2b9055eClaude1134// ---------------------------------------------------------------------------
1135// K3 — auto-merge-sweep
1136// ---------------------------------------------------------------------------
1137
1138interface SweepCandidate {
1139 prId: string;
1140 prNumber: number;
1141 prTitle: string;
1142 prBody: string | null;
1143 baseBranch: string;
1144 headBranch: string;
1145 isDraft: boolean;
1146 repositoryId: string;
1147 authorUserId: string;
1148 ownerUsername: string | null;
1149 repoName: string;
1150 state: string;
1151}
1152
1153export interface AutoMergeSweepDeps {
1154 /** Inject candidate-finder for tests. */
1155 findCandidates?: (lookbackHours: number, limit: number) => Promise<SweepCandidate[]>;
1156 /** Inject evaluator for tests. */
1157 evaluate?: (ctx: AutoMergeContext) => Promise<AutoMergeDecision>;
1158 /** Inject the merge executor for tests. */
1159 merge?: (cand: SweepCandidate) => Promise<PerformMergeResult>;
1160 /** Inject the audit-recording side-effect for tests. */
1161 recordAttempt?: (
1162 repoId: string,
1163 prId: string,
1164 decision: AutoMergeDecision
1165 ) => Promise<void>;
1166 /** Inject the audit/comment side-effects for the merged path (tests). */
1167 onMerged?: (
1168 cand: SweepCandidate,
1169 result: PerformMergeResult
1170 ) => Promise<void>;
1171 /** Inject the audit side-effect for the merge-failed path (tests). */
1172 onMergeFailed?: (cand: SweepCandidate, error: string) => Promise<void>;
1173 /** Inject the AI-key short-circuit signal for tests. */
1174 shouldShortCircuitAi?: (cand: SweepCandidate) => Promise<boolean>;
1175}
1176
1177export interface AutoMergeSweepSummary {
1178 evaluated: number;
1179 merged: number;
1180 blocked: number;
1181}
1182
1183/**
1184 * Default candidate-finder. Selects open, non-draft PRs from non-archived
1185 * repos whose `updated_at` is within the lookback window. Joins repo +
1186 * owner so the merge executor doesn't need extra round trips. Cap is
1187 * enforced at the SQL layer.
1188 */
1189async function defaultFindAutoMergeCandidates(
1190 lookbackHours: number,
1191 limit: number
1192): Promise<SweepCandidate[]> {
1193 const cutoff = new Date(Date.now() - lookbackHours * 60 * 60 * 1000);
1194 try {
1195 const rows = await db
1196 .select({
1197 prId: pullRequests.id,
1198 prNumber: pullRequests.number,
1199 prTitle: pullRequests.title,
1200 prBody: pullRequests.body,
1201 baseBranch: pullRequests.baseBranch,
1202 headBranch: pullRequests.headBranch,
1203 isDraft: pullRequests.isDraft,
1204 repositoryId: pullRequests.repositoryId,
1205 authorUserId: pullRequests.authorId,
1206 ownerUsername: users.username,
1207 repoName: repositories.name,
1208 state: pullRequests.state,
1209 })
1210 .from(pullRequests)
1211 .innerJoin(
1212 repositories,
1213 eq(repositories.id, pullRequests.repositoryId)
1214 )
1215 .leftJoin(users, eq(users.id, repositories.ownerId))
1216 .where(
1217 and(
1218 eq(pullRequests.state, "open"),
1219 eq(pullRequests.isDraft, false),
1220 eq(repositories.isArchived, false),
1221 gte(pullRequests.updatedAt, cutoff)
1222 )
1223 )
1224 .limit(limit);
1225 return rows.map((r) => ({
1226 prId: r.prId,
1227 prNumber: r.prNumber,
1228 prTitle: r.prTitle,
1229 prBody: r.prBody,
1230 baseBranch: r.baseBranch,
1231 headBranch: r.headBranch,
1232 isDraft: r.isDraft,
1233 repositoryId: r.repositoryId,
1234 authorUserId: r.authorUserId,
1235 ownerUsername: r.ownerUsername ?? null,
1236 repoName: r.repoName,
1237 state: r.state,
1238 }));
1239 } catch (err) {
1240 console.error("[autopilot] auto-merge: candidate query failed:", err);
1241 return [];
1242 }
1243}
1244
1245/**
1246 * Determine whether the matched branch_protection rule on this PR
1247 * requires AI approval but no `ANTHROPIC_API_KEY` is configured. In that
1248 * case the AI-approval check would inevitably fail downstream, so we
1249 * short-circuit to a "blocked" decision without invoking `evaluateAutoMerge`
1250 * — keeps the log readable and prevents misleading "AI review unavailable"
1251 * lines in the audit trail.
1252 */
1253async function defaultShouldShortCircuitAi(
1254 cand: SweepCandidate
1255): Promise<boolean> {
1256 if (process.env.ANTHROPIC_API_KEY) return false;
1257 try {
1258 const rule = await matchProtection(cand.repositoryId, cand.baseBranch);
1259 return !!(rule && rule.requireAiApproval);
1260 } catch {
1261 return false;
1262 }
1263}
1264
1265/**
1266 * Default success-path: post an `auto_merge.merged` audit row + a stable
1267 * marker comment on the PR so a partial-merge retry doesn't double-post.
1268 * Both are best-effort; failures are logged not thrown.
1269 */
1270async function defaultOnMerged(
1271 cand: SweepCandidate,
1272 result: PerformMergeResult
1273): Promise<void> {
1274 try {
1275 await audit({
1276 repositoryId: cand.repositoryId,
1277 action: "auto_merge.merged",
1278 targetType: "pull_request",
1279 targetId: cand.prId,
1280 metadata: {
1281 prNumber: cand.prNumber,
1282 baseBranch: cand.baseBranch,
1283 headBranch: cand.headBranch,
1284 closedIssueNumbers: result.closedIssueNumbers,
1285 resolvedFiles: result.resolvedFiles,
1286 },
1287 });
1288 } catch (err) {
1289 console.error("[autopilot] auto-merge: merged audit failed:", err);
1290 }
1291 try {
a7460bfClaude1292 const commentAuthorId = await getBotUserIdOrFallback(cand.authorUserId);
2b9055eClaude1293 await db.insert(prComments).values({
1294 pullRequestId: cand.prId,
a7460bfClaude1295 authorId: commentAuthorId,
2b9055eClaude1296 isAiReview: true,
1297 body: `${AUTO_MERGE_COMMENT_MARKER}\nAuto-merged by Gluecron autopilot — branch protection conditions satisfied.`,
1298 });
1299 } catch (err) {
1300 console.error("[autopilot] auto-merge: comment insert failed:", err);
1301 }
1302}
1303
1304/** Default failure-path: only an audit row; no comment (we may retry). */
1305async function defaultOnMergeFailed(
1306 cand: SweepCandidate,
1307 error: string
1308): Promise<void> {
1309 try {
1310 await audit({
1311 repositoryId: cand.repositoryId,
1312 action: "auto_merge.merge_failed",
1313 targetType: "pull_request",
1314 targetId: cand.prId,
1315 metadata: {
1316 prNumber: cand.prNumber,
1317 baseBranch: cand.baseBranch,
1318 headBranch: cand.headBranch,
1319 error,
1320 },
1321 });
1322 } catch (err) {
1323 console.error("[autopilot] auto-merge: merge_failed audit failed:", err);
1324 }
1325}
1326
1327/**
1328 * Execute one sweep over recently-updated open PRs. For each, evaluate
1329 * with K2's `evaluateAutoMerge`; on `merge: true`, call `performMerge` and
1330 * record the merged/merge-failed audit row + comment. Always record the
1331 * `auto_merge.evaluated` audit row via `recordAutoMergeAttempt`.
1332 *
1333 * Returns a counts summary that the autopilot prints as the tick log line.
1334 * Never throws.
1335 */
1336export async function runAutoMergeSweep(
1337 deps: AutoMergeSweepDeps = {}
1338): Promise<AutoMergeSweepSummary> {
1339 const findCandidates = deps.findCandidates ?? defaultFindAutoMergeCandidates;
1340 const evaluate =
1341 deps.evaluate ?? ((ctx) => evaluateAutoMerge(ctx, {}));
1342 const merge =
1343 deps.merge ??
1344 (async (cand) => {
1345 if (!cand.ownerUsername) {
1346 return {
1347 ok: false,
1348 error: "owner username unresolved",
1349 closedIssueNumbers: [],
1350 resolvedFiles: [],
1351 };
1352 }
1353 return performMerge({
1354 pr: {
1355 id: cand.prId,
1356 number: cand.prNumber,
1357 title: cand.prTitle,
1358 body: cand.prBody,
1359 baseBranch: cand.baseBranch,
1360 headBranch: cand.headBranch,
1361 repositoryId: cand.repositoryId,
1362 authorId: cand.authorUserId,
1363 state: cand.state as "open",
1364 isDraft: cand.isDraft,
1365 },
1366 ownerName: cand.ownerUsername,
1367 repoName: cand.repoName,
1368 actorUserId: cand.authorUserId,
1369 });
1370 });
1371 const recordAttempt = deps.recordAttempt ?? recordAutoMergeAttempt;
1372 const onMerged = deps.onMerged ?? defaultOnMerged;
1373 const onMergeFailed = deps.onMergeFailed ?? defaultOnMergeFailed;
1374 const shouldShortCircuitAi =
1375 deps.shouldShortCircuitAi ?? defaultShouldShortCircuitAi;
1376
1377 let candidates: SweepCandidate[] = [];
1378 try {
1379 candidates = await findCandidates(
1380 AUTO_MERGE_LOOKBACK_HOURS,
1381 AUTO_MERGE_MAX_PER_TICK
1382 );
1383 } catch (err) {
1384 console.error("[autopilot] auto-merge: findCandidates threw:", err);
1385 return { evaluated: 0, merged: 0, blocked: 0 };
1386 }
1387
1388 let evaluated = 0;
1389 let merged = 0;
1390 let blocked = 0;
1391
1392 for (const cand of candidates) {
1393 try {
1394 evaluated += 1;
1395
1396 // AI-key short-circuit: if the rule requires AI approval and we have
1397 // no key, treat as blocked without calling the evaluator (which would
1398 // log a misleading "AI review unavailable").
1399 let decision: AutoMergeDecision;
1400 if (await shouldShortCircuitAi(cand)) {
1401 decision = {
1402 merge: false,
1403 reason:
1404 "Branch protection requires AI approval but ANTHROPIC_API_KEY is unset.",
1405 blocking: [
1406 "ANTHROPIC_API_KEY missing; AI approval cannot be sourced.",
1407 ],
1408 };
1409 } else {
1410 decision = await evaluate({
1411 pullRequestId: cand.prId,
1412 repositoryId: cand.repositoryId,
1413 baseBranch: cand.baseBranch,
1414 isDraft: cand.isDraft,
1415 authorUserId: cand.authorUserId,
1416 });
1417 }
1418
1419 // Always record the evaluation, regardless of outcome.
1420 try {
1421 await recordAttempt(cand.repositoryId, cand.prId, decision);
1422 } catch (err) {
1423 console.error(
1424 `[autopilot] auto-merge: recordAttempt failed for pr=${cand.prId}:`,
1425 err
1426 );
1427 }
1428
1429 if (!decision.merge) {
1430 blocked += 1;
1431 continue;
1432 }
1433
1434 // Perform the actual merge.
1435 const result = await merge(cand);
1436 if (result.ok) {
1437 merged += 1;
1438 await onMerged(cand, result);
1439 } else {
1440 blocked += 1;
1441 await onMergeFailed(cand, result.error || "unknown merge error");
1442 }
1443 } catch (err) {
1444 blocked += 1;
1445 console.error(
1446 `[autopilot] auto-merge: per-PR failure for pr=${cand.prId}:`,
1447 err
1448 );
1449 }
1450 }
1451
1452 console.log(
1453 `[autopilot] auto-merge: evaluated=${evaluated} merged=${merged} blocked=${blocked}`
1454 );
1455
1456 return { evaluated, merged, blocked };
1457}
1458
2b821b7Claude1459/**
1460 * Visits each distinct (repo, base_branch) that has queued rows and logs a
1461 * stub depth line. The actual gate-running + merge happens in the pulls
1462 * route; this tick is just a heartbeat so we can wire per-queue progress
1463 * through without duplicating merge logic.
1464 */
1465async function processMergeQueues(): Promise<void> {
1466 let distinct: Array<{ repositoryId: string; baseBranch: string }> = [];
1467 try {
1468 const rows = await db
1469 .selectDistinct({
1470 repositoryId: mergeQueueEntries.repositoryId,
1471 baseBranch: mergeQueueEntries.baseBranch,
1472 })
1473 .from(mergeQueueEntries)
1474 .where(sql`${mergeQueueEntries.state} IN ('queued','running')`);
1475 distinct = rows;
1476 } catch (err) {
1477 console.error("[autopilot] merge-queue: distinct query failed:", err);
1478 return;
1479 }
1480 for (const d of distinct) {
1481 try {
1482 const head = await peekHead(d.repositoryId, d.baseBranch);
1483 if (head) {
1484 console.log(
1485 `[autopilot] merge queue depth head=${head.id.slice(0, 8)} repo=${d.repositoryId.slice(0, 8)} base=${d.baseBranch}`
1486 );
1487 }
1488 } catch (err) {
1489 console.error(
1490 `[autopilot] merge-queue: peek failed for repo=${d.repositoryId}:`,
1491 err
1492 );
1493 }
1494 }
1495}
1496
1497/**
1498 * Pick a small batch of repos that actually have dep rows and re-run
1499 * advisory scan against them. Cheap — one SELECT DISTINCT with LIMIT.
1500 */
1501async function rescanAdvisoriesBatch(limit: number): Promise<void> {
1502 let repoIds: string[] = [];
1503 try {
1504 const rows = await db
1505 .selectDistinct({ repositoryId: repoDependencies.repositoryId })
1506 .from(repoDependencies)
1507 .limit(limit);
1508 repoIds = rows.map((r) => r.repositoryId);
1509 } catch (err) {
1510 console.error("[autopilot] advisory-rescan: query failed:", err);
1511 return;
1512 }
1513 for (const id of repoIds) {
1514 try {
1515 await scanRepositoryForAlerts(id);
1516 } catch (err) {
1517 console.error(
1518 `[autopilot] advisory-rescan: scan failed for repo=${id}:`,
1519 err
1520 );
1521 }
1522 }
1523}
1524
1525/** Resolve the tick interval from env → opts → default. */
1526function resolveIntervalMs(optsMs?: number): number {
1527 if (typeof optsMs === "number" && optsMs > 0) return optsMs;
1528 const raw = process.env.AUTOPILOT_INTERVAL_MS;
1529 if (raw) {
1530 const parsed = Number(raw);
1531 if (Number.isFinite(parsed) && parsed > 0) return parsed;
1532 }
1533 return DEFAULT_INTERVAL_MS;
1534}
1535
1536/**
1537 * Start the recurring autopilot loop. No-op when AUTOPILOT_DISABLED=1.
1538 * The first tick fires after `intervalMs`, not immediately, to keep boot
1539 * fast. Returns a `stop()` that clears the interval.
1540 */
1541export function startAutopilot(opts?: StartAutopilotOpts): { stop: () => void } {
1542 if (process.env.AUTOPILOT_DISABLED === "1") {
1543 return { stop: () => {} };
1544 }
1545 const intervalMs = resolveIntervalMs(opts?.intervalMs);
1546 const tasks = opts?.tasks ?? defaultTasks();
1547 let running = false;
1548 const handle = setInterval(() => {
1549 if (running) return;
1550 running = true;
1551 void runAutopilotTick({ tasks, now: opts?.now })
1552 .catch(() => {
1553 // runAutopilotTick already never throws, but belt-and-braces.
1554 })
1555 .finally(() => {
1556 running = false;
1557 });
1558 }, intervalMs);
1559 return {
1560 stop: () => clearInterval(handle),
1561 };
1562}
1563
8e9f1d9Claude1564/** Last tick snapshot for observability. Module-level, swap-on-complete. */
1565let lastTick: AutopilotTickResult | null = null;
1566let tickCount = 0;
1567
1568/** Return the most recent completed tick, or null if autopilot hasn't run yet. */
1569export function getLastTick(): AutopilotTickResult | null {
1570 return lastTick;
1571}
1572
1573/** Return the total number of completed ticks in this process. */
1574export function getTickCount(): number {
1575 return tickCount;
1576}
1577
2b821b7Claude1578/**
1579 * Run one tick: invokes every sub-task with its own try/catch, records a
1580 * per-task result, and emits a single summary line. Never throws.
1581 */
1582export async function runAutopilotTick(
1583 opts?: RunTickOpts
1584): Promise<AutopilotTickResult> {
1585 const now = opts?.now ?? Date.now;
1586 const tasks = opts?.tasks ?? defaultTasks();
1587 const startedAt = new Date(now()).toISOString();
1588 const results: AutopilotTaskResult[] = [];
1589 for (const t of tasks) {
1590 const t0 = now();
1591 try {
1592 await t.run();
1593 results.push({ name: t.name, ok: true, durationMs: now() - t0 });
1594 } catch (err) {
1595 const message =
1596 err instanceof Error ? err.message : String(err ?? "unknown error");
1597 console.error(`[autopilot] ${t.name}: ${message}`);
1598 results.push({
1599 name: t.name,
1600 ok: false,
1601 durationMs: now() - t0,
1602 error: message,
1603 });
1604 }
1605 }
1606 const finishedAt = new Date(now()).toISOString();
1607 const totalMs = results.reduce((a, r) => a + r.durationMs, 0);
1608 const okCount = results.filter((r) => r.ok).length;
1609 console.log(
1610 `[autopilot] tick ok tasks=${okCount}/${results.length} ms=${totalMs}`
1611 );
8e9f1d9Claude1612 const result: AutopilotTickResult = { startedAt, finishedAt, tasks: results };
1613 lastTick = result;
1614 tickCount += 1;
1615 return result;
2b821b7Claude1616}
1617
1618/** Exposed for unit tests. */
1619export const __test = {
1620 resolveIntervalMs,
1621 processMergeQueues,
1622 rescanAdvisoriesBatch,
1623 DEFAULT_INTERVAL_MS,
1624 ADVISORY_RESCAN_BATCH,
2b9055eClaude1625 AUTO_MERGE_LOOKBACK_HOURS,
1626 AUTO_MERGE_MAX_PER_TICK,
1627 AUTO_MERGE_COMMENT_MARKER,
534f04aClaude1628 PR_RISK_RESCORE_MAX_PER_TICK,
1629 PR_RISK_RESCORE_LOOKBACK_HOURS,
2b9055eClaude1630 defaultFindAutoMergeCandidates,
1631 defaultOnMerged,
1632 defaultOnMergeFailed,
1633 defaultShouldShortCircuitAi,
534f04aClaude1634 defaultFindPrRiskCandidates,
1635 defaultFilterNeedsScoring,
2b821b7Claude1636};