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