Назад к блогу

GitHub переписал рантайм Copilot на Rust: 832 тысячи строк, 135 релизов и ИИ-агенты в роли исполнителей

GitHub переписал рантайм Copilot на Rust: 832 тысячи строк, 135 релизов и ИИ-агенты в роли исполнителей

GitHub переписала рантайм Copilot с TypeScript на Rust, сократив число строк и сделав его нативным бинарником, который можно встроить в процесс на любом из шести языков. Разбор полезен тем, кто следит за миграциями на Rust, устройством FFI-интерфейсов и за тем, как крупные продукты решают проблему лишней зависимости от Node.js и V8.

GitHub перевёл Copilot agent runtime с TypeScript на Rust. Это не смена модели ИИ — переписан слой, который используется в GitHub Copilot CLI, GitHub Copilot app, GitHub Copilot SDK, а также в свежих релизах VS Code, Visual Studio, CCA, Copilot Code Review, Copilot Cowork, Copilot Studio, Excel, Outlook, PowerPoint и Word. О работе рассказали в блоге GitHub 16 сентября 2026 года (обновление 23 сентября), на Хабре — 5 октября.

Перенос шёл с мая по август. За это время выпустили 135 версий, включая предварительные, а новый код на Rust составил 832 378 строк без учёта тестов — при том что через перенос прошло около 430 тысяч строк старого кода, хотя изначально речь шла примерно о 130 тысячах.

Что именно изменилось

Рантайм стал на 100% Rust — чистым нативным бинарником. Он предоставляет C ABI для внутрипроцессного использования и stdin/stdout- или socket-сервер для внепроцессного режима. Поставляемый runtime.node — обычная платформенная разделяемая библиотека: под расширением .node лежит .dll, .so или .dylib. У неё две «двери» к одному движку: napi, которую Node-процесс загружает как нативный аддон (это путь CLI), и C ABI-дверь, которую любой язык может загрузить в свой процесс и вызывать через FFI. Обе двери ведут к одному движку, но napi-дверь рассчитана на загрузку из Node, а C ABI-дверь — на загрузку из любого языка.

Через C ABI-дверь SDK выбирает in-process рантайм своим механизмом нативной интероперации: C# — через P/Invoke, Go — через purego, Python — через cffi, Rust — через libloading. C ABI-дверь имеет всего 19 экспортируемых функций (четыре для жизненного цикла сервера, четыре для регистрации и конфигурации сессий, восемь для соединений и три для встроенного хоста). За ними стоит общий контракт из 364 маршрута диспетчеризации: 340 вызываются потребителями SDK, 24 — обратные вызовы рантайма к SDK. Методы API не получают отдельных экспортов, а передаются как JSON-RPC байты в соединение; результаты, события и запросы сервер-клиент возвращаются через предоставленные хостом колбэки.

In-process хостинг стал «drop-in» вместо переписывания: у каждого SDK уже был работающий JSON-RPC клиент с фреймингом, корреляцией запросов и ответов и обработчиками сервер-клиент направления. Подключение FFI как ещё одного транспорта под этим клиентом переносит байтовый путь с пайпа или сокета на вызов функции, оставляя всё выше нетронутым. SDK теперь можно загружать прямо в хост-процесс на любом из шести языков без Node.js или V8 в цепочке зависимостей и без второго процесса для надзора.

Как было раньше

Рантайм изначально написали на TypeScript поверх Node.js и движка V8 — для того, что теперь называется GitHub Copilot cloud agent (CCA). Запуск требовал поднять и разместить Node и V8, разобрать значительный объём JavaScript, сгенерированного из TypeScript в CLI, и сгенерировать для него байткод. Это давало накладные расходы по памяти, связанные с V8, и навязывало модель потоков Node, при которой вся работа, ограниченная CPU, по умолчанию сериализуется.

Каждый потребитель SDK на любом языке был вынужден поставлять Node.js или собранный бинарник с V8, платя за второй языковой рантайм минимум порядка 100 МБ рабочего набора. Любое событие, сообщение и абстрагированное чтение/запись файловой системы сессии передавались через границу процесса, а падение Node уносило с собой сессию. Архитектура CLI и рантайма была переплетена, а SDK надстроили над CLI: подключение к рантайму из SDK означало порождение ещё одного процесса.

Почему это сделали

Старая архитектура возникла из-за скорости индустрии: решения принимались ради скорости поставки и охвата рынка. Из-за быстрой поставки TUI и рантайм оказались переплетены, а SDK положили поверх CLI, а не наоборот. Чтобы дать программный доступ, CLI научили работать headless и общаться по JSON-RPC, а SDK стал запускать процесс CLI — это плохо влияло на производительность (запуск, память, пропускная способность) и надёжность потребляющих приложений.

