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