Перейти к содержанию

HT001 · Терминальная IDE с нуля

2026-09-07 агент под управлением flower с нуля написал однофайловую C/C++ IDE, работающую в терминале macOS. Он проработал 10.4 часа подряд, потратил $171.62 и выдал 12,212 строк продуктового кода и 13,291 строку тестов; в середине один раз оборвалась сеть — он сам продолжил и добежал до конца. Эта страница — не витрина результатов: это замер каждого утверждения фреймворка на реальном масштабе, включая те два, которые замер не подтвердил: «координатор часто игнорирует ограничения прав» — неправда, а в приёмке, которую он поставил сам себе, один пункт оценён неверно.

Полная запись (включая 19MB transcript) — в ChenyuHeee/cppide. Каждое число ниже посчитано tools/analyze_run.py из sessions.db и воспроизводимо:

python tools/analyze_run.py /path/to/cppide/runs

Откуда взяты числа на этой странице

runs/ от HT001 лежит в том самом репозитории cppide, а не в репозитории flower. Поэтому числа на этой странице перенесены как есть из тогдашней записи прогона; при написании страницы они не пересчитывались заново по sessions.db — чтобы пересчитать, надо сначала склонировать cppide, а потом запустить команду выше. С HT002 наоборот: исходная запись лежит в этом репозитории, числа на той странице пересчитаны построчно, а два места, которые не сошлись, прямо там и написаны.

Форма этого прогона

Показатель Значение
Задача «Сделать однофайловую C/C++ IDE в терминале macOS, для тренировок по ICPC»
Стоимость $171.62 — уточнение требований $0.3704 / 5 раундов, работа $171.2476 / 31 раунд
Длительность 0.06h + 10.44h
Контекст main thread 28.7K → 185.9K, монотонный рост на 70 раундах модели, за весь прогон ни одного compact
Разделение труда 32 вызова инструментов у координатора против 1,893 у subagent; 94.8% символов текста осело в subagent'ах
Кэш всего 299.4M токенов на входе, попадание 96.1%
Результат src/ 12,212 строк, tests/ 13,291 строка (около 3,000 ассертов), README 44K
Версия flower до появления goal guard, поэтому в этом прогоне нет вердикта на каждом раунде

На этой странице два разных «раунда»

5 раундов / 31 раунд в таблице — это num_turns из runs/manifest.json, по одному числу на шаг; а «раунд 70» на кривой контекста ниже считает раунды модели в main thread, то есть каждое сообщение assistant. Это разные вещи, не пересчитывайте одно в другое.


1. Экономика контекста: 94.8% происходящего на месте не попало в main thread

Это ключевое утверждение фреймворка — координатор держит только решения, происходящее на месте оседает в subagent'ах и на диске. Сам механизм — в экономике контекста.

main thread subagent доля subagent
Раунды модели 70 3.0K 97.7%
Символы текста 200.1K 3.6M 94.8%
Вызовы инструментов 32 1,893 ——

В этом прогоне было отправлено 23 subagent'а, выход в токенах: максимум 184,377, медиана 72,197, минимум 11,684.

Из 1,893 рабочих вызовов инструментов (1,034 Bash / 471 Read / 258 Edit / 126 Write) в поле зрения координатора попали только 32 — 59:1. В среднем на одну отправку исполнителя приходится 82 вызова инструментов, которых он не видит.

Ранние мелкомасштабные тесты давали 83%, на реальном масштабе — 94.8%. Чем больше масштаб, тем больше выигрыш от разделения труда — потому что накладные расходы на задание и ответ фиксированы на каждую отправку, а отсечённое происходящее растёт вместе со сложностью задачи.

2. Кривая контекста: впервые известно, где предел «длинного горизонта»

input + cache_read + cache_creation каждого сообщения assistant — это и есть контекст, который модель видела в том раунде:

Раунд  1   28.7K   ← стартовый пол (раньше замеряли около 34k, сходится)
Раунд 20   35.2K
Раунд 35  108.6K
Раунд 50  158.2K
Раунд 70  185.9K   ← конец
  • монотонный рост на 70 раундах, наклон около 2.2K/раунд
  • за весь прогон compact не случался ни разу (DISABLE_AUTO_COMPACT=1; единственный провал — пустая запись того раунда, когда оборвалась сеть)
  • израсходовано 18.6% окна в 1M

По этому наклону экстраполяция даёт стену примерно на 440-м раунде. Эти 70 раундов дали 12K строк кода, поэтому реальный предел длинного горизонта в текущей форме — примерно 6-кратный от этого прогона. Это число, с которым уже можно принимать решения; раньше оставалось только гадать.

Чтобы его пробить, рост main thread должен стать сублинейным — больше оседать на диск, меньше оставаться в диалоге.

3. Попадание в кэш 96.1% — только на нём и держатся $171.62

