Первый раз, когда запускаешь двух кодовых агентов одновременно, это ощущается как бесплатная пропускная способность. Во второй раз один из них коммитит файл, который другой переписывал наполовину, и вечер уходит на восстановление случившегося по git reflog.
В этом весь урок, и приходит он рано: один агент — задача про инструменты, несколько агентов на одном репозитории — задача распределённых систем. Всё, что вы знаете про конкурентность, работает — взаимное исключение, живучесть, обнаружение отказов, уборка сирот, — только воркеры недетерминированы, могут зависнуть по причинам, которые никогда не повторятся, и уверенно отчитаются об успехе работы, которую не сделали.
Ниже — набор отказов, на которые я реально наступил, гоняя флот против мультирепозиторного воркспейса, и модель координации в Hilum Tools, которая их снимает. Ничего из этого не требует процесса-супервизора, и всё встраивается в существующую схему по частям.
Отказ 1 — два воркера берут один и тот же кусок
Наивный диспетчер раздаёт список задач и полагается на то, что все останутся в своей полосе. Работает, пока две задачи не заденут один файл или пока воркер не закончит раньше срока, не пойдёт искать работу и не подберёт то, что уже в работе.
Чинится это не вежливостью, а арбитражем в точке раздачи. Есть ровно одно место, где работа становится чьей-то, — операция claim_next, которая атомарно выбирает незанятый элемент, помечает его владельцем-воркером и возвращает. Никогда «вот список, выбирайте». Воркер, которому нужна работа, просит работу и узнаёт, что ему досталось.
Помимо атомарности важны два свойства:
- Claim несёт аренду, а не блокировку. Воркер, умерший с вечной блокировкой, вешает очередь навсегда. Аренда истекает, элемент возвращается в пул, его подбирает кто-то другой. Воскресший воркер обнаруживает, что его claim'а больше нет, и останавливается — это правильное поведение, а не баг, который надо обходить.
- Claim заявляет радиус поражения. Какие пути, какой репозиторий, какой пакет. Именно это делает следующий отказ предотвратимым, а не только обнаружимым.
Отказ 2 — параллельные правки портят друг друга
Два агента, редактирующих одно рабочее дерево, — это не конкурентность, а потеря данных с лишними шагами. Режимы отказа конкретны и все неприятны: git add -A одного воркера подхватывает недописанный файл другого; хук-форматтер прогоняется по модулю в середине переписывания и падает, блокируя коммит, который к нему отношения не имел; сборка, которая нужна одному агенту зелёной, красная, потому что другой на третьем файле рефакторинга.
Изоляция — единственный настоящий ответ, и её самая дешёвая форма уже есть в git: дайте каждому воркеру собственный worktree. Тот же репозиторий, то же хранилище объектов, отдельный чекаут и отдельный индекс. Параллельные правки друг друга не видят, хуки гоняются по когерентному дереву, а слияние происходит один раз и осознанно, в конце.
Это не бесплатно — пара сотен миллисекунд и место на диске на воркера, — поэтому стоит быть точным в том, когда это нужно. Пересекающиеся пути, общие артефакты сборки, всё, что дёргает commit-хуки, — изолировать. Действительно непересекающаяся работа в разных репозиториях — не тратьте ресурс.
И независимо от изоляции одно правило заслуживает места в каждом файле инструкций для агентов, который я веду: никогда git add -A, пока в том же дереве жив другой воркер. Стейджить явные пути. Одна эта строка предотвратила больше инцидентов, чем любое количество умного тулинга.
Отказ 3 — вывод умирает вместе с процессом
Воркер заканчивает, пишет результат в stdout и выходит. Родитель был занят, или перезапустился, или буфер пайпа заполнился. Работа произошла; запись о ней — нет. Теперь у вас репозиторий в состоянии, которое никто не может объяснить, и ни одного артефакта, описывающего почему.
Обращайтесь с выводом воркера как с долговечным состоянием, а не как с потоком. Пишите его туда, что переживёт процесс, — в файл, в очередь, в таблицу — по мере производства, а не в конце. Читатель тогда опрашивает это место и волен отсутствовать, тормозить или перезапускаться, ничего не теряя.
То же касается передачи. Когда работа агента A становится входом агента B, этот перенос не должен быть «A сообщает B» — сообщение в полёте при смерти любой из сторон это потерянное сообщение. Он должен быть «A пишет в долговечный mailbox, B читает, когда готов». B подхватывает ровно там, где A остановился, без повторного брифинга, и обоим не нужно быть живыми в один и тот же момент.
Отказ 4 — тихое зависание
Этот — дорогой, потому что стоит настенного времени, а не корректности, и потому что вы часто не замечаете его часами.
Агент ждёт контейнер, который никогда не поднимется, промпт, на который никто не ответит, сетевой вызов без таймаута. Он не упал — упавший это просто, упавший даёт код возврата. Он жив, простаивает и ничего не производит, и любая наивная проверка живости говорит, что всё нормально.
Два механизма, именно в таком порядке:
Событийное завершение — основное. Воркер, закончив, сообщает об этом, и уведомление будит оркестратор. Это нормальный путь, и через него должен идти практически весь трафик.
Сторожевой таймер — страховка. Таймер, взводимый при диспатче, длинный — десятки минут, не секунды, — единственная задача которого спросить «оно ещё живо?», если события о завершении так и не пришло. Длинные интервалы важны по недооценённой причине: короткий цикл опроса не просто расточителен, он ещё и не обнаруживает зависание. Опрос говорит, что процесс существует. Он не говорит, что процесс что-то делает.
Про «что-то делает» спрашивайте ground truth, а не состояние процесса: меняется ли рабочее дерево, крутится ли компилятор, лёг ли коммит с прошлой проверки. Воркер, который N минут не произвёл движения в файловой системе и не породил дочерних процессов, завис — что бы ни говорило поле статуса.
И, обнаружив: убивайте группу процессов, а не процесс. Остановленный родитель с живыми детьми — худшее из состояний: оркестратор считает слот свободным, диспатчит в него, и вот теперь в одном дереве действительно два воркера. А после убийства — восстанавливайте, а не выбрасывайте: у зависших агентов обычно уже застейджена реальная проверенная работа, которую осталось закоммитить.
Минимальный протокол
Всё сказанное сворачивается в пять правил, которые добавляются в существующую схему по одному, примерно в порядке отдачи:
- Одно место, где работа становится чьей-то. Атомарный claim с арендой и заявленным радиусом поражения. Никаких «выберите из списка».
- Изолировать всё с пересекающимися путями. Worktree на воркера; стейджить явные пути, никогда
-A. - Долговечные вывод и передача. Результаты и сообщения переживают породивший их процесс.
- Событийное завершение, сторож с длинным интервалом. Живость проверяется против ground truth — движение дерева, дочерние процессы, коммиты, — а не против поля статуса.
- Проверять независимо, прежде чем верить. Отчёт воркера об успехе — это утверждение, а не доказательство. Перезапустите проверку сами.
Последнее — не правило распределённых систем, а правило про агентов, и ловит оно больше всего. Самоотчёт об успехе — наименее надёжный сигнал в системе, и не потому, что агент врёт, а потому, что «я запустил тесты» и «тесты прошли» — разные утверждения, которые языковые модели под давлением склеивают. Всё несущее перепроверяет тот, кто собирается на этом действовать.
Что не надо параллелить
Пропускная способность — не единственная ось, и часть работы просто дешевле последовательно:
- Всё, что трогает один модуль. Разбор конфликта слияния между двумя агентами стоит дороже, чем сэкономил последовательный прогон.
- Всё, что пишет в общий индекс или локфайл. Два
pnpm installв один store, два индексатора в одну базу — конкуренция обходится дороже выигрыша от параллельности. - Первый проход по незнакомой работе. Запустите одного агента, посмотрите, как он падает, зафиксируйте урок, потом параллельте. Веерный запуск до понимания режима отказа умножает отказ, а не результат.
Выводы
- Один агент — инструменты; несколько на одном репозитории — распределённые системы. Относитесь так с первого же параллельного запуска.
- Владение арбитрируется там, где раздаётся работа, и с арендой — никогда по договорённости.
- Пересекающуюся работу изолировать в отдельные worktree; стейджить явные пути.
- Вывод и передача обязаны переживать создавшие их процессы.
- Завершение событийное; сторож — длинная страховка; живость меряется против ground truth.
- Убивать группу процессов, а потом восстанавливать застейдженную работу, а не выбрасывать.
- Никогда не действовать по самоотчёту, который вы не проверили независимо.
Вторая половина удешевления флота — сделать так, чтобы каждый агент перестал переучивать репозиторий: долговременная память для того, что он должен знать заранее, и выборка под бюджет токенов для того, что приходится смотреть. Если нужен слой оркестрации под ваш стек — это направление AI-разработка.
