Друзья, привет!
Я пишу язык Nova с ИИ-агентами пять с половиной месяцев. Nova — это Go/Rust-подобный язык для бэкенда и серверных программ, где функция в типе объявляет прямым текстом, чем занимается сама. Например, работает с сетью, часами, файловой системой и т. п. В Nova это называется эффектами (идея: Koka, Eff, OCaml 5). Эффекты могут быть подменены в рантайме и направлять программу по другому пути исполнения. Поэтому Nova не требует движков для моков и систем тестирования, просто реализация подменяется на тестовую через эффекты, а сама программа остаётся неизменной. Я пробую довести систему эффектов до прикладного состояния. У языка, на текущий момент, два компилятора: Nova Oracle (на Rust) и Nova Carina (на самой Nova). Спецификация языка записана как журнал проектных решений, каждое с обоснованием: их больше 300. Ещё больше десятка документов описывают соглашения по разработке.
Начну с эпизода, после которого Oracle (почему такое название, скажу в статье), первый компилятор Nova, зашёл в тупик. Летом мы с агентами «чинили» определение типов выражений. Агенты со временем завели несколько параллельных реализаций одного и того же, и тип выражения вычислялся дважды: в проверке типов (дальше чекер) и ещё раз в генераторе кода, как запасная ветка (дальше запасной вывод). С 11 июля мы с агентами месяц сверяли эти два вывода в попытке свести всё к одной «двери», то есть к одной реализации: запасной планировал удалить, как только всё переедет в чекер и будет работать идентично. Совпадало почти всё, но запасного вывода меньше не становилось. Тесты проходили, отчёты были зелёными, а я принимал сверку за прогресс, и месяц на самом деле стоял на месте по этой теме. Одна из основных проблем в работе текущих агентов: контролируемо заставлять их выполнять наши задания.
Так можно ли разработать и написать новый язык с ИИ? Да, до альфы и самосборки уже доказано: второй компилятор, Nova Carina, всё же собрал себя сам из своих исходников! Для меня это значительное достижение. Что же касается стабильного и мощного компилятора на выходе, тут работа и выводы ещё впереди. Первую копию Nova из исходников на Nova собрал компилятор Oracle. Затем получившийся компилятор собрал себя сам из своих же исходников, вот и получилась самосборка.
Nova Oracle получился переходной альфой: он довольно неплохо реализует спецификацию языка Nova, он компилирует всю стандартную библиотеку, подключаемые модули, примеры и реальные приложения. Также на нем собирается Carina. Сейчас в Oracle я чиню только то, что мешает полному релизу Carina, т.к. сейчас она умеет делать проверку синтаксиса, типов и генерацию C. Oracle тоже на выходе получает исходники на языке C, которые компилируются (clang и т. п.) в исполняемую программу.
Дальше — как это было по шагам. В конце — семь приёмов проверки работы агентов, которые я из этого вывел, и как попробовать Nova самому.

