Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

autopilot.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

autopilot.tsBlame772 lines · 1 contributor
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";
2b821b7Claude43
44export interface AutopilotTaskResult {
45 name: string;
46 ok: boolean;
47 durationMs: number;
48 error?: string;
49}
50
51export interface AutopilotTickResult {
52 startedAt: string;
53 finishedAt: string;
54 tasks: AutopilotTaskResult[];
55}
56
57export interface AutopilotTask {
58 name: string;
59 run: () => Promise<void>;
60}
61
62export interface StartAutopilotOpts {
63 intervalMs?: number;
64 now?: () => number;
65 tasks?: AutopilotTask[];
66}
67
68export interface RunTickOpts {
69 tasks?: AutopilotTask[];
70 now?: () => number;
71}
72
73const DEFAULT_INTERVAL_MS = 5 * 60 * 1000;
74const ADVISORY_RESCAN_BATCH = 5;
2b9055eClaude75/** K3 — recency window for auto-merge candidate selection. */
76const AUTO_MERGE_LOOKBACK_HOURS = 24;
77/** K3 — hard cap on PRs evaluated per tick (runaway protection). */
78const AUTO_MERGE_MAX_PER_TICK = 50;
79/** K3 — stable marker for the auto-merge audit comment. */
80const AUTO_MERGE_COMMENT_MARKER = "<!-- gluecron:auto-merge:v1 -->";
2b821b7Claude81
82/**
83 * Default task set. Each task is a thin wrapper around an existing locked
84 * helper — no gate/merge logic is duplicated here.
85 */
86export function defaultTasks(): AutopilotTask[] {
87 return [
88 {
89 name: "mirror-sync",
90 run: async () => {
91 await syncAllDue();
92 },
93 },
94 {
95 name: "merge-queue",
96 run: async () => {
97 await processMergeQueues();
98 },
99 },
100 {
101 name: "weekly-digest",
102 run: async () => {
103 await sendDigestsToAll();
104 },
105 },
106 {
107 name: "advisory-rescan",
108 run: async () => {
109 await rescanAdvisoriesBatch(ADVISORY_RESCAN_BATCH);
110 },
111 },
a76d984Claude112 {
113 name: "wait-timer-release",
114 run: async () => {
115 await releaseExpiredWaitTimers();
116 },
117 },
665c8bfClaude118 {
119 name: "scheduled-workflows",
120 run: async () => {
121 await runScheduledWorkflowsTick();
122 },
123 },
2b9055eClaude124 {
125 name: "auto-merge-sweep",
126 run: async () => {
127 await runAutoMergeSweep();
128 },
129 },
130 {
131 name: "ai-build-from-issues",
132 run: async () => {
133 const summary = await runAiBuildTaskOnce();
134 console.log(
135 `[autopilot] ai-build: queued=${summary.queued} skipped=${summary.skipped}`
136 );
137 },
138 },
46d6165Claude139 {
140 name: "sleep-mode-digest",
141 run: async () => {
142 const summary = await runSleepModeDigestTaskOnce();
143 console.log(
144 `[autopilot] sleep-mode-digest: sent=${summary.sent} skipped=${summary.skipped}`
145 );
146 },
147 },
2b821b7Claude148 ];
149}
150
46d6165Claude151// ---------------------------------------------------------------------------
152// L1 — sleep-mode-digest
153// ---------------------------------------------------------------------------
154
155export interface SleepModeDigestCandidate {
156 userId: string;
157 digestHourUtc: number;
158 lastDigestSentAt: Date | null;
159}
160
161export interface SleepModeDigestTaskDeps {
162 /** Override the candidate finder. */
163 findCandidates?: (cap: number) => Promise<SleepModeDigestCandidate[]>;
164 /** Override the send-one-user helper (DI for tests). */
165 sendOne?: (userId: string) => Promise<{ ok: boolean; reason?: string }>;
166 /** Override the wall clock (DI for tests). */
167 now?: () => Date;
168 /** Override the per-tick cap. */
169 cap?: number;
170 /** Override the cooldown hours. */
171 cooldownHours?: number;
172}
173
174export interface SleepModeDigestTaskSummary {
175 sent: number;
176 skipped: number;
177}
178
179/**
180 * Default candidate-finder. Returns enabled users whose
181 * `lastDigestSentAt` is older than the cooldown OR null. The hour-match
182 * filter is applied in JS by `runSleepModeDigestTaskOnce` so it stays
183 * timezone-independent of any SQL `extract(hour ...)` behaviour.
184 */
185async function defaultFindSleepModeCandidates(
186 cap: number
187): Promise<SleepModeDigestCandidate[]> {
188 try {
189 const rows = await db
190 .select({
191 userId: users.id,
192 digestHourUtc: users.sleepModeDigestHourUtc,
193 lastDigestSentAt: users.lastDigestSentAt,
194 })
195 .from(users)
196 .where(eq(users.sleepModeEnabled, true))
197 .limit(cap);
198 return rows.map((r) => ({
199 userId: r.userId,
200 digestHourUtc: r.digestHourUtc,
201 lastDigestSentAt: r.lastDigestSentAt,
202 }));
203 } catch (err) {
204 console.error("[autopilot] sleep-mode-digest: candidate query failed:", err);
205 return [];
206 }
207}
208
209/**
210 * One iteration of the sleep-mode-digest task. Never throws.
211 *
212 * Per-user filters (applied in JS so we can DI a clock):
213 * 1. `lastDigestSentAt` is null OR older than cooldown (23h).
214 * 2. `now.getUTCHours() === digestHourUtc` — fires once at the user's
215 * configured local UTC hour.
216 *
217 * Caps at `SLEEP_MODE_USER_CAP_PER_TICK` (100) users per tick.
218 */
219export async function runSleepModeDigestTaskOnce(
220 deps: SleepModeDigestTaskDeps = {}
221): Promise<SleepModeDigestTaskSummary> {
222 const findCandidates =
223 deps.findCandidates ?? defaultFindSleepModeCandidates;
224 const sendOne = deps.sendOne ?? sendSleepModeDigestForUser;
225 const now = deps.now ?? (() => new Date());
226 const cap = deps.cap ?? SLEEP_MODE_USER_CAP_PER_TICK;
227 const cooldownHours = deps.cooldownHours ?? SLEEP_MODE_COOLDOWN_HOURS;
228
229 let candidates: SleepModeDigestCandidate[] = [];
230 try {
231 candidates = await findCandidates(cap);
232 } catch (err) {
233 console.error("[autopilot] sleep-mode-digest: findCandidates threw:", err);
234 return { sent: 0, skipped: 0 };
235 }
236
237 const nowDate = now();
238 const currentHour = nowDate.getUTCHours();
239 const cooldownMs = cooldownHours * 60 * 60 * 1000;
240
241 let sent = 0;
242 let skipped = 0;
243
244 for (const cand of candidates) {
245 try {
246 // Hour-match: must equal the user's configured UTC delivery hour.
247 if (cand.digestHourUtc !== currentHour) {
248 skipped += 1;
249 continue;
250 }
251 // Cooldown: skip if we sent within the last cooldown window.
252 if (
253 cand.lastDigestSentAt &&
254 nowDate.getTime() - new Date(cand.lastDigestSentAt).getTime() <
255 cooldownMs
256 ) {
257 skipped += 1;
258 continue;
259 }
260 const result = await sendOne(cand.userId);
261 if (result.ok) sent += 1;
262 else skipped += 1;
263 } catch (err) {
264 skipped += 1;
265 console.error(
266 `[autopilot] sleep-mode-digest: per-user failure for user=${cand.userId}:`,
267 err
268 );
269 }
270 }
271
272 return { sent, skipped };
273}
274
2b9055eClaude275// ---------------------------------------------------------------------------
276// K3 — auto-merge-sweep
277// ---------------------------------------------------------------------------
278
279interface SweepCandidate {
280 prId: string;
281 prNumber: number;
282 prTitle: string;
283 prBody: string | null;
284 baseBranch: string;
285 headBranch: string;
286 isDraft: boolean;
287 repositoryId: string;
288 authorUserId: string;
289 ownerUsername: string | null;
290 repoName: string;
291 state: string;
292}
293
294export interface AutoMergeSweepDeps {
295 /** Inject candidate-finder for tests. */
296 findCandidates?: (lookbackHours: number, limit: number) => Promise<SweepCandidate[]>;
297 /** Inject evaluator for tests. */
298 evaluate?: (ctx: AutoMergeContext) => Promise<AutoMergeDecision>;
299 /** Inject the merge executor for tests. */
300 merge?: (cand: SweepCandidate) => Promise<PerformMergeResult>;
301 /** Inject the audit-recording side-effect for tests. */
302 recordAttempt?: (
303 repoId: string,
304 prId: string,
305 decision: AutoMergeDecision
306 ) => Promise<void>;
307 /** Inject the audit/comment side-effects for the merged path (tests). */
308 onMerged?: (
309 cand: SweepCandidate,
310 result: PerformMergeResult
311 ) => Promise<void>;
312 /** Inject the audit side-effect for the merge-failed path (tests). */
313 onMergeFailed?: (cand: SweepCandidate, error: string) => Promise<void>;
314 /** Inject the AI-key short-circuit signal for tests. */
315 shouldShortCircuitAi?: (cand: SweepCandidate) => Promise<boolean>;
316}
317
318export interface AutoMergeSweepSummary {
319 evaluated: number;
320 merged: number;
321 blocked: number;
322}
323
324/**
325 * Default candidate-finder. Selects open, non-draft PRs from non-archived
326 * repos whose `updated_at` is within the lookback window. Joins repo +
327 * owner so the merge executor doesn't need extra round trips. Cap is
328 * enforced at the SQL layer.
329 */
330async function defaultFindAutoMergeCandidates(
331 lookbackHours: number,
332 limit: number
333): Promise<SweepCandidate[]> {
334 const cutoff = new Date(Date.now() - lookbackHours * 60 * 60 * 1000);
335 try {
336 const rows = await db
337 .select({
338 prId: pullRequests.id,
339 prNumber: pullRequests.number,
340 prTitle: pullRequests.title,
341 prBody: pullRequests.body,
342 baseBranch: pullRequests.baseBranch,
343 headBranch: pullRequests.headBranch,
344 isDraft: pullRequests.isDraft,
345 repositoryId: pullRequests.repositoryId,
346 authorUserId: pullRequests.authorId,
347 ownerUsername: users.username,
348 repoName: repositories.name,
349 state: pullRequests.state,
350 })
351 .from(pullRequests)
352 .innerJoin(
353 repositories,
354 eq(repositories.id, pullRequests.repositoryId)
355 )
356 .leftJoin(users, eq(users.id, repositories.ownerId))
357 .where(
358 and(
359 eq(pullRequests.state, "open"),
360 eq(pullRequests.isDraft, false),
361 eq(repositories.isArchived, false),
362 gte(pullRequests.updatedAt, cutoff)
363 )
364 )
365 .limit(limit);
366 return rows.map((r) => ({
367 prId: r.prId,
368 prNumber: r.prNumber,
369 prTitle: r.prTitle,
370 prBody: r.prBody,
371 baseBranch: r.baseBranch,
372 headBranch: r.headBranch,
373 isDraft: r.isDraft,
374 repositoryId: r.repositoryId,
375 authorUserId: r.authorUserId,
376 ownerUsername: r.ownerUsername ?? null,
377 repoName: r.repoName,
378 state: r.state,
379 }));
380 } catch (err) {
381 console.error("[autopilot] auto-merge: candidate query failed:", err);
382 return [];
383 }
384}
385
386/**
387 * Determine whether the matched branch_protection rule on this PR
388 * requires AI approval but no `ANTHROPIC_API_KEY` is configured. In that
389 * case the AI-approval check would inevitably fail downstream, so we
390 * short-circuit to a "blocked" decision without invoking `evaluateAutoMerge`
391 * — keeps the log readable and prevents misleading "AI review unavailable"
392 * lines in the audit trail.
393 */
394async function defaultShouldShortCircuitAi(
395 cand: SweepCandidate
396): Promise<boolean> {
397 if (process.env.ANTHROPIC_API_KEY) return false;
398 try {
399 const rule = await matchProtection(cand.repositoryId, cand.baseBranch);
400 return !!(rule && rule.requireAiApproval);
401 } catch {
402 return false;
403 }
404}
405
406/**
407 * Default success-path: post an `auto_merge.merged` audit row + a stable
408 * marker comment on the PR so a partial-merge retry doesn't double-post.
409 * Both are best-effort; failures are logged not thrown.
410 */
411async function defaultOnMerged(
412 cand: SweepCandidate,
413 result: PerformMergeResult
414): Promise<void> {
415 try {
416 await audit({
417 repositoryId: cand.repositoryId,
418 action: "auto_merge.merged",
419 targetType: "pull_request",
420 targetId: cand.prId,
421 metadata: {
422 prNumber: cand.prNumber,
423 baseBranch: cand.baseBranch,
424 headBranch: cand.headBranch,
425 closedIssueNumbers: result.closedIssueNumbers,
426 resolvedFiles: result.resolvedFiles,
427 },
428 });
429 } catch (err) {
430 console.error("[autopilot] auto-merge: merged audit failed:", err);
431 }
432 try {
433 await db.insert(prComments).values({
434 pullRequestId: cand.prId,
435 authorId: cand.authorUserId,
436 isAiReview: true,
437 body: `${AUTO_MERGE_COMMENT_MARKER}\nAuto-merged by Gluecron autopilot — branch protection conditions satisfied.`,
438 });
439 } catch (err) {
440 console.error("[autopilot] auto-merge: comment insert failed:", err);
441 }
442}
443
444/** Default failure-path: only an audit row; no comment (we may retry). */
445async function defaultOnMergeFailed(
446 cand: SweepCandidate,
447 error: string
448): Promise<void> {
449 try {
450 await audit({
451 repositoryId: cand.repositoryId,
452 action: "auto_merge.merge_failed",
453 targetType: "pull_request",
454 targetId: cand.prId,
455 metadata: {
456 prNumber: cand.prNumber,
457 baseBranch: cand.baseBranch,
458 headBranch: cand.headBranch,
459 error,
460 },
461 });
462 } catch (err) {
463 console.error("[autopilot] auto-merge: merge_failed audit failed:", err);
464 }
465}
466
467/**
468 * Execute one sweep over recently-updated open PRs. For each, evaluate
469 * with K2's `evaluateAutoMerge`; on `merge: true`, call `performMerge` and
470 * record the merged/merge-failed audit row + comment. Always record the
471 * `auto_merge.evaluated` audit row via `recordAutoMergeAttempt`.
472 *
473 * Returns a counts summary that the autopilot prints as the tick log line.
474 * Never throws.
475 */
476export async function runAutoMergeSweep(
477 deps: AutoMergeSweepDeps = {}
478): Promise<AutoMergeSweepSummary> {
479 const findCandidates = deps.findCandidates ?? defaultFindAutoMergeCandidates;
480 const evaluate =
481 deps.evaluate ?? ((ctx) => evaluateAutoMerge(ctx, {}));
482 const merge =
483 deps.merge ??
484 (async (cand) => {
485 if (!cand.ownerUsername) {
486 return {
487 ok: false,
488 error: "owner username unresolved",
489 closedIssueNumbers: [],
490 resolvedFiles: [],
491 };
492 }
493 return performMerge({
494 pr: {
495 id: cand.prId,
496 number: cand.prNumber,
497 title: cand.prTitle,
498 body: cand.prBody,
499 baseBranch: cand.baseBranch,
500 headBranch: cand.headBranch,
501 repositoryId: cand.repositoryId,
502 authorId: cand.authorUserId,
503 state: cand.state as "open",
504 isDraft: cand.isDraft,
505 },
506 ownerName: cand.ownerUsername,
507 repoName: cand.repoName,
508 actorUserId: cand.authorUserId,
509 });
510 });
511 const recordAttempt = deps.recordAttempt ?? recordAutoMergeAttempt;
512 const onMerged = deps.onMerged ?? defaultOnMerged;
513 const onMergeFailed = deps.onMergeFailed ?? defaultOnMergeFailed;
514 const shouldShortCircuitAi =
515 deps.shouldShortCircuitAi ?? defaultShouldShortCircuitAi;
516
517 let candidates: SweepCandidate[] = [];
518 try {
519 candidates = await findCandidates(
520 AUTO_MERGE_LOOKBACK_HOURS,
521 AUTO_MERGE_MAX_PER_TICK
522 );
523 } catch (err) {
524 console.error("[autopilot] auto-merge: findCandidates threw:", err);
525 return { evaluated: 0, merged: 0, blocked: 0 };
526 }
527
528 let evaluated = 0;
529 let merged = 0;
530 let blocked = 0;
531
532 for (const cand of candidates) {
533 try {
534 evaluated += 1;
535
536 // AI-key short-circuit: if the rule requires AI approval and we have
537 // no key, treat as blocked without calling the evaluator (which would
538 // log a misleading "AI review unavailable").
539 let decision: AutoMergeDecision;
540 if (await shouldShortCircuitAi(cand)) {
541 decision = {
542 merge: false,
543 reason:
544 "Branch protection requires AI approval but ANTHROPIC_API_KEY is unset.",
545 blocking: [
546 "ANTHROPIC_API_KEY missing; AI approval cannot be sourced.",
547 ],
548 };
549 } else {
550 decision = await evaluate({
551 pullRequestId: cand.prId,
552 repositoryId: cand.repositoryId,
553 baseBranch: cand.baseBranch,
554 isDraft: cand.isDraft,
555 authorUserId: cand.authorUserId,
556 });
557 }
558
559 // Always record the evaluation, regardless of outcome.
560 try {
561 await recordAttempt(cand.repositoryId, cand.prId, decision);
562 } catch (err) {
563 console.error(
564 `[autopilot] auto-merge: recordAttempt failed for pr=${cand.prId}:`,
565 err
566 );
567 }
568
569 if (!decision.merge) {
570 blocked += 1;
571 continue;
572 }
573
574 // Perform the actual merge.
575 const result = await merge(cand);
576 if (result.ok) {
577 merged += 1;
578 await onMerged(cand, result);
579 } else {
580 blocked += 1;
581 await onMergeFailed(cand, result.error || "unknown merge error");
582 }
583 } catch (err) {
584 blocked += 1;
585 console.error(
586 `[autopilot] auto-merge: per-PR failure for pr=${cand.prId}:`,
587 err
588 );
589 }
590 }
591
592 console.log(
593 `[autopilot] auto-merge: evaluated=${evaluated} merged=${merged} blocked=${blocked}`
594 );
595
596 return { evaluated, merged, blocked };
597}
598
2b821b7Claude599/**
600 * Visits each distinct (repo, base_branch) that has queued rows and logs a
601 * stub depth line. The actual gate-running + merge happens in the pulls
602 * route; this tick is just a heartbeat so we can wire per-queue progress
603 * through without duplicating merge logic.
604 */
605async function processMergeQueues(): Promise<void> {
606 let distinct: Array<{ repositoryId: string; baseBranch: string }> = [];
607 try {
608 const rows = await db
609 .selectDistinct({
610 repositoryId: mergeQueueEntries.repositoryId,
611 baseBranch: mergeQueueEntries.baseBranch,
612 })
613 .from(mergeQueueEntries)
614 .where(sql`${mergeQueueEntries.state} IN ('queued','running')`);
615 distinct = rows;
616 } catch (err) {
617 console.error("[autopilot] merge-queue: distinct query failed:", err);
618 return;
619 }
620 for (const d of distinct) {
621 try {
622 const head = await peekHead(d.repositoryId, d.baseBranch);
623 if (head) {
624 console.log(
625 `[autopilot] merge queue depth head=${head.id.slice(0, 8)} repo=${d.repositoryId.slice(0, 8)} base=${d.baseBranch}`
626 );
627 }
628 } catch (err) {
629 console.error(
630 `[autopilot] merge-queue: peek failed for repo=${d.repositoryId}:`,
631 err
632 );
633 }
634 }
635}
636
637/**
638 * Pick a small batch of repos that actually have dep rows and re-run
639 * advisory scan against them. Cheap — one SELECT DISTINCT with LIMIT.
640 */
641async function rescanAdvisoriesBatch(limit: number): Promise<void> {
642 let repoIds: string[] = [];
643 try {
644 const rows = await db
645 .selectDistinct({ repositoryId: repoDependencies.repositoryId })
646 .from(repoDependencies)
647 .limit(limit);
648 repoIds = rows.map((r) => r.repositoryId);
649 } catch (err) {
650 console.error("[autopilot] advisory-rescan: query failed:", err);
651 return;
652 }
653 for (const id of repoIds) {
654 try {
655 await scanRepositoryForAlerts(id);
656 } catch (err) {
657 console.error(
658 `[autopilot] advisory-rescan: scan failed for repo=${id}:`,
659 err
660 );
661 }
662 }
663}
664
665/** Resolve the tick interval from env → opts → default. */
666function resolveIntervalMs(optsMs?: number): number {
667 if (typeof optsMs === "number" && optsMs > 0) return optsMs;
668 const raw = process.env.AUTOPILOT_INTERVAL_MS;
669 if (raw) {
670 const parsed = Number(raw);
671 if (Number.isFinite(parsed) && parsed > 0) return parsed;
672 }
673 return DEFAULT_INTERVAL_MS;
674}
675
676/**
677 * Start the recurring autopilot loop. No-op when AUTOPILOT_DISABLED=1.
678 * The first tick fires after `intervalMs`, not immediately, to keep boot
679 * fast. Returns a `stop()` that clears the interval.
680 */
681export function startAutopilot(opts?: StartAutopilotOpts): { stop: () => void } {
682 if (process.env.AUTOPILOT_DISABLED === "1") {
683 return { stop: () => {} };
684 }
685 const intervalMs = resolveIntervalMs(opts?.intervalMs);
686 const tasks = opts?.tasks ?? defaultTasks();
687 let running = false;
688 const handle = setInterval(() => {
689 if (running) return;
690 running = true;
691 void runAutopilotTick({ tasks, now: opts?.now })
692 .catch(() => {
693 // runAutopilotTick already never throws, but belt-and-braces.
694 })
695 .finally(() => {
696 running = false;
697 });
698 }, intervalMs);
699 return {
700 stop: () => clearInterval(handle),
701 };
702}
703
8e9f1d9Claude704/** Last tick snapshot for observability. Module-level, swap-on-complete. */
705let lastTick: AutopilotTickResult | null = null;
706let tickCount = 0;
707
708/** Return the most recent completed tick, or null if autopilot hasn't run yet. */
709export function getLastTick(): AutopilotTickResult | null {
710 return lastTick;
711}
712
713/** Return the total number of completed ticks in this process. */
714export function getTickCount(): number {
715 return tickCount;
716}
717
2b821b7Claude718/**
719 * Run one tick: invokes every sub-task with its own try/catch, records a
720 * per-task result, and emits a single summary line. Never throws.
721 */
722export async function runAutopilotTick(
723 opts?: RunTickOpts
724): Promise<AutopilotTickResult> {
725 const now = opts?.now ?? Date.now;
726 const tasks = opts?.tasks ?? defaultTasks();
727 const startedAt = new Date(now()).toISOString();
728 const results: AutopilotTaskResult[] = [];
729 for (const t of tasks) {
730 const t0 = now();
731 try {
732 await t.run();
733 results.push({ name: t.name, ok: true, durationMs: now() - t0 });
734 } catch (err) {
735 const message =
736 err instanceof Error ? err.message : String(err ?? "unknown error");
737 console.error(`[autopilot] ${t.name}: ${message}`);
738 results.push({
739 name: t.name,
740 ok: false,
741 durationMs: now() - t0,
742 error: message,
743 });
744 }
745 }
746 const finishedAt = new Date(now()).toISOString();
747 const totalMs = results.reduce((a, r) => a + r.durationMs, 0);
748 const okCount = results.filter((r) => r.ok).length;
749 console.log(
750 `[autopilot] tick ok tasks=${okCount}/${results.length} ms=${totalMs}`
751 );
8e9f1d9Claude752 const result: AutopilotTickResult = { startedAt, finishedAt, tasks: results };
753 lastTick = result;
754 tickCount += 1;
755 return result;
2b821b7Claude756}
757
758/** Exposed for unit tests. */
759export const __test = {
760 resolveIntervalMs,
761 processMergeQueues,
762 rescanAdvisoriesBatch,
763 DEFAULT_INTERVAL_MS,
764 ADVISORY_RESCAN_BATCH,
2b9055eClaude765 AUTO_MERGE_LOOKBACK_HOURS,
766 AUTO_MERGE_MAX_PER_TICK,
767 AUTO_MERGE_COMMENT_MARKER,
768 defaultFindAutoMergeCandidates,
769 defaultOnMerged,
770 defaultOnMergeFailed,
771 defaultShouldShortCircuitAi,
2b821b7Claude772};