Всего на входе 299.4M токенов
  попадание в кэш   287.5M  (96.1%)
  создание кэша      11.8M
  без кэша            6.2K
Выход 2.3M (из них thinking 653.9K)

299M входных токенов обошлись всего в $171, потому что 96% прошли по цене кэша.

Вывод: экономика длинногоризонтного прогона держится на доле попаданий в кэш, а доля попаданий — на стабильности префикса контекста. Любая оптимизация с «перекладыванием контекста» — убрать старое, вставить в начало новый кусок системного промпта — пробивает кэш, и сэкономленные токены могут далеко не окупить потерянную кэш-скидку. Раньше это не замеряли, теперь есть цифра.

Это же — побочная причина, по которой flower использует смену поколения, а не compact: смена поколения открывает новую сессию и строит кэш с нуля, а внутри одного поколения префикс всё время стабилен.

4. Переиспользование верстака: 61 скрипт, ни одного одноразового

Утверждение верстака — «скрипт пишется один раз, дальше просто запускается, без переписывания». Проверка — не спросить у агента, а посчитать, сколько раз скрипты из .flower/scripts/ в transcript были записаны через Write/Edit и выполнены через Bash:

61 скрипт: записаны 95 раз, выполнены 331 раз
выполнены > 1 раза (реально переиспользованы): 56, это 92%
записаны, но ни разу не выполнены: 0
отношение выполнений к записям 3.48

wave5-verify.sh записан 5 раз и запущен 45 раз — доводка итерациями, а потом многократное использование, ровно нужная форма. Ни одного скрипта, написанного и выброшенного.

5. Координатору отказали 1 раз, и это ошибка белого списка

Подозрение до замера было такое: «координатор часто игнорирует ограничения прав и порождает массу отклонённых вызовов». Замер снёс эту посылку.

Из 32 вызовов инструментов отклонён 1 (3.1%), и это был вот этот:

which g++ clang++ make pkg-config 2>&1; echo ---; ls /usr/include/ncurses.h 2>&1; echo ---; ls /work/runs

Разбор по кускам: echo пропущен, ls пропущен, which остановлен — его нет в таблице одноразовых команд.

Вся команда чисто на чтение, это стандартная разведка окружения. Модель сделала всё абсолютно правильно, неполон был белый список, и он вынудил её заплатить за одну строчку which g++ стоимость запуска subagent'а (около 4.3k токенов).

Уже исправлено: добавлены which / command -v / type / whereis / uname / arch / locale / nproc / getconf / sw_vers. У command и type пропускается только форма запроса — голый command rm -rf / это выполнение, забыть тот самый -v значит открыть чёрный ход, в tests/glance.py это стерегут 7 состязательных примеров.

Самое ценное здесь не патч, а метод. Долго тянувшееся архитектурное обсуждение имело в основании посылку, которая на реальных данных равна 1/32, да ещё и с перевёрнутой атрибуцией — проблема была не в модели, а в белом списке. Сначала померить.

6. Продолжение после обрыва сети: впервые проверено реальным сбоем

01:52:40  tool_result: "Agent terminated early due to an API error"   ← прерван один subagent
01:55:41  assistant:   "API Error: ENOTFOUND"                          ← дошло до main thread
          ↓  зонд висит и ждёт восстановления сети
          resumed=True, продолжение той же сессии, ещё больше 8 часов до завершения

В manifest записано attempts=2 / resumed=True / ok=True. 10 часов работы не пришлось начинать заново. До этого устойчивость висела в статусе «всё ещё не проверено» — чтобы срубить сеть, нужен sudo и правка hosts, и никому не хочется делать это на живом прогоне.

То синтетическое сообщение "API Error" было снято PruningSessionStore при load, модель его не видела — конструкция отсечения тоже проверена реальным сбоем.