Кто есть кто и как я работаю с агентами
Oracle и Carina. Oracle — переходная альфа, сборщик Carina и эталон (образец ответов, с которым сверяется Carina). Carina — второй компилятор, написанный заново на самой Nova: это не перевод кода Oracle с Rust, а новая архитектура. Я начал делать Carina 14 августа. 11 октября 2026 года вышли альфа-выпуски Oracle 0.1.0 и Carina 0.2.0.
Агенты и приёмка. Nova я пишу на личных аккаунтах: в нескольких параллельных сессиях работают агенты, одна из них, агент-интегратор, сводит работу остальных. Я определяю, что считать выполненным заданием, и выборочно читаю код. Правила приёмки собираю в публичную методологию (тексты под CC BY 4.0, скрипты под Apache-2.0 или MIT).
Масштаб и цена. С мая (пять месяцев) около 19 тысяч коммитов, больше 300 тысяч строк на Rust в Oracle (основная часть — компилятор), около 64 тысяч строк на Nova в исходниках Carina и около 16 тысяч в её тестовых фикстурах. Стандартная библиотека
stdна Nova примерно того же объёма, что и сама Carina: около 65 тысяч строк (из них около 17,5 тысячи тестов). Восемь опубликованных пакетов (HTTP, TLS, Polaris, сжатие, DuckDB, PostgreSQL, числа произвольной точности, SOCKS): ещё около 43 тысяч строк по последним тегам. Всего на Nova около 190 тысяч строк вместе с тестами. Около 160 тысяч рублей на подписки за всё время (порядка 30 тысяч в месяц); почти всё время основным агентом был Claude Code, в нескольких параллельных сессиях над непересекающимися задачами. Аккаунт Claude Code у меня один, и это сильно ограничивает скорость: с несколькими аккаунтами можно делать гораздо больше или быстрее. Но проект я делаю на энтузиазме, поэтому работаю со своего одного аккаунта. Недавно подключил ещё Codex и Kimi Code, они тоже хорошо справляются. Своё время не считал.
Про имена. Carina — это киль корабля Арго, «Nova Carinae» — обозначение новых звёзд в созвездии Киля (почему выбрано именно это имя, записано в соглашении об именах). Имя Oracle придумали агенты, я его заметил в их переписках. Они назвали базовый компилятор Оракулом, потому что ходили к нему для сверки результатов Carina. Мне это показалось довольно интересным, и я решил оставить имя Oracle.
Как выглядит Nova и зачем она
Nova довольно похожа на «Go с эффектами»: то, что функция сама делает с внешним миром, видно в её типе. Часть эффектов заложена в stdlib (например, Net, Time), пользователь вправе добавить свои эффекты (например, Counter, Emit). Nova компилируется в C, а затем в нативный бинарник, без виртуальной машины; сборщик мусора есть. Назначение Nova: бэкенд, серверные программы; HTTP-фреймворк Polaris для неё уже написан (ниже).
Вот сигнатура публичной функции демонстрационного агрегатора, из aggregate.nv (строка 113; в { … } тело опущено):
![export fn aggregate(sources []Source, budget Duration) Net Time Emit -> Report { … }: части сигнатуры — публичная функция, имя, параметры, эффекты Net Time Emit, результат Report, тело опущено](https://habrastorage.org/r/w780/webt/79/96/af/7996af73325b2a57f186e627da0b898c.png)
Текст для копирования
export fn aggregate(sources []Source, budget Duration) Net Time Emit -> Report { … }
export делает функцию публичной, []Source обозначает массив, Net Time Emit между параметрами и стрелкой задаёт эффекты (Emit — собственный эффект агрегатора: события прогресса для живого интерфейса, например «запрос начался», «завершился», «отменён»). Публичная функция обязана объявить эффекты, к которым обращается сама: если она обращается к часам, а Time в типе нет, это ошибка компиляции.
Как в Nova устроены конкурентность, эффекты и ресурсы, показано в конце, в разделе «Как попробовать Nova».
68 тысяч строк в одном файле: как возник запасной вывод типов
Oracle рос от задания к заданию: агент пишет план, код и тесты, я проверяю результат. Архитектура компилятора, написанная агентами, получилась — ну, скажем, не получилась (такое бывает и без ИИ; скажу наперёд: заставить агентов писать по чётко выверенной архитектуре — та ещё задачка), и к выпуску генератор C-кода (часть, которая переводит программу в C) стал одним файлом порядка 70 тысяч строк. В Nova я заложил мощную поддержку дженериков и на расчёт мономорфизации типов уходит очень много кода.
Пришлось выяснять, почему компилятор ошибается в похожих местах. Тип определял чекер и записывал его в таблицу. Генератор C читал таблицу, а если записи не было, определял тип ещё раз, своей логикой. Это и есть запасной вывод.

Каждая такая правка по отдельности выглядела разумно. Запасной вывод разросся до нескольких тысяч строк в двух функциях, и на него опирались сотни мест. Когда оба вывода расходились, компилятор мог молча вызвать метод не того типа: программа собиралась, ошибки не было, а работала неправильно.
Так появился план «Одна правда»: дополнить таблицу чекера и удалить запасной вывод из генератора.
Я принимал отчёты о прогрессе, а запасной вывод только рос
Чекер дополняли новой логикой (расширяли таблицу типов). Она сначала работает в тени: считает то же самое, сверяет свой ответ с запасным выводом и выбрасывает его, а реально работает по-прежнему запасной вывод. Когда ответы совпадают везде, запасной вывод удаляют. Так мы с агентами работали около месяца: план создан 11 июля, а Carina я начал 14 августа (≈34 дня).
Месяц я принимал совпадение ответов за движение к цели, а это была только проверка, что новый код ничего не ломает.
На лёгких случаях новая логика справлялась, а на сложных, с обобщёнными типами, отвечала «не знаю». Это не считалось расхождением, поэтому сверка показывала совпадение почти везде, а сложные случаи по-прежнему решал запасной вывод. Счётчика удалённого кода не было.
Потом мы с агентами это признали и вернулись к прямой работе: дополнять таблицу и удалять ветви запасного вывода по одной. Появился счётчик: каждый этап обязан уменьшать запасной вывод. Он мерил одну из двух функций, infer_call_ret_c. Она и правда уменьшилась, но вторая, infer_expr_c_type, за то же время выросла (первый замер в таблице от 12 июля):
12 июля |
выпуск |
|
|---|---|---|
|
≈2590 |
≈2380 |
|
≈2340 |
≈2820 |
вместе |
≈4,9 тысячи |
≈5,2 тысячи |
вызовов |
229 |
242 |
Сумма почти не изменилась: ≈4,9 → ≈5,2 тысячи строк, в реальности увеличилась (считал по git: коммит b7a45bf от 12 июля и ревизия выпуска eee6eae; строки между фигурными скобками функций).

Счётчик смотрел только на одну из двух функций, поэтому прогресса не показывал. Переделку мы с агентами так и не довели, поэтому тихие ошибки этого класса в Oracle возможны, и это одна из причин, почему он альфа. Тесты показывали, не изменилось ли поведение, но сколько запасного вывода удалось убрать, сказать не могли.
До релиза оставалось 36 дефектов-блокеров. Потом 62
Блокер: известный дефект, из-за которого я не выпускал Oracle 0.1.0, пока он не исправлен. Я вёл их по плану выхода на релиз. Замеры по неделям до паузы 9 сентября (число блокеров считает скрипт проекта по реестру дефектов):
67 → 36 → 44 → 62.

За первую неделю блокеров стало почти вдвое меньше: это настоящие исправления, и в ту неделю закрыли больше, чем нашли. Затем дефекты стали находить быстрее, чем закрывать. Например, 8–9 сентября нашлось, что компилятор молча терял часть настроек проекта в некоторых ситуациях. Такой класс ошибок ловится очень сложно. Найденные блокеры в основном были старые, но мы с агентами стали их находить пачками только сейчас, десятки блокеров могли быть закрыты в день, а ещё большее количество прибавлялось среди найденных, и расстояние до релиза в таких условиях, конечно, только увеличивалось.
За три недели до паузы в реестр добавилось 333 записи, а закрыто 182. В реестре дефектов все найденные дефекты любой важности, закрытые тоже; к октябрю там набралось 1789 записей. Блокеры — лишь небольшая часть реестра, поэтому числа 62 и 1789 сравнивать нельзя.
Вторую попытку я начал с правил, которые проверяет машина
Решение о Carina я принял в проектной сессии 13 августа (план 274, добавлен 13 августа, коммит 0d1505c), работа началась 14 августа. Как я сказал ранее, дефекты находились быстрее, чем исправлялись, и причина была в дизайне компилятора Oracle. Поэтому я сразу поставил цель: новый компилятор должен был делаться по продуманной архитектуре, быть стабильным и соответствовать спецификации языка. Вторая цель: быть максимально быстрым, о чем я уточнил запись 11 сентября. Уровня Go, вероятно, не достичь: несравнимы масштабы разработки, также дженерики усложняют компилятор и увеличивают время компиляции значительно (запись реестра №640: семь строк исходника дают 285 КБ C и 444 тела функций из-за полной мономорфизации). Это цель, а не обещание. Починить всё значило бы переписать всё, то есть сделать новый компилятор на Rust? По моим тогдашним словам, воронка не сходилась, положительной динамики не было. Запасной вывод к тому времени уже несколько раз латался точечно (план 196.7 закрыт 15 июля неполно; запись №696 от 16 августа). Всё это вместе привело меня к мысли перестать латать то, что нельзя залатать полностью: 16 августа в плане 196 я запретил дальнейшие точечные заплатки. Я решил написать компилятор заново, сразу на Nova. Это серьёзный вызов и заодно возможность допилить Nova сразу на большом проекте, где можно проверить ее жизнестойкость.
Первый месяц работы над Carina я отвёл на то, чтобы понять перспективность идеи: в плане 274 день 30 назван точкой переоценки. Выпуск Oracle я ещё почти месяц пытался стабилизировать параллельно с разработкой Carina. 9 сентября я поставил его выпуск на паузу, 15 сентября решил, что релизиться будет сразу Carina. Перед выпуском я пересмотрел это: Oracle вышел альфой, без обещания стабильности, потому что только он пока собирает Carina.
Архитектуру с первого дня мы с агентами разбирали по образцам других компиляторов: в плане они названы донорами. От rustc взяли вывод типов внутри тела функции и обход экземпляров дженериков, от Go сопоставление типов при вызове дженерика и генерацию экземпляров (плюс ранний компилятор Go на C как образец некоторых решений), от Roslyn дерево разбора без потерь, от Zig и Carbon индексы узлов дерева. Записывать тип выражения разрешено только чекеру, остальные части читают его ответ. CI (автоматические проверки на сервере при каждой сборке) ищет вызовы функций записи и вывода типа за пределами чекера, по именам функций вывода типа. Допустимые зависимости между модулями записаны в файле правил, его тоже проверяет CI. В Oracle их ловил я глазами и, конечно, неуспешно. Для себя я пока вывел так: правило, которое просто записано, в большом проекте агенты рано или поздно нарушат. Держится только то, что машина проверяет при каждой сборке: архитектура, регламенты, соглашения. Как этого достичь — тема для отдельного обсуждения.

Подробнее: документ архитектуры Carina с таблицей слоёв и доноров.
Тест проходил, потому что проверял только целые
С 30 августа в проекте работают охотники за дефектами: это отдельные агенты, которым назначают модуль и класс дефектов. Охотник ищет пробами, ничего не чинит и сдаёт отчёт, а находки заносятся в реестр. К релизу Oracle охотники нашли 202 записи реестра (определение агента).
5 сентября охотник проверял генератор C у Carina (отчёт) и среди прочего отметил, что генератор молча превращает в целое всё, что не строка (самовольное решение агента), а тест при этом зелёный, т.к. он проверял только целые. Это очень частая механика работы агентов: найти дыру в подходе и протаскивать через неё всё, что надо и не надо.
Проверка типов пускала в строковую интерполяцию "${expr}" только строки и целые, хотя я изначально настойчиво просил агентов делать без упрощений, т.к. натыкался на такое уже миллион раз, а исправлять упрощения потом — отдельная головная боль, учитывая их количество в большом проекте и неполную фиксацию (в этом агенты тоже могут халтурить). Но агенты нашли, что тесты проверяли только целые типы и начали лепить целые везде, даже там где типы целыми не являлись. По спецификации подставлять можно и дробные, и логические значения, и Option, и любой тип, превращаемый в строку. Это латентное расхождение со спецификацией: агент сузил реализацию до удобного решения. Прочитав отчёт, я спросил то, что стоило спросить сразу:
а такое сужение вообще допустимо по спецификации и нашим договорённостям?

После этого я закрепил правило: делать строго по спецификации. Если что-то пока не сделано, компилятор должен прямо сказать «пока не поддерживается», а пробел должен быть записан в реестр или план. Молча сделать проще, чем требует спецификация, нельзя: такая задача не считается выполненной, тем более что тесты этого не поймают.
Эталон тоже ошибается: 25 обходов багов Oracle в исходниках Carina (середина сентября)
При написании нового компилятора удобно подавать ему ту же программу, что и для Oracle, и сравнивать результат. Так нашлись новые дефекты Oracle. Перенести дефекты в новый компилятор значило бы перенести ошибки, поэтому в спорных случаях решала спецификация языка Nova.
Баг Oracle иногда приходится обходить в исходниках самой Carina, иначе инструмент, которым её собирают, не даёт её собрать. Такой учёт обходов в плане назван «налогом оракула». К 16 сентября в исходниках Carina стояло 25 обходов шести багов Oracle в 18 файлах (пометки LEGACY-#N со ссылкой на запись реестра). Обход делался другой конструкцией языка, которая компилировалась правильно. К выпуску Carina все шесть багов исправили в самом Oracle, и обходы убрали: последние сняты 30 сентября.
Такие же пометки стоят ещё у двух записей, но это не баги Oracle: №700 — долг самой Carina (на ошибке в программе она пока может упасть внутренней ошибкой вместо понятного сообщения), №708 — недоделка в рантайме языка: по спецификации обработчик сбоя должен получать запись об ошибке с полями, но это ещё не реализовано, поэтому Carina пока разбирает текст сообщения об ошибке. Они остались и в выпуске.

«CI зелёный, кроме известного красного»: за красным шагом ничего не запускалось
20 сентября агент-интегратор стал разбирать, что именно исполняется на CI, и обнаружил, что модульные тесты компилятора не запускаются. Перед ними падал шаг проверки соглашений проекта, и всё после него пропускалось. Этот красный шаг и я, и агенты считали известной проблемой и не трогали, а снаружи пропущенные тесты выглядели так же, как пройденные. Не запускались они минимум с 16 сентября. Я рассчитывал на защиту, которой, как оказалось, не было. Почему спохватились только 20 сентября? Аккаунт Claude Code у меня один, и его лимита хватает только на главные задачи: я тратил его на разработку Carina и выпуск. На отдельного агента, который занялся бы самим CI, лимита уже не оставалось, как и на многие другие вопросы в разработке Nova.

20 сентября красный шаг перестал падать, и CI наконец запустил модульные тесты Carina (прогон): они прошли, 27 файлов из 27.

В те же дни Carina начала проверять собственные исходники командой check (проверка без генерации кода) по модулю целиком, а не по файлу. Число диагностик упало:
1216 → 332 (к 20 сентября).
Разница в 884 это не исправленные баги. Раньше каждый файл проверялся отдельно и не знал, что объявлено в соседних файлах, и большая часть сообщений была ложной тревогой из-за этого. Когда проверку стали запускать на весь модуль, такие сообщения пропали сами.

Ещё одна проверка вела себя по-разному в зависимости от окружения: 3 октября всплыло, что на CRLF один файл переставал разбираться, а другой, с неподдерживаемой конструкцией, принимался: результат проверки зависел от переводов строк. Исправили в тот же день, в лексере.
Carina собрала себя дважды, и результаты совпали байт в байт
Carina 0.2.0 на Windows x86_64 собрала себя дважды: и C-код, и исполняемые файлы совпали байт в байт (при детерминированной линковке, C-код компилировался в бинарник отдельным скриптом, т.к. в Carina пока не реализована команда build). Это самосборка в базовом виде. Самосборку зафиксировали вечером 10 октября 2026: наблюдавшийся мной прогон начался около 19:35 UTC, побайтное совпадение записано в журнале приёмки. Отдельный CI на коммите 99756a8 в тот же вечер завершился успешно, но побайтное совпадение он сам по себе не доказывает. От начала работы 14 августа прошло меньше двух месяцев.
После истории с пропущенными тестами мне нужен был ответ на вопрос: как узнать, что не врёт сама проверка? Сравнение проверили в обратную сторону:
один изменённый байт в сгенерированном C или в исполняемом файле должен давать «не совпадает» с номером байта.
В копии файла изменили один байт, и сравнение показало «не совпадает» ровно на этом байте. После восстановления байта оно снова показало «совпадает».
Как устроена цепочка. A — Carina, собранная Oracle. A переводит исходники Carina в код на C, clang собирает из него исполняемый файл B. Затем B делает то же с теми же исходниками и получает B′.
Oracle → A A (исходники Carina) → C-код₁ → clang → B B (исходники Carina) → C-код₂ → clang → B′ проверка: C-код₁ = C-код₂ и B = B′, байт в байт
A — первое поколение (собрано Oracle), B — второе, B′ — третье. A ≠ B допустимо: должны совпасть C-код₁ с C-кодом₂ и B с B′. (В журнале приёмки, где записаны проверки самосборки, приложенной к релизу Carina 0.2.0, третья копия B′ называется C.)

Детерминированная линковка нужна потому, что линкер по умолчанию вписывает в исполняемый файл время сборки и имя файла: иначе файлы нельзя сравнивать. Поэтому она включена, а B и B′ линкуются под одним именем в разных каталогах.
Сам C-код у Carina, собранной Oracle, и у Carina, собранной самой Carina, получается один и тот же из одних исходников (кроме общего снимка Oracle).
Корректность языка совпадение копий не доказывает. Есть три оговорки:
Carina строилась не на пустом месте. Спецификация, стандартная библиотека и рантайм уже были, а Oracle служил и сборщиком, и эталоном. Исходники Carina написаны на понятном ей подмножестве; из примеров проекта (118 файлов) она на 10 октября принимает 66, и в 61 из них поведение совпало с собранным Oracle (по логу CI на коммите Carina); ещё 43 она пока не умеет принять. Одинаковая ошибка может остаться в обеих копиях: для семантики нужны свои тесты.
Около 12% C — готовый снимок Oracle. Это ≈1,5 из 13,25 МБ: служебные таблицы и используемые части стандартной библиотеки, заранее сгенерированные Oracle. Совпадение B и B′ относится к коду программы плюс к этому общему снимку. Компилировать стандартную библиотеку самой Carina ещё предстоит.
Первую копию собрала внутренняя сборка Oracle, а не опубликованный архив. Журнал приёмки (релиз Carina 0.2.0, там же сохранённый B) записал только хеш этой сборки. Поэтому цепочку отдельно повторили с опубликованным архивом Oracle 0.1.0: A собрана его релизным
nova.exe, B и B′ (в расписке второе называется C) совпали побайтно (3 874 304 байта), как и C-файлы (13 250 302 байта). Это замещающая расписка релиза; числа в ней другие, чем в первоначальной приёмке, потому что это отдельный прогон. Первоначальная приёмка по-прежнему шла на внутренней сборке.
Carina пока умеет только check и emit (проверка и генерация C), Linux-бинарника в 0.2.0 нет, а сборка всех пакетов и примеров входит в критерии выпуска 0.3. Исходники Carina открыты. Цель 0.3 — чтобы Carina сама собирала и запускала все примеры и пакеты, а проверки и CI проходили на одном коммите. Если Carina расходится с Oracle, сверяемся со спецификацией Nova.
Семь попыток агента и порог приёмки «30 из 30»
Отступление в май: то же «почти работает» случилось раньше. 31 мая и 1 июня агент семь раз подряд пытался исправить гонку в рантайме с планировщиком файберов (лёгких потоков, как горутины в Go; около 10 часов работы агента в шести сессиях; debugging-races.md и план 83.11, §12.13–12.26; в таблице попытки пронумерованы мной по порядку). Каждая давала «почти работает». Воспроизводилось так: в блоке с токеном отмены (объект, которым останавливают группу заданий) запущенное внутри задание отменяет этот же токен, и программа иногда падает. Сколько прогонов стресс-теста из 30 проходило успешно:
№ |
Что пробовали |
Успешных прогонов из 30 |
|---|---|---|
1 |
поменять порядок двух записей в токене отмены: сначала причина, потом пометка «отменено» |
4 |
2 |
закрепить токен отмены в памяти, чтобы сборщик мусора его не убрал |
1 |
3 |
обойти проблему прямо в генерируемом коде, для отмены без причины |
25 (но причина отмены возвращалась неверно: поведение сломано) |
4 |
четыре способа по-другому хранить причину отмены (строку) в памяти |
7, 25, 5, 5 |
5 |
отключать сборщик мусора на время отмены |
19 (+4 теста упали по таймауту в полном наборе) |
6 |
регистрировать у сборщика мусора стек каждого файбера |
21 (+726 тестов по таймауту) |
7 |
сообщать сборщику мусора о стеке работающего файбера |
13 |
Порог приёмки жёсткий: стресс-тест обязан пройти 30 из 30, а все семь попыток его не прошли и были откатаны. Восьмой попытки не понадобилось, помогла диагностика. На Windows внутри процесса поставили перехватчик, который при падении по памяти печатает стек вызовов, и причина стала видна с первого запуска: очередь на стеке уже освобождена (use-after-free). Исправление (коммит bf1b45a) заняло около 30 минут после 10 часов безуспешных попыток, и стресс-тест прошёл 30 из 30. Это единственный известный мне случай, где порог приёмки сыграл, и он из рантайма, не из компилятора, так что вывод опирается на один случай.

Так можно ли?
До альфы и самосборки — да. За пять месяцев (цена и время указаны выше) получились альфа языка со стандартной библиотекой, на которой собираются небольшие программы (пакеты есть отдельно и в приёмку выпуска не входили; у Oracle известны тихие ошибки генерации), и второй компилятор на самой Nova, прошедший самосборку. До стабильного компилятора — пока не знаю: первая попытка упёрлась в архитектуру, вторая, Carina, дошла до альфы и самосборки, но путь к стабильной версии у неё ещё впереди.
Ошибались обе стороны. Агент подменял задание или выдавал «почти работает» за готовое. Я принимал зелёные отчёты за прогресс. Из этих эпизодов я вывел семь приёмов:
Мерить удаление запасного вывода, а не отчёты о прогрессе (месяц сверки: по отчётам прогресс шёл, а запасной вывод вырос с ≈4,9 до ≈5,2 тысячи строк).
Считать расстояние до цели, а не число закрытых дефектов (блокеры: 67 → 36 → 44 → 62, находили быстрее, чем закрывали).
Сверять реализацию со спецификацией, даже когда тест зелёный (интерполяция «интерполяция в строке ${expr}»: генератор молча делал целое из всего, что не строка).
Проверять, что на CI тесты вообще исполняются («CI зелёный, кроме известного красного»: модульные тесты не запускались минимум с 16 сентября из-за красного шага перед ними).
Проверять саму проверку (самосборка: испорченный байт должен давать «не совпадает» с номером байта).
Ставить жёсткий порог приёмки, который не пускает «почти работает» (семь попыток исправить гонку в рантайме, каждая «почти работает»; порог опирается на один случай из рантайма).
При красном пороге сначала диагностика, новые попытки потом (после семи попыток причину показала диагностика: около 30 минут против около 10 часов).
Часть этого случилась бы и с людьми. С агентами, по-моему, добавляется своё: кода становится больше, чем я успеваю прочитать, и отчёты зеленеют даже без прогресса. Поломки мои проверки ловили, а подмену задания замечал только человек или другой агент. Поэтому к вопросу «прошли ли тесты?» я добавил два: что именно они проверили и какое изменение заставит их упасть. Ответ на них должен быть повторяемой проверкой: счётчик удалённого кода (он считает обе функции и число вызовов) и намеренная порча файла, которую сравнение обязано поймать.
Если вы ведёте с агентами большой проект: случалось ли, что задание агента выполнено для галочки, тесты зелёные, а нужного результата нет? Какую проверку вы добавили после этого?
## Как попробовать Nova
Здесь: как запустить Nova самому, на что обратить внимание, короткий тур по языку и ссылки.
Оба компилятора переводят Nova в C, а бинарник собирает C-компилятор. Carina пока только проверяет и генерирует C, поэтому пробовать язык стоит на Oracle: архивы для Windows и Linux x86_64, CLI nova с check, build, test, линтером и языковым сервером.
Собрать первую программу
Есть стандартная библиотека и отдельные пакеты в организации nv-lang (HTTP, TLS, Polaris, сжатие, DuckDB, PostgreSQL и другие). В архивы Oracle пакеты не входят и приёмку выпуска не проходили. Полный демонстрационный агрегатор работает на Polaris, веб-фреймворке для Nova.
Скачайте nova-oracle-0.1.0-windows-x86_64.zip со страницы Oracle и сверьте с SHA256SUMS того же выпуска. Нужны MSVC Build Tools (SDK и линкер; они требуются и для clang). Если установлен clang (winget install LLVM.LLVM), nova выберет его первым. Распакуйте архив и в каталоге с nova.exe откройте PowerShell:
. .\setup-env.ps1 nova --version
Точка в начале команды (dot-sourcing) оставляет переменные окружения в текущей сессии. В отдельной папке создайте nova.toml ([package], name = "hello", version = "0.1.0") и hello.nv; имя модуля должно совпадать с именем пакета:

Текст для копирования
module hello fn main() { println("Hello, Nova!") }
В той же сессии, из папки проекта, выполните nova build hello.nv и .\hello.exe. Ожидаемый вывод: Hello, Nova!.
Эти шаги на Windows автоматически проверены в CI на собранном zip (прогон 38131440529: распаковка, nova build hello.nv, запуск hello.exe печатает «Hello, Nova!»). Это раннер GitHub, а не чистая пользовательская машина. Все примеры кода из статьи я проверил на этом же архиве. Для Linux есть архив nova-oracle-0.1.0-linux-x86_64.tar.gz, установка описана в README-INSTALL.md.
Как я пишу на Nova с агентом: открываю клонированный репозиторий и говорю «прочитай проект», после этого агент уже умеет писать на Nova. Это на моих агентах, и качество у разных агентов разное, но даже бесплатный агент OpenCode с моделью Big Pickle пишет на Nova: ядро BigInt в пакете nova-bignum (целые произвольной точности: знак и массив 32-битных слов, сложение, вычитание, сравнение; 302 строки модуля и около 200 строк тестов) он написал в первом коммите пакета 29 июля, и тесты прошли. Из семи бесплатных моделей, которые я пробовал на задачах Nova, пригодными оказались две; результаты отбора записаны в runbook.
Что стоит знать до первого эксперимента
Оба выпуска альфа: язык и API могут меняться. Бинарники Carina есть только для Windows x86_64.
Oracle может тихо сгенерировать неверный код (см. про запасной вывод): проверяйте поведение тестами.
Известен отказ: строковый литерал, переданный в
[]u8-параметр перегруженной функции, не собирается (запись №1594 реестра); обход: явный.bytes(). Отдельная запись №1850: модульstd/src/osне собирается под MSVC (ошибка C2099).Гонки данных проверяются не полностью (см. «Ресурсы, гонки данных, дженерики» ниже).
Контракты
requires/ensuresпроверяются во время выполнения. Статическое доказательство включается флагом--verify, но в готовых архивах оно справляется только с простыми случаями (очевидные тавтологии, вычисление констант). Полноценный решатель Z3 в Oracle есть, но подключается при сборке из исходников (cargo build --release --features z3-backend) и в архивы не входит.Контракты проверяются и в релизной сборке: на опубликованном архиве программа с нарушенным
requiresв режимеreleaseтоже завершается с ошибкой (код 101). По спецификации (D421) вырезаются только контракты с пометкой#debug.
В продакшен это нести рано, а для небольшого эксперимента, с проверкой поведения тестами, уже хватает.
Немного о языке
Здесь только то, что нужно для примеров статьи; синтаксис целиком описан в спецификации на сайте.
Конкурентность: запросы с дедлайном
Конкурентность структурная: параллельные задания запускаются внутри блока, и блок не завершается, пока все они не закончили работу или не были отменены, так что в фоне после выхода из блока ничего не остаётся. Конструкции async/await отсутствуют, т.к. это красит всё дерево вызовов сразу в Async. Вместо этого Nova построена на модели M:N: как в Go, есть файберы (лёгкие потоки, как горутины), M штук, а планировщик раскладывает их по N системным потокам и при необходимости переносит работу между потоками. Ввод-вывод у каждого потока идёт через свой цикл событий libuv. Функции при этом не делятся на синхронные и асинхронные.
Четыре строки из examples/mini_aggregator.nv, строки 49–52:
![Код Nova: fn fan_out(latencies []int, deadline Monotonic) Time -> []str => parallel for ms in latencies { probe(ms, deadline) }](https://habrastorage.org/r/w780/webt/75/f0/cb/75f0cb017a2ea070df3eaad9de4b1542.png)
Текст для копирования
fn fan_out(latencies []int, deadline Monotonic) Time -> []str => parallel for ms in latencies { probe(ms, deadline) }
parallel for запускает итерации одновременно в отдельных файберах и собирает результаты, здесь в []str; отмена по дедлайну встроена в сам язык. Типы пишутся после имён, как в Go; ->: тип результата, =>: тело одним выражением; другая форма тела: блок { … }, и значение блока равно его последнему выражению (как a + b + c ниже). Time между ) и -> — эффект (часы), как Net Time Emit в сигнатуре aggregate в начале статьи; Monotonic в скобках — обычный тип: монотонные часы, deadline — момент отмены. Future и await не нужны: вместо async используется модель M:N.
Опоздавшие запросы отменяются по дедлайну (структурная конкурентность, как в Kotlin и Trio). Сама отмена в probe, из того же файла, строки 30–37 (выше в функции объявлены timed_out, атомарный флаг, и канал tx/rx):
![Код Nova: обработчик with Fail[TimeoutError] и supervised(deadline: deadline) со вложенным spawn](https://habrastorage.org/r/w780/webt/45/70/25/45702536458b27ec15488eec8d0667ac.png)
Текст для копирования
with Fail[TimeoutError] = |_e| { timed_out.store(true) } { // ловим ошибку через эффект supervised(deadline: deadline) { // включаем супервизор с дедлайном (это опционально), // супервизор ждёт, пока все файберы внутри не завершатся // либо, как в данном случае, не отменятся по дедлайну spawn { // запускаем файбер latency_ms.to_millis().sleep() // в данном случае просто засыпаем на определенное количество мс ro _ = tx.try_send(true) // результат записываем в канал } } }
supervised(deadline: …) ограничивает блок по времени: когда дедлайн истёк, дочерние задания отменяются, а with Fail[TimeoutError] = |_e| { … } { … } ловит это как ошибку и взводит флаг (with X = |e| { … }: краткая форма обработчика для Fail: в |_e| приходит ошибка, в фигурных скобках реакция, а следом блок, на который обработчик действует). ro объявляет неизменяемую переменную; ro _ = … значит, что результат вызова сознательно не используется.

Это пример конкурентной работы с HTTP-запросами. Что видно: зелёная полоса — запрос успел, красная — ошибка, серая — отменён по дедлайну. Москва, Петербург, Новосибирск и Лондон успели, Нью-Йорк — ошибка, Токио отменён по дедлайну 2 с. Демонстрация детерминированная (mock_net, не живая сеть). Сетка маленьких квадратов под полосами — остальные запросы той же пачки сверх шести городов, по квадрату на запрос; в кадр она попадает обрезанной. Посмотреть в движении (гифка, 4,75 МБ).
Запись 2 августа 2026, сейчас другая версия. Сигнатура в шапке отличается от репозитория на этой ревизии (aggregate в начале статьи); сценарий погодный, не mini_aggregator.nv выше.
Поменять реализацию, не меняя функцию
type Counter effect { … } объявляет операции эффекта (effect здесь — вид типа, как consume ниже). effect Counter { … } внутри with задаёт реализацию (обработчик, handler). После with идут два блока в фигурных скобках: реализация и область, где она действует. Пример из examples/tour/effects_tour.nv (без модуля и комментариев) должен напечатать total = 6. ro объявляет неизменяемую переменную, mut — изменяемую:

Текст для копирования
type Counter effect { fn next() -> int } fn count_three() Counter -> int { ro a = Counter.next() ro b = Counter.next() ro c = Counter.next() a + b + c } fn main() { mut state = 0 with Counter = effect Counter { next() -> int { state += 1 return state } } { ro total = count_three() println("total = ${total}") } }
Обработчик выдаёт 1, 2 и 3, а count_three знает только про Counter.next(). В тесте агрегатора сеть подменяется одной строкой (README, строка 174): with Net = mock_net(), Emit = null_emit() {.
Ресурсы, гонки данных, дженерики
consume задаёт тип, значение которого компилятор отслеживает как ресурс (близко к владению в Rust). Такое значение потребляется ровно один раз. Если у типа есть @cleanup, то значение, которое не потребили явно, потребляется автоматически при выходе из области видимости. Без @cleanup забытое значение даёт ошибку компиляции.
Пример по мотивам examples/tour/consume_tour.nv: Resource — тип-ресурс, make создаёт его значение, а @cleanup — метод типа. Метод объявляется как fn Тип @имя(…): @ перед именем означает неявного получателя (то, что в других языках self), поля получателя доступны как @поле, а слово после типа (consume, у изменяющих методов mut) говорит, как метод обращается со значением. consume r = привязывает значение, которое надо потребить. Значение r никто не потребляет явно, поэтому при выходе из внутреннего блока вызывается @cleanup:

Текст для копирования
type Resource consume { id int } fn Resource consume @cleanup(_outcome ScopeOutcome) { println("cleanup: ресурс закрыт автоматически") } fn make(id int) -> Resource => { id } fn main() { { consume r = make(1) println("работаем с ресурсом") } println("вышли из области") }
Программа печатает (проверено на опубликованном архиве Oracle 0.1.0 для Windows; компилятор при этом предупреждает о вызове println внутри @cleanup без пометки #cancel_safe, для примера это не мешает):
работаем с ресурсом cleanup: ресурс закрыт автоматически вышли из области
Гонки данных: Nova проверяет небезопасное разделение изменяемых данных между файберами, но не гарантирует отсутствия всех гонок: косвенные вызовы замыканий остаются непроверенными. В обеих реализациях есть тихие ошибки.
Дженерики — основа языка и стандартной библиотеки: на них построены типы Vec[T], Option[T] и Result[T, E] из std Nova. Запись с параметрами типа в квадратных скобках обычная, новизны здесь нет, показываю только её (из examples/tour/types.nv, строки 19–21):
 -> Option[T] с проверкой длины](https://habrastorage.org/r/w780/webt/4f/5e/95/4f5e953a6da6c04a52a69ce0ea1b6c79.png)
Текст для копирования
fn first[T](xs []T) -> Option[T] { if xs.len() == 0 { None } else { Some(xs[0]) } }
Сумм-тип: значение одного из нескольких вариантов, и у каждого варианта могут быть свои данные. Option и Result в Nova именно такие: Some/None, Ok/Err. Пример по мотивам examples/tour/types.nv (строки 8–16; main мой): Shape — фигура, круг или прямоугольник, match разбирает варианты. Если пропустить вариант, компилятор откажет (E_MATCH_NON_EXHAUSTIVE):

Текст для копирования
type Shape enum | Circle(f64) | Rectangle(f64, f64) fn area(s Shape) -> f64 => match s { Circle(r) => 3.14 * r * r Rectangle(w, h) => w * h } fn main() { ro c = Circle(2.0) ro r = Rectangle(3.0, 4.0) println("area(circle)=${area(c)} area(rect)=${area(r)}") }
Печатает (проверено на опубликованном архиве Oracle 0.1.0 для Windows): area(circle)=12.56 area(rect)=12.
Пример программы на Nova: claude-limits
Небольшая, но настоящая программа на Nova: claude-limits, страница с лимитами аккаунтов Claude Code, Kimi Code и Codex и прогнозом расхода к сбросу окна (периода до обнуления лимита). Использует недокументированные API; сборка для Windows отдельная.

На картинке только лимиты Claude. Интерфейс английский («at reset» — «к сбросу»); имена и числа выдуманы. Три карточки: calm (спокойный), pace (прогноз «130% к сбросу», лимит закончится через 1 ч 10 мин), locked (лимит исчерпан). Штриховка — прогноз расхода до сброса, тонкая линия под полосой — доля прошедшего окна, окно — период до обнуления лимита.
Куда идти дальше
сайт языка: документация, спецификация, установка, блог;
ошибки присылайте в issues с версией, ОС и минимальным примером; в первую очередь чиню те, что мешают разработке Carina;
об уязвимостях сообщайте приватно по SECURITY.md;
исходники под MIT OR Apache-2.0, выпуски на странице релизов.
Всем спасибо, что дочитали до конца, остаёмся на связи!
egorkozelskij
Какие у Вас выводы после разработки компилятора с ИИ?
unitcraft Автор
С ИИ я могу взяться за проект, который в одиночку раньше вряд ли бы потянул. Но за архитектурой приходится следить самому. В Nova Oracle я слишком долго принимал задачи по одной и не замечал, как расползается общая конструкция.
С выводом типов так и вышло: месяц мы проверяли, что поведение не изменилось, а старый механизм, который собирались убрать, оставался на месте. Вовремя я этого не заметил.
Отсюда мой вывод: нужна более чёткая, декларативная методология. Заранее описать, какой результат нужен и как его проверить, чтобы выполнение задачи меньше зависело от того, как агент понял очередной разговор. Такую методологию я сейчас собираю на опыте Nova: https://github.com/unitcraft/ai-dev-methodology
С ИИ я продолжу работать, с ним в разработке можно делать то, что раньше было недоступно. Но в большом проекте агенты пока сильно спотыкаются и регулярно нарушают даже явно записанные правила и соглашения. Записать правила и довериться агентам пока не получается: у меня держатся только те правила, которые CI проверяет при каждой сборке.