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