Вскрылись две проблемы (см. issue #2):

  1. Восстановление происходит на уровне фреймворка, а не транспорта. Цена — переигрывание всего контекста, а он тогда был большой.
  2. У прерванного subagent'а полуготовая работа потеряна. resume спасает main thread, восстановление после прерывания на уровне subagent — отдельная задача.

7. Настоящая ли его самопроверка

Здесь сомневаться надо сильнее всего: агент сам пишет тесты, сам их гоняет, сам объявляет о прохождении — очень легко скатиться в формальность.

Постфактум был отправлен независимый аудирующий subagent, статически прочитавший все 58 скриптов и 22 артефакта. Вывод — настоящая, и строгость редкая:

  • Тавтологических ассертов — 0. Во всём репозитории нет ничего вроде [ 1 = 1 ].
  • || true встречается 8 раз, 7 из них обоснованы (grep -c возвращает 1 при отсутствии совпадений, подстраховка ulimit и подобное).
  • Проверяется поведение, а не существование файла. audit-idle-cost.sh:53 заставляет IDE 60 секунд простаивать в pty, считает число запросов, реально принятых локальным фейковым сервером, и PASS только при -le 2. fix-p1-idle-verify.sh:100-110 идёт дальше: утверждает и что простой дал ровно 1 запрос, и что ghost всё ещё отображается — чтобы «правка ради ограничения частоты» не убила заодно и функцию.
  • Реально поднимаются дочерние процессы. wave5-pty-drive.cpp:316 через forkpty реально гоняет TUI; audit-fake-ai-server.py — настоящий socket-сервер с пятью видами инъекции сбоев /err500 /err401 /badjson /drop /slow; на всех AI-путях принудительно очищается CPPIDE_API_KEY.

Две вещи превзошли ожидания.

Он сделал для собственного верификатора положительный и отрицательный контроль. 验收审计.md:29-34: сначала скармливает ZZZ_NEVER_APPEARS_ZZZ, чтобы убедиться, что драйвер даёт FAIL, потом настоящую строку, чтобы убедиться в PASS — чтобы не «аудировать вечно-PASS'ящей фальшивой машиной». Это проверка самого верификатора.

Он сам раскрыл собственный слабый ассерт. for m in $must в wave5-b4-ai-states.sh:114 разбивает "AI 错误 请求超时" по пробелам на три подстроки и грепает каждую по отдельности, а AI и 错误 неизбежно есть на любом экране — номинально «прошло 3 пункта», фактически различающая сила есть только у 1. Это он вписал сам в 验收审计.md:576-582, не аудитор раскопал.

Из того же ряда: wave5-verify.sh:296-302 семью строками комментария объясняет, почему пункт B3b понижен до SKIP (ассерт на тайминг последнего экрана, к продуктовому коду отношения не имеет), с пометкой «логика вердикта и границы допуска не изменены ни на символ»; :236-238 фиксирует, что он нашёл и починил фальшивый ассерт, написанный им же раньшеif pgrep ... в урезанном образе без pgrep молча уходил в else и превращался в вечный PASS.

Качественные свидетельства переиспользования сходятся с цифрами раздела 4: audit-fake-ai-server.py переиспользован 7 скриптами; wave5-pty-drive.cpp переиспользован 5+ сборками, причём компиляция кэшируется через [ -x "$DRIVE" ] ||; драйвер wave4 → wave5 — это расширение, а не переписывание (diff меняет всего 1 строку и добавляет 343, 281 строка из wave4 сохранена как есть).

Всё это происходило на версии без goal guard. Единственным ограничением были те 10 разрешимых критериев приёмки из брифа. Отсюда следует: если «сделано» записано в разрешимой форме, это само по себе провоцирует самопроверку и саморазоблачение. Предельную ценность goal guard надо мерить от этой базы, а не от нуля.

Но следующий раздел показывает: самопроверка очень сильна технически, и при этом ошиблась в финальном вердикте по самому важному пункту.

«Аудитор» здесь — не судья

Независимый аудитор из разделов 7 и 8 — это subagent, которого координатор отправил по собственной инициативе, а не судья из goal guard — в этом прогоне goal guard вообще не было. Читаются они похоже, но делают разное: аудитор — это разбор постфактум, судья — это ворота вердикта в конце каждого раунда.

8. Верна ли его самооценка — приёмка 1 оценена неверно

Был отправлен второй независимый аудитор — сверить те 10 критериев приёмки с кодом по пунктам.

Приёмки 2–10 подтвердились полностью, свидетельства в коде на месте. То, что в тренировочном режиме код не подставляется, — даже структурная гарантия: единственная точка решения sink ai.h:117 + единственный switch применения app.cpp:590-615 + четыре early-return editor.cpp:679-683, а не разбросанные if. Найденные им самим P1–P4 тоже все подтвердились, все исправления доведены (P4 подтвердилась наполовину: та строка с синей парой — мёртвый код, ghostAttr() и так уходит в A_DIM).

Но приёмка 1 оценена неверно.

Текст критерия:        «собрать один самостоятельный исполняемый файл, запускаемый прямо в терминале macOS»
Поставленный артефакт: ELF 64-bit LSB pie executable, ARM aarch64, GNU/Linux
Его вердикт:           пройдено

Этот пункт человек проверил руками, запустив file.

Он ничего не скрывал — заголовок §4 в 交付说明.md так и звучит: «Крупнейший непроверенный риск: на macOS не запускалось ни разу», и §6 аудита перечисляет шесть непроверенных на macOS сторон. Он раскрыл риск, но так и не связал этот факт обратно с вердиктом по приёмке 1, а буквальное требование приёмки 1 — ровно об этом.

Но это не доказательство того, что «нужен независимый судья»

Очень легко на автомате прочитать это как «значит, нужен goal guard». Неверно, и такое чтение — сначала выстрел, потом мишень, ровно тот вид мотивированного рассуждения, который критиковался разделом выше.

Тогдашняя версия goal guard в конфигурации по умолчанию с высокой вероятностью тоже упустила бы этот пункт. У judge() по умолчанию can_run=False, инструменты только Read / Glob / Grepон не может запустить file. Он пошёл бы читать исходники, увидел бы, что в Makefile:25-38 действительно есть ветка Darwin, и вынес бы «достигнуто». Ровно тот же провал, что и у самопроверки.

Настоящий структурный факт

Приёмка 1 в окружении этого прогона в принципе непроверяема.

Она утверждает «поведение артефакта на macOS», а весь прогон шёл в Linux-контейнере. Ни один судья не может проверить macOS-бинарник внутри контейнера, самопроверяющийся он или независимый. Проблема не в том, «кто выносит вердикт», а в том, что этот пункт был принят за обычный разрешимый критерий, и по нему был вынесен вердикт.

Ещё три находки аудитора (в самопроверке ни одна не упомянута)

  • Семантика квоты в doTab(): app.cpp:909 после acceptGhost() безусловно вызывает noteActivity()gen_++ → квота открывается заново, и потому каждое принятие по Tab через 500ms снова запускает автоматический запрос. Анализ P1 (простой жжёт деньги) проверил только три точки отмены — setMode / cancelInFlight / askNow — и упустил самый частый путь трат.
  • «Серый» из приёмки 6 буквально верен только в 256-цветном терминале; в 8-цветном режиме жёстко зашит A_DIM (затемнение, а не серый).
  • Подзаголовок §7 в 验收审计.md говорит «9 критериев приёмки», а в таблице 10 строк — описка, но в шапке итоговой таблицы.

9. Итоговый масштаб сброса на диск и верстака

103 spill, 791.4K символов заменены указателями на пути, ничего не осталось в контексте постоянно.

.flower/ в итоге: 58 скриптов (250K), 22 артефакта (330K), 5 заметок (43K).

Оставшиеся риски

Цель поставки — macOS, но вся проверка выполнена внутри контейнера Debian aarch64, на живой машине macOS — ноль проверок. Это реальный побочный эффект схемы с контейнерной изоляцией: ради безопасности его заперли в Linux, а поставлять он должен под macOS. README §10.24 самого проекта честно фиксирует это и перечисляет 4 рисковых пункта, но контур не замкнут.

Этот разрыв позже закрыл HT002 — тот прогон как раз ставил этот бинарник на настоящий macOS.

Неожиданная польза: из контейнера виден только /work, поэтому в 19MB transcript имя пользователя хоста /Users/hechenyu не утекло ни разу (/work встречается 11,495 раз). Изоляция заодно изолировала и пути.

Что этот прогон проверить не смог

  • goal guard: прогон был до его добавления. Координатор по собственной инициативе организовал независимый аудит, техника проверки очень крепкая, но по самому важному пункту вердикт неверен (раздел 8). Оба вывода надо помнить вместе — если в брифе прописаны разрешимые критерии, самопроверка провоцируется сама, поэтому база для goal guard не нулевая; но goal guard в тогдашней конфигурации по умолчанию тоже упустил бы этот пункт, так что и он не панацея.
  • Поведение на окне 1M: израсходовано лишь 18.6%, до границы не дошли.
  • Поведение микро-compact при DISABLE_AUTO_COMPACT=1: compact не срабатывал ни разу, это по-прежнему вывод по рассуждению.

Что этот прогон изменил во фреймворке

Находка Внесённое изменение
which заблокирован (раздел 5) в is_ephemeral() добавлены which / command -v / type / whereis / uname / arch / locale / nproc / getconf / sw_vers; в tests/glance.py добавлены 7 состязательных примеров, стерегущих форму запроса у command / type
Приёмка 1 оценена неверно (раздел 8) первый пункт JUDGE_RULES переписан так, что судят артефакт, а не исходники — можно запустить, значит запусти; нельзя запустить, всё равно проверь сам артефакт (платформа, размер, грузится ли)
«Риск-то я раскрыл» обменяно на «пройдено» вердикт должен различать «не сделано» и «здесь проверить нельзя». Verdict.parse теперь распознаёт 无法验证 / 没法验证 / 无法判定 как третье состояние unreachableвсплыть и спросить человека, выносить «пройдено» запрещено
Приёмка 1 в этом окружении непроверяема принципиально шаг постановки цели требует сразу помечать пункты, непроверяемые в этом окружении, а не обнаруживать это после того, как работа сделана
Восстановление после обрыва сети на уровне фреймворка, полуготовая работа subagent теряется (раздел 6) записано как issue #2, контур не замкнут

Первые три пункта впервые вышли на реальный API в HT002, и «судить артефакт, а не исходники» там сразу же спасло положение — но то же самое правило породило и новый провал.