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