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