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