Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

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