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