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