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