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