Хотели рантайм без TUI, с минимальными зависимостями, встраиваемый в процесс, пригодный для FFI всех шести SDK. Требования подчёркивали встраивание через C ABI, низкие накладные расходы на запуск и в установившемся режиме, а также предсказуемое использование ресурсов. Rust сделал эти цели достижимыми — ценой других осложнений, например необходимости явно представлять времена жизни и разделяемое состояние. Помимо технических причин названы «мягкие» — опыт команды и направление индустрии. Авторы оговаривают, что это не утверждение, будто каждая большая программа на TypeScript должна стать Rust.

Стратегия переноса: in-place porting

In-place porting означал, что каждый кусок переписывается по отдельности. Выбран был вариант atomic replacement: каждый PR заменяет существующую TypeScript-реализацию тонкой прослойкой (shim), которая вызывает Rust, и удаляет старый код одним атомарным изменением. Поэтому main всегда оставался shippable: каждый PR портирует один компонент или срез, все существующие end-to-end тесты CLI и SDK прогоняются против нового Rust-кода на каждом шаге, и если PR ломал обязательный тест, он не попадал в main.

За время переноса main выпустил 135 релизов (100 pre-release и 35 stable), в среднем ~1,3 релиза в день и ~1,3 port-PR в день. Временный внутренний interop был двусторонним: любую портированную в Rust функцию нужно было вызывать из TypeScript, а Rust-функциям нужно было вызывать TypeScript-колбэки. Вызов Rust из TypeScript реализовали через крейт napi из проекта napi-rs: функция помечается #[napi], и макрос генерирует N-API-регистрацию плюс TypeScript-декларацию в index.d.ts. Обратное направление делали через threadsafe functions napi-rs.

Каждый такой колбэк временный по построению: он существует только потому, что другая сторона ещё TypeScript, и удаляется при её портировании. Пик временного шва пришёлся на 3 августа — 2 019 внутренних N-API-экспортов и 3 356 мест вызова в TypeScript; в завершении осталось 0 и 0.

Как работали ИИ-агенты

Параллельно работали 15 дочерних сессий, созданных родительской сессией в семь волн примерно за три часа: первая волна — пять, вторая — ещё две примерно через двадцать минут, затем ещё пара через двадцать минут, дальше одиночные и пары в течение следующих двух часов. Каждая сессия — отдельная ветка со своим worktree и своим агентом. Выбор модели делался на каждый слайс: 10 из 15 работали на GPT-5.6 Sol и пять на Claude Opus 4.8, все 15 запускались в autopilot.

Стартовые промпты дочерним сессиям писал родительский агент, а не человек; медианный промпт был около 1 100 символов. Помимо 15 подсессий та же родительская сессия использовала пять субагентов: три explore, запускались параллельно с первой волной, один code-review, и один rubber-duck. Субагент работает внутри рабочего пространства родителя и возвращает ответ в его контекст, тогда как дочерняя сессия имеет собственный worktree, ветку и цикл агента.

Порт оркестрации моделей — слоя, который общается с провайдерами, — имел основную сессию длительностью 42 часа настенных часов и запустил 126 субагентов, из которых в пике одновременно работали 22. Порт extension-runtime занял 88 часов и имел иную структуру: написание и ревью значительно перекрывались, а простой откладывался на конец. Сессия entrypoints решила не считаться с мнением сессии session.ts и забрала все её изменения из worktree, объединив их в свою работу.

Позже обычная чат-сессия была превращена в планировщик сборок для восьми независимых сессий-портов, выдающий по одной аренду на сборку за раз: сессии обязывались избегать ресурсоёмких сборок и тестов и запрашивать разрешение, когда сборка необходима.

Agent merge и автоматизация inner loop

Agent merge по таймеру или в ответ на внешние стимулы (уведомления от GitHub о завершении CI, комментарий ревью) смотрит, что изменилось, и автоматизирует действия разработчика. В CLI тот же цикл доступен как /pr auto. При появлении комментария ревью агент решает, отклонить его или принять и исправить, и отвечает, отмечая, что отвечает автоматизация. При падении теста он скачивает логи, исследует сбой и исправляет баг; при конфликте — выполняет merge или rebase.

В рамках миграции агент выполнял squash в один коммит, затем rebase на последний origin/main с разрешением всех конфликтов и force push, причём rebase всегда делался самим агентом, без суб-агента. После этого запускался цикл review/fix с суб-агентами (по одному на opus 5, gpt-5.6-sol и grok 4.6), которые построчно сравнивали старый TypeScript и новый Rust, искали несовместимости, возможности упрощения и идиоматичности, требовали удаления отжившего TypeScript и проверяли, что E2E-тесты не удалены и не изменены. При обнаружении проблем их валидировали, исправляли и повторяли полный цикл ревью до чистого результата, а после каждого набора изменений коммитили и пушили, чтобы CI-валидация шла параллельно с последующими ревью.

Что это меняет на практике

