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