Сценарий «клиент, сессия, один ход» ускорился с 5.25 с до 1.33 с (в 4.0 раза) вне процесса и до 292 мс (в 18.0 раз) при загрузке в процесс через FFI. Возобновление 32-ходовой сессии — с 5.64 с до 1.52 с (3.7x) вне процесса и до 264 мс (21.4x) в процессе. Десять параллельных жизненных циклов клиента — с 12.34 с до 4.18 с (3.0x) и 742 мс (16.6x) соответственно. Один цикл клиента в процессе занимает около 55 мс.

Основная часть накладных расходов вне процесса приходилась на запуск Node, инициализацию V8 и загрузку, разбор и генерацию байткода JavaScript — Rust-рантайм эти затраты устраняет. На нагрузке из 1000 одноходовых жизненных циклов сессий старый TypeScript CLI выполнял 7.55 цикла в секунду, Rust вне процесса — 57.45, Rust в процессе — 120.0, при этом агрегированное CPU-время упало с 312 с примерно до 110 с. Пиковый прирост резидентной памяти на десяти клиентах составил 1 383 МБ до переноса, 247 МБ для Rust вне процесса и 126 МБ для Rust в процессе.

Регрессии и unsafe-код

Регрессии делились на несколько групп. Парные операции рассинхронизировались: проверка лимита ходов обновляла состояние прерывания в нативном реестре, но не отменяла внутрипроцессный цикл модели, позволяя уйти ещё одному запросу; завершение задачи сохранялось и публиковалось, но не проецировалось в состояние активной сессии, поэтому Autopilot продолжал работу после завершения задачи. Замены библиотек давали разное поведение: Rust MCP SDK (rmcp) отвечал на некорректный JSON-RPC, а TypeScript SDK — нет, и против сервера, отвечавшего ошибками ещё более некорректным выводом, это превращалось в бесконечный цикл, вешавший запуск.

Часть регрессий возникала из-за дрейфа веток и ребейзов: сессия, открытая на дни, накапливала изменения и конфликты, порт требовал тысяч ребейзов, и даже очень высокий процент успеха оставлял сбои. Были и «медленные» регрессии: терялась мемоизация, полностью асинхронное ожидание или ограниченная потоковая передача логов, добавлялись накладные расходы на границе Rust-TypeScript — избыточная сериализация, блокировки, опрос, неограниченная нативная конкурентность и переходы между нативным и хост-кодом. К 14 сентября 2026 года проследили десятки известных регрессий порта, все исправлены. Исправляли точечно: например, экспорт /chronicle reindex сделали async и перенесли работу в поток блокирующего пула, а также ввели постоянное правило, что napi-экспорты, делающие реальную работу, должны быть асинхронными и при необходимости использовать spawn_blocking.

В рантайме насчитывается 158 блоков unsafe в 36 файлах, вместе с ними 26 объявлений unsafe fn, 26 блоков unsafe extern и девять реализаций трейтов unsafe impl. Все они связаны исключительно с взаимодействием с внешними компонентами: граница C ABI (51 блок), Windows API (49), POSIX/libc (46), SQLite C API (7), динамическая загрузка библиотек (4) и окружение процесса (1). Эти блоки отмечают места, где гарантии Rust действительно заканчиваются: по ту сторону находится C-функция, системный вызов, указатель из чужого рантайма или глобальное состояние процесса. unsafe не используется в клиентах моделей, слое MCP, слое агентов и слое промптов, и ни одна из известных регрессий при портировании не была связана с блоком unsafe.

Цифры

По логам сессий зафиксировано 12 760 995 событий, 31 247 пользовательских сообщений (из них примерно 2 600 набраны или произнесены лично автором, около одного из 12) и 1 385 214 сообщений ассистента. Дочерние сессии затронули 140 различных файлов, 120 из которых — ровно одна сессия; родитель опросил их состояние 60 раз и отправил 89 координационных сообщений.

Расход токенов составил ~136,3 млрд всего, включая ~130,6 млрд кэшированных входных чтений, ~4,2 млрд кэшированных входных записей, ~900 млн свежих входных и ~600 млн выходных токенов. Денежный счёт — ~$120 000.

Ограничения и открытые вопросы

Авторы признают, что цель должна быть сформулирована чётко и полно: ранние инструкции были слишком расплывчатыми, из-за чего агенты считали ввод-вывод и оркестрацию вне области работ. Сквозные тесты критичны, и их собственный план требовал значительно улучшить тестовую базу до начала переноса, но этого сделали недостаточно. Также признаётся необходимость защищать «оракул» от агента: нельзя позволять агенту ослаблять тесты, обновлять снимки или применять метку-исключение без надзора.

Автор сожалеет об отступлениях от принципа «сначала перевод, потом редизайн» — каждое такое отступление стоило больше регрессий, времени или токенов. Остаются открытыми вопросы дальнейшего улучшения сборки и внутреннего цикла разработчика, очистки переведённых структур, редизайна под владение и конкурентность Rust и дальнейших оптимизаций производительности. Автор прямо допускает, что обнаружатся новые ошибки: весь набор редких ситуаций заранее не перебрали.

Источники

Похожее