# Инструкция по мультиагентной системе разработки

> Это полная инструкция по построению автономного конвейера разработки: задачи лежат в базе Вторума, агенты на базе Claude Code сами берут их, пишут код, коммитят, выкатывают на тестовый стенд и на прод. Человек только ставит задачи и проверяет результат. Инструкция написана по действующей системе, которая уже выкатила сотни релизов. В ней нет адресов, ключей и идентификаторов: всё, что зависит от вашей среды, обозначено в угловых скобках, например <БАЗА_ЗАДАЧ>. Рабочий код скриптов — в приложениях в конце страницы.

> Авторство и лицензия. Автор: Константин Кокуш, октябрь 2026. Лицензия: Creative Commons «Attribution 4.0 International» (CC BY 4.0). Эту инструкцию и весь код из приложений может использовать любой желающий во Вторуме: свободно и бесплатно копировать, изменять, переиздавать и применять, в том числе в коммерческих целях. Условия: указать автора (Константин Кокуш) и дать ссылку на эту страницу и на лицензию, а если вы меняли материал, отметить, что изменено. Материал предоставляется «как есть», без гарантий; ответственность за применение на вашем сервере и в ваших данных лежит на вас.

> Автоматическая установка. Конвейер собирается одной командой: файл-установщик vtorum-pipeline-kit.py приложен ниже. Он сам создаёт базу задач во Вторуме, раскладывает все скрипты из приложений, подставляет ваши адреса и команды, клонирует рабочие копии, ставит таймеры и проверяет результат тестовым прогоном (см. приложение Р). Вручную остаются только то, что может сделать лишь человек: выпустить ключ бота во Вторуме и войти в Claude Code.

[file](https://notes.vtorum.net/api/public/web/%D0%B8%D0%BD%D1%81%D1%82%D1%80%D1%83%D0%BA%D1%86%D0%B8%D1%8F-%D0%BF%D0%BE-%D0%BC%D1%83%D0%BB%D1%8C%D1%82%D0%B8%D0%B0%D0%B3%D0%B5%D0%BD%D1%82%D0%BD%D0%BE%D0%B8-%D1%81%D0%B8%D1%81%D1%82%D0%B5%D0%BC%D0%B5-%D1%80%D0%B0%D0%B7%D1%80%D0%B0%D0%B1%D0%BE%D1%82%D0%BA%D0%B8-cf7630ac/assets/b0976999-5ce3-4ff5-a959-78b5884e8373/raw)

## 1. Что получится и на чём это держится

Результат: вы заводите запись в базе задач, ставите статус todo — и через несколько минут агент берёт её в работу. Он читает требование и вложения, правит код, пишет тесты, поднимает версию, вливает работу в центральный репозиторий и пишет резюме в конец задачи. Скрипты без участия модели собирают накопившиеся задачи в одну сборку и выкатывают на dev. После вашей команды (статус deploy/prod) так же выкатывают на прод. Статус done ставит только человек.

Принципы, без которых система разваливается:

- Механика — скриптами, модель — только для работы головой. Выбор режима, захват задачи, выдача номера версии, слияние, выкатка, переключение статусов, замки, проверка тестов — это bash и вызовы API Вторума. Модель пишет код, резюме и чинит то, что скрипт не смог. Так конвейер быстрый, дешёвый и предсказуемый: если модель недоступна, выкатки всё равно идут.
- Статус задачи — переключатель. Человек одним движением (todo → backlog) останавливает работу. Агент перед каждым шагом перечитывает статус и, если он сменился, прекращает работу, не ломая ничего.
- База задач — единственный источник правды. Состояние конвейера видно на доске. Никаких скрытых очередей: что агент делает, написано в статусе, в поле worker и в резюме.
- Один шаг — одна ответственность. За прогон агент делает ровно одно: либо выкатку на прод, либо одну задачу, либо выкатку на dev. Режим выбирает скрипт, а не модель.
- Замки на всём общем. Одна выкатка за раз, один номер версии на агента, одно слияние за раз, максимум N тяжёлых проверок одновременно. Замки — файлы с flock.
- Любая ошибка оставляет следы и ждёт человека. Упавшая выкатка не повторяется бесконечно: причина пишется в задачи, замки снимаются, и конвейер ждёт решения.
- Работа не теряется при обрыве. Лимит подписки, перезагрузка сервера, таймаут — незаконченная задача запоминается в файле состояния и дорабатывается следующим прогоном.
## 2. Архитектура одним взглядом

Компоненты:

- [Вторум](https://notes.vtorum.net) — база задач (свойства, статусы, тело записи, вложения, связи между задачами). Весь доступ к Вторуму — только через публичный API v2 (скрипты и агенты) или через MCP-коннектор и интерфейс (люди и ассистенты в чате). Прямого доступа к базе данных Вторума (SQL) у вас нет и быть не должно: он есть только у владельца сервера Вторума. Все скрипты в приложениях написаны под API и не требуют ничего, кроме ключа бота.
- Сервер агентов — Linux-машина (Ubuntu), на которой живут: Claude Code, репозитории, рабочие копии агентов, скрипты, замки, журналы, таймеры systemd. Рекомендуется 8 ядер и 16 ГБ на четырёх агентов.
- Центральный репозиторий (central) — голый git-репозиторий; в него вливается готовая работа.
- Стенды — dev (тестовый) и prod (боевой). Выкатку на них делают скрипты установщика проекта; у стенда есть адрес с номером версии (эндпоинт version).
- Агенты — N одинаковых процессов, каждый со своим номером, кодовым именем (оно пишется в поле worker), рабочей копией и веткой.
- Резервный агент на ноутбуке (необязательно) — тот же агент, запускаемый launchd, на случай аварии сервера.
- Сторож — скрипт, который следит за перезагрузками и состоянием сервера (необязательно, но очень полезно).
Поток задачи по статусам:

1. backlog — черновик. Агент не трогает.
1. todo — запустить. Человек ставит, когда задача описана полностью.
1. in progress — агент работает. Под замком.
1. commit — код закоммичен и влит в central, на dev его ещё нет.
1. deploy/dev — идёт общая выкатка (этот статус получает вся пачка).
1. test/dev — выкачено на dev, ждёт проверки человеком.
1. deploy/prod — человек велел выкатить на прод. Статус ставит человек.
1. test/prod — выкачено на прод, ждёт проверки человеком.
1. done — принято. Ставит только человек.
1. error — в задаче не хватает данных; человек дописывает и сам возвращает в todo.
1. cancel — отменена.
Кто какой статус ставит: человек — backlog, todo, deploy/prod, done, cancel и возврат из error. Агент и скрипты — in progress, commit, deploy/dev, test/dev, test/prod, error (и возврат технически упавшей задачи в todo с причиной).

Приоритет работы в каждом прогоне: сначала прод (чтобы выкатка на прод не ждала разработку), потом новая задача, потом общая выкатка на dev (она запускается, только когда в todo не осталось готовых задач и нет задач in progress: пачка копится, и на dev уезжает один раз).

## 3. Что понадобится

- Рабочее пространство Вторума с правом создавать базы и выдавать токены интеграции. Для агентов лучше завести отдельного бота (системного пользователя) с доступом к базе задач.
- Сервер агентов: Linux, root или отдельный пользователь, systemd, flock, git, ssh, curl, python3, Docker (если стенды в Docker), Node.js и менеджер пакетов вашего проекта.
- Доступ с сервера агентов к: Вторуму по https (API v2, ключ бота), центральному репозиторию (ssh) и боевому серверу проекта (ssh-ключ без пароля: нужен для отметок тестов, установщика и промоута; к Вторуму ssh не нужен).
- Ключ бота Вторума: системный пользователь с правами на страницы, блоки, базы и файлы (чтение и запись), которому выдан доступ на правку к базе задач. Ключ лежит в файле с правами 600. Скрипты написаны на bash и python3 (только стандартная библиотека).
- Ассистентам в чате (людям, ведущим задачи) удобнее MCP-коннектор Вторума, если он у вас подключён: те же операции над теми же записями, без ключей в командной строке. Агентам на сервере нужен именно API: он предсказуем и не зависит от чата.
- Claude Code (CLI) и подписка, на которой он работает. Для автономной работы нужен долгоживущий токен: команда claude setup-token выдаёт ключ на год. Его кладут в файл с правами 600 и экспортируют в переменную окружения CLAUDE_CODE_OAUTH_TOKEN. Обычный вход по сессии протухает и ломает агентов по ночам.
- Проект с автоматическими проверками: типы, тесты, быстрые «гвардии» (см. раздел 9) и установщик выкатки, который умеет ставить сборку на dev и промоутить её на прод.
- Лимиты подписки: четыре агента на одной подписке упираются в лимиты. Закладывайте паузы и возобновление (раздел 12).
## 4. Шаг 1. База задач во Вторуме

Создайте базу «bugs&tasks» (название любое) со следующими свойствами. Имена важны только вам, но скрипты и промпт должны ссылаться на одни и те же.

- Название (title).
- status (select) со значениями: backlog, todo, error, in progress, commit, deploy/dev, test/dev, deploy/prod, test/prod, done, cancel. У каждого значения есть id и цвет. Скрипты пишут вариант по имени, а при создании базы id вариантов задаются явно (Приложение А): без id интерфейс не сопоставляет значение с вариантом.
- problem to solve (rich_text) — суть задачи, одна-две строки.
- attaches (files) — макеты, скриншоты, PDF.
- DEV · Версия (rich_text) — номер релиза, в который вошла задача.
- DEV · Ветка (rich_text) — тот же номер, записывается сразу при его получении, чтобы на доске было видно.
- DEV · Commit (rich_text) — короткий хеш коммита (несколько — через запятую).
- DEV · Последняя ошибка (rich_text) — причина по-русски, если задачу выполнить не удалось; при успехе пустая строка.
- DEV · Обновлено (date) — время последнего изменения агентом; по нему отличают «человек что-то дописал после ошибки».
- worker (select) — кто делает задачу: кодовое имя агента или чата человека. Значения — имена ваших агентов.
- Тесты (select: идут / зелёные / красные) — результат полного прогона тестов для сборки, в которую вошла задача.
- Блокируется и Блокирует (связь с этой же базой, двусторонняя) — зависимости, раздел 15.
Правила оформления задачи (их же вшивайте в промпт и в инструкции для людей):

- В поле «problem to solve» — одна строка сути. Полное описание пишется блоками в тело записи: цель, что сломано, где в коде, ожидаемый результат, приёмка (заголовки, списки, абзацы). Скриншоты — вставляются в тело или прикрепляются.
- Любое требование может лежать в трёх местах: поле, вложения, тело. Агент обязан прочитать все три (для этого есть скрипт task-materials).
- Нехватка данных — не повод угадывать: агент ставит error, пишет в резюме, что именно человеку дописать, и больше задачу не трогает.
Как читать и писать значения через API (подробности и запросы — в приложении Б):

- Свойства называются по имени (так их находят скрипты), поэтому переименовывать свойства нельзя без правки скриптов.
- select: {"select": {"name": "todo"}}; сброс — {"select": null}. rich_text: {"rich_text": [{"type": "text", "text": {"content": "текст"}}]}, пустое значение — []. date: {"date": {"start": "2026-10-22T16:30:00"}}. relation: {"relation": [{"id": "<id записи>"}]}.
- Читать записи удобнее одним запросом к базе с фильтром по статусу: он возвращает свойства, время правки (last_edited_time), признак замка и связи. Все скрипты ходят через один клиент vt.py (Приложение В).
- Создание и правка задач людьми и внешними системами — тем же API (или формой, интерфейсом, MCP).
Замок задачи: у записи есть признак «заблокирована» (поле locked в API). В интерфейсе это «Заблокировано»: смотреть можно, править нельзя. Замок ставится на время работы агента и обязательно снимается при любом выходе. Забытый замок — худшее, что может сделать агент: человек остаётся с задачей, которую не может править. Важная особенность API: заблокированная запись отклоняет любую правку, в том числе от самого агента, ответом 423 (node_locked); исключение — запрос, в теле которого только {"locked": false}. Поэтому клиент vt.py при каждой записи снимает замок, пишет и возвращает его обратно (запись под замком — три запроса вместо одного, окно для чужой правки — доли секунды).

## 5. Шаг 2. Сервер агентов

### Каталоги

- каталог агентов с подкаталогами: lib (общие скрипты), bin (обёртки), state (файлы состояния), locks (замки), work (рабочие копии), по одному каталогу на вид агента (внутри — run.sh и PROMPT.md);
- каталог репозиториев (общие копии для выкаток);
- каталог журналов: по файлу на агента.
### Окружение

- Установите Claude Code, проверьте, что claude -p «привет» отвечает под тем пользователем, от которого пойдут таймеры.
- Выпустите долгоживущий токен (claude setup-token), положите в файл с правами 600. Скрипт запуска читает его и экспортирует CLAUDE_CODE_OAUTH_TOKEN.
- ssh: ключ без пароля до центрального репозитория и до боевого сервера; в конфиге ssh — псевдонимы для них. Для частых обращений к базе держите мультиплексирование соединения (ControlMaster=auto, ControlPersist=10m): сервер ssh режет слишком частые подключения.
- Если машина в другом часовом поясе, чем люди, договоритесь, в каком времени пишете отчёты. Время в отчёте берите командой date, а не «прикидкой».
### Клиент Вторума (vt.py)

Единственный способ доступа скриптов и агентов ко Вторуму — маленький клиент vt.py (Приложение В, python3, только стандартная библиотека). Он читает ключ бота из файла и умеет: tasks (записи по статусам), get (одна запись), ready (готовые задачи), claim (атомарно взять задачу), set (записать свойства с замками), lock, materials (задание целиком), file (скачать вложение), summary (резюме в конец тела записи). Вывод — строки с полями через «|»: id | status | версия | ошибка | обновлено | правка | замок(0/1) | worker | создана | блокируется | тесты | название. Рядом лежит обёртка bin/vt: из PATH агента команда vt вызывает клиент с нужными настройками.

Опрос API — раз в минуту на агента (планировщик делает два-три запроса). Если у вашего Вторума есть ограничение числа запросов на токен, выберите один из путей: меньше агентов, реже таймер или один общий планировщик, который раз в минуту записывает результат в файл, а агенты читают файл.

### Файл констант (db.sh)

Подключается (source) всеми скриптами проекта. В нём: идентификатор базы задач (из ids.sh), адрес Вторума и файл ключа, список значений worker, которые агент не берёт (человек делает задачу сам из чата), адреса эндпоинтов версии dev и prod, пути замков и состояния, функции vt, vver (версия стенда из эндпоинта), vle (сравнение версий как чисел по частям, а не строк: строкой 5.32.99 больше 5.32.101), vname (кодовое имя агента по номеру), tstatus (статус задачи), vtests (отметка тестов версии), model_ok и not_human (отбор строк без задач человека).

## 6. Шаг 3. Планировщик, захват задачи и условие готовности

### Условие READY

Задача в todo готова к работе, если одновременно:

- на ней нет замка;
- в «DEV · Последняя ошибка» пусто, либо человек правил задачу уже после этой ошибки (время последней правки записи last_edited_time больше «DEV · Обновлено» плюс две минуты: агент всегда пишет «Обновлено» последним, поэтому его собственные записи под это условие не попадают), либо ошибка — типа «Тесты сборки … красные» (это возврат на починку, а не отказ);
- нет блокирующих задач, которые не дошли до commit (раздел 15);
- задача не взята человеком из чата (значение worker входит в список HUMAN_WORKERS: пока человек делает задачу сам, агент её не берёт).
Это условие — единственное место, где решается «можно ли брать»: функция ready_tasks в vt.py. Её используют и планировщик, и захват — тогда они не расходятся.

### Планировщик (plan)

Быстрый скрипт без модели. Запрашивает Вторум (API) и печатает по строке на вид работы:

- prod — есть задачи в deploy/prod без ошибки или задачи в test/dev, уже накрытые версией прода (их надо перевести в test/prod); при этом тесты версии на dev не «идут» и не «красные»;
- todo — есть готовая (READY) задача в todo;
- devdeploy — готовых todo нет, задач in progress нет, упавшей выкатки нет, есть что-то в commit или deploy/dev без ошибки.
Выход 0 — что-то есть; 1 — работы нет (тогда модель не запускается вовсе); печать «error» — база не ответила (прогон пропускается).

### Атомарный захват (claim)

Два агента не должны взять одну задачу. У API нет транзакций и блокировок строк (в SQL это был FOR UPDATE SKIP LOCKED), поэтому захват сериализуется файловым замком на сервере агентов, а результат проверяется:

- под замком (flock на файле в каталоге замков) команда vt claim берёт список готовых задач (READY), самую старую из них перечитывает заново и убеждается, что она всё ещё в todo и не заблокирована;
- в одной записи ставит status = in progress, «DEV · Обновлено» = сейчас и worker = кодовое имя агента, затем замок задачи;
- печатает «id|название»; нечего брать — ничего не печатает и возвращает код 1.
Условие: все захваты идут через эту машину. Агент на другой машине (ноутбук) берёт задачу, вызывая claim.sh по ssh на сервере агентов (Приложение О). Два сервера агентов с общей базой так работать не смогут без общего замка (например, запись «кто взял» в самой задаче с проверкой после записи); один сервер на базу — рабочая схема.

Скрипты захвата и планировщика — в Приложении Г.

## 7. Шаг 4. Репозитории и git-дисциплина

- central — голый репозиторий, единая точка правды.
- Общая копия — ветка main, только для выкаток: установщик пакует именно рабочее дерево этой копии. В ней агенты не разрабатывают. Во время выкатки её не правят.
- Рабочая копия агента — отдельный клон для каждого агента, ветка agent-N. Перед каждой задачей агент переключается на свежий origin/main (switch -C), ставит зависимости с замороженным lock-файлом. Если копия не чиста — задачу не берёт и сообщает, что нужен человек.
- Влить работу в central можно только скриптом integrate под замком: он забирает origin, при чужих коммитах делает merge (без rebase и без переписывания истории: с историей работают и люди, у которых есть автосинхронизация), останавливается и просит прогнать проверки, при конфликтах перечисляет файлы, и только когда ничего нового нет — делает push. Итог — последняя строка: OK <хеш> / СЛИТО … / КОНФЛИКТ … / ОШИБКА …. Пока нет OK, статус commit не ставят: commit означает «код уже в central и поедет с ближайшей выкаткой».
- Запрещено агентам: commit --amend, reset, stash, checkout чужих файлов, форматирование чужих файлов «заодно», деструктивные команды в проде. Миграции — только файлами, их применяет установщик.
- Если проект ведёт один агент и больше никто не коммитит, вместо merge допустим rebase перед push (проще история).
## 8. Шаг 5. Версии релизов

Номер версии должен быть свободным, и два агента не должны получить один. Скрипт next-version под замком берёт максимум из четырёх источников: версия в central, версия в рабочей копии, версия на dev, версия на проде (и ещё «зарезервированный» номер, который выдан другому агенту, но ещё не попал в central). Дальше увеличивает патч-номер и сохраняет резерв. В этой системе агентам достаются только нечётные патч-номера, чётные оставлены людям (чтобы человек с ноутбука и агент не столкнулись).

Номер версии часто зашит в нескольких файлах (манифесты пакетов, установщик, compose-файл, service worker, клиентские константы, документация). Составьте список мест и вшейте его в промпт: гвардия (см. ниже) ловит пропущенные места, но по одному за прогон — дорого. Конфликт версий при слиянии — норма: оставляется больший номер во всех местах, а в списке изменений сохраняются обе строки.

## 9. Шаг 6. Проверки: гвардии, типы, тесты

Три уровня, от быстрого к медленному:

1. При разработке (агент): проверка типов и только те тесты, что связаны с изменёнными файлами (vitest related от точки расхождения с main — merge-base, а не от origin/main, иначе попадут чужие правки). Файлы с номером версии и сгенерированные исключаются из списка, иначе «связанные» превращаются в полный прогон. Полный прогон при разработке не запускают — кроме правок общих мест (схема базы, общие пакеты, роутинг).
1. Гвардии: маленькие node-скрипты, которые проверяют содержимое файлов (assert по строкам) и вшиты в сборку образа. Каждая задача добавляет свою гвардию (комментарий «почему» сверху, строка «версия: суть — ок»). Весь набор гвардий прогоняется скриптом guards за 20–60 секунд перед коммитом и перед выкаткой: именно на них падает сборка, а поймать это дешевле до неё.
   Новые гвардии дописывайте в существующую цепочку RUN через &&, а не отдельным RUN: у Docker есть предел слоёв.

1. Полный набор тестов: запускается в фоне после выкатки на dev; результат — файл-отметка на боевом сервере с полями status (running/green/red), версия, коммит, время, краткое резюме. Промоут на прод без status=green отказывает. Скрипт tests-sync (таймер раз в минуту) переносит отметки в поле «Тесты» задач и: при красных заводит ОДНУ задачу на починку причины (с упавшими проверками, списком вошедших задач и пошаговой инструкцией); статусы задач сборки не меняет: их код уже в central, переделывать его не нужно (раньше все задачи после последней зелёной возвращались в todo и делались заново, а причина была одна — пропавшая при слиянии зависимость). Если после красной сборки уже есть зелёная, причина исправлена и задача не заводится; задачи с версией не выше такой зелёной сборки получают отметку «зелёные»; отметку running без итога дольше 20 минут считает оборвавшейся и ставит red с понятной причиной (если процесс прогона жив — не трогает).
## 10. Шаг 7. Выкатки — скриптами, без модели

Оба скрипта запускаются под общим замком выкаток (одна выкатка за раз по всей системе). Замок берёт run.sh на файловом дескрипторе и держит весь прогон; дочерний процесс его наследует.

### Выкатка на dev (devdeploy)

1. Под замком слияния: чистота общей копии, pull без rebase, определение версии в дереве, выбор пачки (все задачи в commit и задачи в deploy/dev без ошибки).
1. Вся пачка — в deploy/dev и под замок (по запросу на задачу: у API нет транзакций); сразу проверка, что у всех именно так (иначе стоп).
1. Гвардии. Падение — сбой.
1. Установщик (с таймаутом). Успех определяется по маркеру в его выводе (строка с версией), а не по коду возврата. Если установщик отказал с «central не является предком локального HEAD» (пока шла подготовка, агенты влили новое в central), скрипт сам, без модели, до трёх раз: под замком слияния подтягивает central, добавляет влитые за это время задачи в пачку (deploy/dev + замок), пересчитывает номер версии и повторяет установщик. Любой другой отказ — сбой по общим правилам.
1. Проверка: эндпоинт версии dev отвечает выкаченным номером.
1. Пачка → test/dev, очистка ошибок, «Обновлено», самой свежей задаче — реальный номер; замки сняты. Уведомление о выкатке отправляется отдельно и на конвейер не влияет.
1. Код возврата: 0 — выкачено, 1 — сбой (нужна модель-починка), 2 — выкатывать нечего.
### Выкатка на прод (proddeploy)

1. Задачи со статусом deploy/prod (без ошибки) и test/dev; без версии → пометка «ожидает выкатки на dev».
1. Всё, что накрыто версией прода (версия ≤ прод), — сразу в test/prod.
1. Если есть что выкатывать (версия выше прода, не выше dev): проверить отметку тестов версии dev (green — едем; running/red — ждём без ошибки; нет отметки — сбой).
1. Собрать пачку: всё из test/dev и deploy/prod с версией в диапазоне (прод; dev] — код уже на dev и уедет вместе. Перевести в deploy/prod под замки, проверить.
1. Промоут (с таймаутом), проверить маркер успеха и эндпоинт версии, подождать и просмотреть журнал API на ошибки.
1. Пачка → test/prod, замки сняты.
Прод уезжает целиком сборкой с dev, не отдельной задачей — поэтому в пачку входят все задачи с версией не выше dev.

### Если выкатка упала

- Скрипт пишет описание сбоя в файл состояния и возвращает 1.
- run.sh запускает модель в режиме «починка» с этим описанием: она смотрит статусы (пачка уже в deploy/* под замками — не перевыбирать), чинит причину (конфликт, гвардия, номер версии, установщик) и доводит шаг до конца. Не вышло — по правилам «если выкатка не удалась»: замки сняты, вся пачка остаётся в deploy/*, каждой задаче записана одна и та же причина. Ошибка у задачи — сигнал «выкатка ждёт человека»: следующие прогоны её не трогают.
- Если модели нет (см. раздел 12), отдельный скрипт nomodel-fail делает то же без модели: пишет ошибку пачке и снимает замки.
## 11. Шаг 8. Скрипт запуска агента (run.sh)

Один файл на вид агента, параметр — номер агента. Запускается таймером раз в минуту. Скелет логики:

1. Подключить db.sh, получить номер, кодовое имя (vname), рабочую копию, журнал, файл «моя незавершённая задача».
1. Запросить планировщик. Если работы нет: незавершённая задача этого агента всё равно продолжается (иначе при лимите подписки все прогоны уйдут «в нет работы», а задачи так и останутся in progress). Нет ни того ни другого — выход.
1. Если есть файл незавершённой задачи и она всё ещё in progress — режим resume (продолжить в той же рабочей копии). Если она уже в другом статусе, а в копии остались правки — остановка «нужен человек»; чисто — удалить файл.
1. Приоритет режимов: prod (скрипт proddeploy под замком выкаток; успех — уведомление и выход; сбой — режим prod-fix для модели; «делать нечего» — замок отпускается и идём дальше) → task (подготовить рабочую копию, захватить задачу скриптом claim, записать файл состояния) → devdeploy (скрипт devdeploy; 0 — уведомление и выход; 2 — выход; 1 — режим devdeploy-fix).
1. Проверка «модель вообще запустится» (защита, раздел 12). Если нет: сбои выкатки записываются в задачи без модели, выход.
1. Сформировать «ЗАДАНИЕ ЭТОГО ПРОГОНА»: режим, имя агента, рабочая копия, задача. Оно стоит первым в промпте и главнее всего остального.
1. Запустить claude в неинтерактивном режиме с таймаутом и списком разрешённых инструментов:
```bash
timeout 5400 claude -p "$HEADER

$PROMPT" --model <МОДЕЛЬ> --effort medium \
  --allowedTools "Bash,Read,Edit,Write,Glob,Grep,TodoWrite,ToolSearch,WebFetch,WebSearch,Agent" >> "$LOG" 2>&1
```

1. После завершения: если задача больше не in progress — удалить файл состояния; записать итог в журнал.
Уведомления («выкачено на dev/на прод», «задача закоммичена») отправляйте отдельным фоновым вызовом лёгкой модели с таймаутом — на конвейер они не влияют.

Важное про скрипты, которые читает bash на лету: bash читает скрипт по ходу выполнения. Правка файла «на месте» (редактором, sed -i) во время прогона ломает его (синтаксические ошибки на полпути). Всегда: скопировать файл → править копию → bash -n → заменить целиком командой mv.

Пути в промпте. В промпте агента указан путь общей копии; для работы в личной копии run.sh подменяет его на путь личной копии перед запуском.

## 12. Шаг 9. Устойчивость к сбоям

- Лимит подписки: прогон обрывается в середине задачи. Решается файлом состояния «моя незавершённая задача» и режимом resume — следующий прогон дорабатывает задачу в той же рабочей копии. Если после лимита все агенты стоят, проверьте, не блокирует ли плановщик resume.
- Нет модели на этой машине: хостинг может сбросить тип процессора до модели без нужных инструкций (SSE4.2/AVX2); тогда Claude Code зависает на 100% CPU. Функция model_ok проверяет флаги процессора; если нет — модель не запускают, задачи не берут, выкатки идут скриптами, а сбой записывается без модели. О причине пишется один раз в час.
- Сервер перезагрузился: таймеры стартуют сами; блокировки файлов исчезают; незавершённые задачи возобновляются; службы, зависящие от сети (nginx и подобные), должны ждать готовности сети (override с ожиданием сети) — иначе после перезагрузки падают на «host not found upstream».
- Одновременные прогоны одного агента: тип сервиса oneshot: пока прогон идёт, systemd не запустит следующий тик. TimeoutStartSec делайте чуть больше таймаута claude; KillMode=process — чтобы выход прогона не убивал фоновые процессы (прогон тестов в фоне).
- Модель падает при незнакомом идентификаторе: предупреждение «unrecognized model» у новой модели безобидно (окно считается по умолчанию); обновите Claude Code и предупреждение уйдёт.
- Сторож: скрипт, который раз в несколько минут по ssh проверяет аптайм, перезагрузки, нагрузку, место на диске и отвечаемость, и печатает строку только при событии. Запускайте его как наблюдателя (monitor) в сессии оператора: так перезагрузки не остаются незамеченными.
- Резервный агент на ноутбуке (launchd): тот же run.sh, но: ssh к серверу через промежуточный хост (напрямую VPN может резать), свои рабочие копии на образе диска с учётом регистра (на macOS файловая система без учёта регистра ломает проекты, где пути различаются регистром), файл паузы (state/paused) для остановки без отключения службы, обёртки над скриптами сервера. Для починки сбоев выкатки без модели у него есть отдельный документ REPAIR. Правки его run.sh — тоже только целым файлом.
- Резервные копии: база Вторума (дамп — параллельный, чтобы не занимать БД), состояние сервера (зашифрованный архив). Проверьте восстановление до того, как оно понадобится.
## 13. Шаг 10. Параллельные агенты и ресурсы

- Каждому агенту — своё кодовое имя (оно же значение select в поле worker) и свой номер. Имена выбирайте так, чтобы было видно, где живёт агент.
- Очередь тяжёлых проверок. Четыре агента, одновременно гоняющие типы и тесты, дают нагрузку 10 на 8 ядер. Скрипт heavy-run даёт не больше N (в системе — 2) слотов: слот — это файл-замок (flock -n), цикл перебирает слоты и ждёт; через 20 минут запуск идёт без очереди (лучше медленно, чем встанет прогон). Вложенные вызовы слот второй раз не берут (переменная окружения), иначе взаимная блокировка.
- Обёртки над менеджером пакетов. Каталог bin с обёртками pnpm и npx стоит первым в PATH у run.sh: если среди аргументов тяжёлая команда (test, typecheck, build, vitest, tsc), запуск идёт через heavy-run, иначе напрямую. Модель ничего об очереди не знает.
- Базовый образ системных пакетов. Если сборка образа ставит системные пакеты, вынесите их в отдельный базовый образ с тегом по хешу списка пакетов; иначе чистка кеша сборки раз в сутки пересобирает слой по полчаса. Чистить кеш — только по предельному объёму (--max-used-space), чистка по возрасту не работает.
- Вторум: нагрузка на его API — один опрос в минуту на агента. Обслуживание самой базы Вторума (число соединений, срок хранения журналов, VACUUM) — дело владельца сервера Вторума, не конвейера.
- Общий замок выкаток — один на проект; замки слияния и версий — тоже общие.
- Диск: следите; образ диска и кеш сборки — главные пожиратели.
## 14. Шаг 11. Промпт агента (PROMPT.md)

Промпт — это инструкция по работе, написанная для исполнителя, который не может переспросить. Он читается целиком в каждом прогоне. Структура разделов (порядок важен):

1. Роль и тон. «Ты — автообработчик задач разработки. Работай молча и по делу; вопросов пользователю нет — если задача невыполнима или неясна, запиши причину в задачу».
1. Как ты запущен. Таймер раз в минуту, без интерактивного ввода; режим выбрал скрипт.
1. Язык. Весь текст (отчёт, резюме, коммиты, комментарии, интерфейс) — на языке команды.
1. Уведомления. Только по факту действий, одна строка, начинается с сути, ничего не отправлять, если делать было нечего.
1. Среда. Пути репозиториев и рабочих копий, как запускать установщик и промоут (с какими переменными), как работать со Вторумом (команда vt), какие стенды и где версия. Запрет опасных git-команд.
1. База задач. Имена и типы всех свойств; шпаргалка команд vt (get, set, tasks, lock, materials, file); как читать задание целиком; как записывать значения.
1. Блокировка записи. Когда ставить и обязательно снимать замок; правило «чужой замок не трогать».
1. Резюме в конце задачи. Обязательный блок в теле записи тремя частями: «Что сделано», «Что не получилось» (честно), «На что обратить внимание». Пишется для человека, без путей и внутренних деталей, до снятия замка и смены статуса. Подаётся обёртке summary на стандартный ввод «встроенным документом» с именем в кавычках (иначе символы $ и кавычки раскроются). Не клади резюме в переменную с кириллическим именем — bash такие имена не знает.
1. Особенности стендов. Что на dev не обновляется (если у вас воркеры и фоновые службы живут на боевом образе): правка таких частей на dev не проявится, об этом пишется отдельным пунктом в «На что обратить внимание», а не подменяется другой причиной.
1. Из чего состоит задание. Читать всё: поле, вложения, тело; запуск task-materials и fetch-asset; требование «только во вложении» — тоже требование; не скачивается файл — это error, а не повод делать по одному тексту.
1. Не хватает данных → error. Перечень признаков; порядок действий (откат незакоммиченного, резюме с конкретикой, что дописать, ошибка, снять замок, статус, уведомление).
1. Порядок прогона и жизненный цикл. Один режим на прогон; схема статусов; done не ставить никогда.
1. Шаг 1 (прод). Правила пачки и тестов (на случай, когда скрипт споткнулся), перевод выкаченного в test/prod, сравнение версий числами.
1. Зависимости. Раздел 15.
1. Шаг 2 (задача). Статус как переключатель; реализация: чтение материалов, минимальные правки в стиле соседнего кода, тесты, гвардия, версия во всех местах, документация, гвардии перед коммитом, коммит, integrate и разбор его ответов, запись результата, резюме, снять замок.
1. Шаг 3 (dev). Порядок для случая починки.
1. Если выкатка не удалась. Что записывать и чего НЕ делать (не повторять самой).
1. Если человек тронул задачу из пачки. Не менять его статус и не включать в пачку; отметить в отчёте.
1. Ограничения. Одна задача и одна выкатка за прогон; не рефакторить и не форматировать чужое; никаких деструктивных команд; отчёт по-русски в конце (что взято, версия, выкатки, статусы).
Приёмы, которые окупились: (а) «ЗАДАНИЕ ЭТОГО ПРОГОНА» первым блоком и с пометкой «главнее всего остального»; (б) одинаковые правила, написанные дважды, лучше противоречий — повторяйте самое важное (снять замок) в нескольких местах; (в) конкретные команды вместо описаний; (г) явные «стоп-условия» (человек сменил статус — остановись); (д) не вшивайте в промпт механику, которую делает скрипт, — только то, что нужно при починке.

## 15. Шаг 12. Зависимости между задачами

В базу добавляется двусторонняя связь на саму себя: «Блокируется» (какие задачи нужно сделать раньше) и зеркальная «Блокирует». Через API v2 пара создаётся одним запросом PATCH базы: свойство типа связь с идентификатором этой же базы, allow_multiple, create_reciprocal: true и reciprocal_name. Значение записывается у зависимой задачи: PATCH страницы со свойством «Блокируется» и массивом связей [{id: <блокирующая>}]; зеркало проставляется само. Клиент читает связь из свойства «Блокируется» (список id) и сверяет статусы блокирующих по тому же запросу.

Правило, которое добавляется в условие READY: зависимая остаётся в todo и не берётся, пока хоть одна блокирующая не дошла до commit. Дошедшими считаются статусы commit, deploy/dev, test/dev, deploy/prod, test/prod, done (код блокирующей уже в central, зависимая ребейзится/мёрджится поверх него). Блокируют: backlog, todo, in progress, error, cancel. Блокирующая в корзине зависимость не держит.

Следствия:

- Условие работает для всех: и для планировщика (задача не будит агента), и для захвата.
- Отменённая блокирующая держит зависимую, пока человек не уберёт связь — это сознательно: пусть человек решит.
- Цикл блокирует обе задачи навсегда.
- Связь, добавленная уже взятой задаче, ничего не остановит: проверка действует при захвате.
- Агенту поле менять нельзя — это решение человека. Человек и чаты (в том числе инструкции для ассистентов), формируя поток связанных задач, сразу заполняют «Блокируется» у зависимой и ставят ей todo.
Проверка после внедрения: создайте две пустые задачи в todo, свяжите их и вызовите vt ready при разных статусах блокирующей (в песочнице так проверены все статусы: блокируют backlog, todo, in progress, error, cancel; снимают commit и дальше); затем уберите тестовые задачи в корзину.

## 16. Шаг 13. Несколько проектов на одном сервере

Схема переносится как есть. Для каждого проекта: своя база задач, свой файл констант, свои скрипты выкатки, свой каталог агентов, свои таймеры, свои замки (выкаток, слияния, версий), свои журналы. Общие: сервер, Claude Code и токен, очередь тяжёлых проверок и каталог обёрток. Шаблоны systemd позволяют запускать N агентов одной юнит-парой: юнит со знаком @ и параметром — номером агента.

```bash
# agent@.service
[Unit]
Description=Агент разработки № %i
After=network-online.target
[Service]
Type=oneshot
ExecStart=/<КАТАЛОГ>/run.sh %i
TimeoutStartSec=5700
KillMode=process

# agent@.timer
[Unit]
Description=Запуск агента № %i раз в минуту
[Timer]
OnBootSec=3min
OnUnitActiveSec=1min
AccuracySec=1s
Unit=agent@%i.service
[Install]
WantedBy=timers.target
```

Включение: systemctl enable --now agent@1.timer agent@2.timer (и так далее). Добавить агента — завести значение в select worker, имя в vname, включить таймер, подготовить рабочую копию. Если базу задач нужно перенести в другое пространство, скопируйте её средствами интерфейса, обновите идентификаторы свойств в файле констант (они меняются!) и поправьте скрипты и промпт.

## 17. Шаг 14. Как человеку работать с системой

- Завести задачу: название, строка в «problem to solve», подробности блоками в теле, вложения. Проверить критерии приёмки. Только после этого — todo.
- Остановить: перевести задачу в backlog; агент прекратит работу, готовый код закоммитит, замок снимет.
- Получить резюме: открыть задачу — в конце тело резюме агента. Читать «На что обратить внимание».
- Релиз на dev случается сам, когда todo пуст. Проверить на dev, затем сменить статус на deploy/prod (можно сразу пачкой). После проверки на проде — done.
- error: дописать недостающее и вернуть в todo. Если у задачи ошибка выкатки — разобрать причину, затем очистить ошибку или вернуть в commit.
- Если задачу делает человек из чата, ставить worker = имя этого чата вместе со статусом in progress. Агенты такие задачи не берут.
- Зависимые задачи — раздел 15.
- Сами правила для ассистентов (чатов) запишите в их глобальные инструкции, чтобы они одинаково заводили и вели задачи.
## 18. Типичные грабли и как их обойти

- Правка скрипта «на месте» во время прогона ломает bash — всегда заменять файл целиком.
- Заблокированная запись отклоняет любую правку, в том числе от агента (423): запись всегда «снять замок — записать — вернуть замок» (это делает vt.py). Не пишите во Вторум напрямую curl-ом в обход клиента, пока запись под замком.
- У API нет транзакций: пачку записывают по одной задаче и проверяют результат; при сбое посреди пачки скрипты оставляют задачи в deploy/* под замками с описанием причины.
- Захват задачи атомарен только внутри одной машины (файловый замок): все агенты берут задачи через сервер агентов.
- Кириллические имена переменных bash не понимает — переменная с резюме станет буквальной строкой.
- API v2 в старых версиях записывал select без id варианта: интерфейс не находил значение. Создавайте базу скриптом из Приложения А: он задаёт id вариантов сам. Для старых баз создайте варианты заново в интерфейсе.
- Забытый замок на задаче — самая неприятная поломка: человек не может её править. Снимать замки в любом выходе, повторное снятие безвредно.
- Номера версий сталкиваются, если выдавать их без замка и без учёта «выданного, но не влитого» номера.
- Автосинхронизация репозитория у человека: история не должна переписываться (никаких amend, reset, force-push).
- Выкатка по таймауту при холодном кеше сборки — выносить системные слои в базовый образ.
- Сеть с ноутбука рвёт ssh посреди сборки — тяжёлые операции запускать на сервере в сессии, не зависящей от соединения (tmux или systemd-run).
- Процессор «слетел» после смены тарифа хостера — защита model_ok и сторож.
- Установщик пакует рабочее дерево: во время выкатки не править файлы в общей копии.
- Устаревшую гвардию не удаляют, а правят, сохранив её смысл.
- Первое подключение ssh к новому хосту зависает на подтверждении ключа — заранее добавить в known_hosts.
- Лимиты подписки при четырёх агентах — разведите расписания, ограничьте число агентов, используйте более экономную модель для рутины.
- Уведомления не должны быть частью конвейера: обёртка с таймаутом в фоне.
- Модель любит «улучшать» — запретите рефакторинг и форматирование вне задачи прямо в промпте.
## 19. План внедрения по этапам

Этапы 1–4 установщик выполняет за один запуск (приложение Р): база, скрипты, рабочие копии, таймеры, самопроверка. Ниже — что именно получается на каждом этапе и как это принимать.

1. День 1: каркас. База задач со свойствами; файл констант и клиент vt.py; один агент без выкаток: plan + claim + run.sh в режиме task до commit; рабочая копия; integrate; PROMPT.md в части шага 2. Ручной запуск run.sh, потом таймер.
1. День 2: резюме и ошибки. summary, task-materials, fetch-asset; раздел «не хватает данных»; замки задач; возобновление после обрыва.
1. День 3: выкатки. devdeploy и proddeploy скриптами; замок выкаток; отметки тестов и tests-sync; режимы починки и nomodel-fail.
1. День 4: масштаб. Второй, третий агент; vname и worker; очередь тяжёлых проверок и обёртки; базовый образ.
1. День 5: устойчивость. model_ok, сторож, резервный агент на ноутбуке, резервные копии, проверка восстановления.
1. Дальше: зависимости, второй проект, уведомления, мониторинг очередей.
Приёмка каждого этапа:

- этап 1: тестовая задача (поменять строку интерфейса) проходит todo → in progress → commit; в central появился коммит, в задаче — резюме, замок снят;
- этап 2: задача без данных уходит в error и не берётся повторно; оборванный прогон дорабатывается;
- этап 3: пачка из двух задач выкатывается одним релизом на dev, затем на прод; при красных тестах пачка возвращается в todo; при сбое установщика статусы остаются в deploy/dev с ошибкой;
- этап 4: два агента одновременно не берут одну задачу, не получают один номер версии, тяжёлые проверки не идут более чем в двух потоках;
- этап 5: после перезагрузки сервера все таймеры работают без ручных действий.
## 20. Диагностика: «агент не берёт задачу»

Идите по списку сверху вниз:

1. Таймер включён и активен? Последний прогон завершился? Журнал агента — последние строки.
1. Планировщик что-то печатает? Если «работы нет» — задача не READY. Проверьте: статус именно todo; нет замка; нет ошибки в «DEV · Последняя ошибка» (или есть, но человек правил после неё — тогда подождите две минуты после правки); нет блокирующих задач, не дошедших до commit; worker не занят чат-пометкой.
1. Рабочая копия агента чиста? Если нет — журнал скажет «нужен человек».
1. Модель доступна? Журнал с предупреждением model_off — процессор или токен; файл токена не истёк (ключ выдан на год).
1. Замок выкаток занят долгой выкаткой? Тогда режимы prod и devdeploy ждут, а задачи идут своим чередом.
1. Лимиты подписки? В журнале будет сообщение лимита; задача возобновится сама.
1. Сбой базы или сети: планировщик печатает «error», прогон пропускается.
## 21. Что куда класть: краткий реестр скриптов

- db.sh — константы и функции (vt, vver, vle, vname, vtests, model_ok).
- vt.py — клиент Вторума: все чтения и записи через API; готовность задачи (READY) и захват внутри него.
- plan — какие режимы нужны сейчас.
- claim — атомарный захват задачи.
- run.sh — запуск агента и выбор режима.
- PROMPT.md — инструкция модели.
- next-version — свободный номер релиза.
- integrate — влить работу в central.
- guards — быстрые проверки сборки.
- devdeploy / proddeploy — выкатки без модели.
- tests-sync — перенос отметок тестов в задачи, возврат виноватых пачек.
- nomodel-fail / repair-done — запись сбоя без модели и закрытие починки.
- summary — резюме в конец задачи.
- task-materials / fetch-asset — все материалы задачи и скачивание вложений (через API).
- heavy-run и обёртки pnpm/npx — очередь тяжёлых проверок.
- system-watch — сторож сервера.
## 22. Итоговый чек-лист готовности

- База задач создана, свойства и статусы совпадают с файлом констант.
- Агент берёт тестовую задачу, коммитит, пишет резюме, снимает замок.
- Два агента параллельно: нет двойных захватов, разные номера версий, очередь проверок работает.
- Выкатка на dev и на прод идёт скриптами; при сбое остаются ошибки в задачах, а не тишина.
- Тесты в фоне, отметки и поле «Тесты» работают; красная сборка не уходит на прод.
- Зависимые задачи не берутся, пока блокирующая не в commit.
- После перезагрузки сервера всё поднимается само; сторож пишет о перезагрузке.
- Резервный агент проверен и стоит на паузе.
- Инструкции для людей и ассистентов записаны: как ставить задачи, как останавливать, как принимать.
## Приложения: рабочий код

> Выше — устройство системы, ниже — код, с которого её можно собрать. Порядок установки — в последнем приложении (Р). Все скрипты работают с Вторумом только через API v2 (SQL не используется). Скрипты обобщены из действующей системы и переписаны под API: адреса, имена и идентификаторы заменены заглушками в угловых скобках. Статус проверки у каждого приложения указан в первой строке. Если вы собираете систему с нуля, идите по порядку приложений: каждое опирается на предыдущие. Пути в примерах: скрипты — /srv/agents/lib, состояние — /srv/agents/state, замки — /srv/agents/locks, проект — /srv/dev/<ПРОЕКТ>.

## Приложение А. Создание базы задач конвейера

Проверено: скрипт создаёт базу со всеми свойствами и двусторонней связью зависимостей; на созданной базе запись задачи со статусом по имени сохраняет вариант с id. Нужен ключ бота (системного пользователя) Вторума с правом записи в пространство, где будет база, и id страницы-родителя (база появится внутри неё).

Что делает скрипт: создаёт базу со свойствами из раздела 4 (title, status с 11 вариантами, problem to solve, attaches, пять DEV-полей, worker с вашими именами агентов, Тесты), затем создаёт пару связей «Блокируется / Блокирует» и печатает файл констант ids.sh: идентификатор базы (идентификаторы свойств — комментариями, для справки: скрипты находят свойства по имени). Идентификаторы вариантов выбора задаются явно: через API без id интерфейс не сопоставляет значение с вариантом.

```bash
#!/usr/bin/env python3
"""Создаёт базу задач конвейера во Вторуме и печатает файл констант db.sh.
Запуск: VTORUM_URL=https://<хост> VTORUM_TOKEN=<ключ бота> python3 create_pipeline_db.py <id страницы-родителя> ["Название базы"] ["worker1,worker2,..."]
Ключ бота нужен с правами на страницы и базы пространства; родитель — страница, где появится база."""
import json, os, sys, urllib.request, urllib.error, uuid

URL = os.environ["VTORUM_URL"].rstrip("/")
TOKEN = os.environ["VTORUM_TOKEN"]
parent = sys.argv[1]
title = sys.argv[2] if len(sys.argv) > 2 else "bugs&tasks"
workers = (sys.argv[3] if len(sys.argv) > 3 else "agent one,agent two").split(",")

def call(method, path, body=None):
    req = urllib.request.Request(URL + path, method=method,
        data=json.dumps(body, ensure_ascii=False).encode() if body is not None else None,
        headers={"Authorization": "Bearer " + TOKEN, "Content-Type": "application/json"})
    try:
        with urllib.request.urlopen(req) as r: return json.load(r)
    except urllib.error.HTTPError as e:
        sys.exit(f"{method} {path}: {e.code} {e.read().decode()[:500]}")

def opts(pairs): return {"select": {"options": [{"id": str(uuid.uuid4()), "name": n, "color": c} for n, c in pairs]}}
rt = {"rich_text": {}}

STATUSES = [("backlog","stone"),("todo","blue"),("error","red"),("in progress","green"),("commit","gray"),
            ("deploy/dev","green"),("test/dev","violet"),("deploy/prod","mint"),("test/prod","violet"),
            ("done","yellow"),("cancel","stone")]
COLORS = ["blue","green","violet","mint","yellow","gray","red","stone"]
props = {
    "Название": {"title": {}},
    "status": opts(STATUSES),
    "problem to solve": rt,
    "attaches": {"files": {}},
    "DEV · Версия": rt,
    "DEV · Ветка": rt,
    "DEV · Commit": rt,
```

```bash
    "DEV · Последняя ошибка": rt,
    "DEV · Обновлено": {"date": {}},
    "worker": opts([(w.strip(), COLORS[i % len(COLORS)]) for i, w in enumerate(workers)]),
    "Тесты": opts([("идут","yellow"),("зелёные","green"),("красные","red")]),
}
db = call("POST", "/v2/databases", {"parent": {"page_id": parent},
      "title": [{"type": "text", "text": {"content": title}}], "properties": props})
dbid = db["id"]
# зависимости: пара связей на саму базу (вторая сторона создаётся сама)
call("PATCH", f"/v2/databases/{dbid}", {"properties": {"Блокируется": {"type": "relation", "relation": {
    "database_id": dbid, "allow_multiple": True, "create_reciprocal": True, "reciprocal_name": "Блокирует"}}}})
schema = call("GET", f"/v2/databases/{dbid}")["properties"]
pid = lambda n: schema[n]["id"]
print(f"""# Константы базы задач — подключается (source) через db.sh.
# Свойства скрипты находят по имени, их id нужны только для справки.
DB={dbid}
# status={pid('status')}  DEV · Версия={pid('DEV · Версия')}  DEV · Ветка={pid('DEV · Ветка')}  DEV · Commit={pid('DEV · Commit')}
# DEV · Последняя ошибка={pid('DEV · Последняя ошибка')}  DEV · Обновлено={pid('DEV · Обновлено')}  worker={pid('worker')}  Тесты={pid('Тесты')}
# Блокируется={pid('Блокируется')}  Блокирует={pid('Блокирует')}  problem to solve={pid('problem to solve')}""")
```

После запуска: сохраните вывод в ids.sh, откройте базу в интерфейсе и сделайте таблицу или доску по статусам. Для бота-автора задач и для людей выдайте доступ к базе. Новые значения worker добавляйте в настройки свойства (и в vname, Приложение В).

## Приложение Б. Какие запросы к API делают скрипты

Проверено на тестовой базе: все запросы ниже выполнены и дали описанный результат. Адрес — ваш Вторум, заголовок Authorization: Bearer <ключ бота>, Content-Type: application/json.

- Записи базы: POST /v2/databases/{id}/query. Тело: {"filter": {"property": "status", "select": {"equals": "todo"}}, "page_size": 100, "start_cursor": "<курсор>"}. Несколько статусов — {"or": [условие, условие]}; «все, кроме done» — {"property": "status", "select": {"does_not_equal": "done"}}. Ответ: results (полные записи со свойствами), has_more, next_cursor. Сортировки по created_time в теле нет (нужно свойство): сортируйте на стороне клиента по полю created_time записи.
- Одна запись: GET /v2/pages/{id}. Нужные поля: properties[<имя>] (тип и значение), created_time, last_edited_time, locked, in_trash.
- Запись свойств: PATCH /v2/pages/{id} с {"properties": {...}}. select — {"select": {"name": "todo"}}, сервер подбирает вариант по имени и сохраняет его с id; сброс — {"select": null}. rich_text — {"rich_text": [{"type": "text", "text": {"content": "текст"}}]}; пустое значение — []. date — {"date": {"start": "2026-10-22T16:30:00"}} (время UTC без зоны). relation — {"relation": [{"id": "<id>"}]}.
- Замок записи: PATCH /v2/pages/{id} с {"locked": true} или {"locked": false}. Заблокированная запись на любую другую правку (в том числе добавление блоков в тело) отвечает 423 node_locked; снять замок можно только запросом, где в теле нет ничего, кроме locked: false.
- Тело записи: GET /v2/blocks/{id}/children?page_size=100 (постранично по next_cursor); добавить блоки — PATCH /v2/blocks/{id}/children с {"children": [блоки]} (до 40 блоков за запрос). Типы: heading_2, heading_3, paragraph, bulleted_list_item, numbered_list_item, quote, code, file, image.
- Вложения (свойство attaches, тип files): в ответе записи у каждого файла есть name и file.url — подписанная ссылка на скачивание. Она живёт час и не принимает заголовок Authorization (с заголовком хранилище отвечает 400): скачивайте простым GET. Файлы и картинки в теле — блоки типа file / image с такой же ссылкой в file.file.url. Загрузка файла: POST /v2/files/upload-url с {"parent": {"page_id": "<id>"}, "filename": "...", "name": "...", "content_type": "...", "size": <байт>}, затем PUT файла по upload_url, затем объект file из ответа кладётся в свойство ({"attaches": {"files": [<file>]}}) или в блок ({"type": "file", "file": <file>}).
- Создание базы: POST /v2/databases с parent.page_id, title и properties (обязательно ровно одно свойство типа title); пара зависимостей — PATCH /v2/databases/{id} со свойством типа relation (database_id этой же базы, allow_multiple, create_reciprocal: true, reciprocal_name). Подробности — Приложение А.
- Корзина: DELETE /v2/pages/{id} убирает запись в корзину; записи из корзины в запросах не возвращаются.
- Права ключа: чтение и запись страниц, блоков, баз и файлов; плюс доступ бота к самой базе задач (бота нужно «поделить» на базу, как человека).
Что отличается от прямого доступа к базе данных: нет транзакций и блокировок строк (захват задачи сериализуется замком на файле, см. раздел 6), замок записи мешает и самому агенту (раздел 4), пачку обновляют по одной записи. Взамен нет зависимости от схемы таблиц Вторума: API стабилен между версиями.

## Приложение В. Файл констант и клиент Вторума

Проверено на тестовой базе (все команды vt.py и функции db.sh выполнены на песочнице; стенд версий и отметки тестов — заглушки). Заглушки в угловых скобках: адрес Вторума, адреса эндпоинтов версии, ssh-псевдоним боевого сервера. Ключ бота — в файле с правами 600. Кодовые имена агентов в vname должны совпадать со значениями select worker.

```bash
# /srv/agents/lib/db.sh — общие константы и функции, подключается (source) всеми скриптами
. /srv/agents/lib/ids.sh                       # DB=<id базы задач> (печатает create_pipeline_db.py)
export VTORUM_URL="https://<ХОСТ_ВТОРУМА>"       # доступ к Вторуму — только по API v2
export VTORUM_TOKEN_FILE=/root/.vtorum-bot-token   # ключ бота, права 600
export VT_DB=$DB VT_LOCKDIR=/srv/agents/locks
HUMAN_WORKERS="human chat"                     # значения worker, которые агент не берёт (человек делает сам из чата)
export VT_HUMAN_WORKERS=$HUMAN_WORKERS
PROD_URL="https://<ПРОД>/api/version"            # эндпоинты версии стендов (JSON с полем version)
DEV_URL="https://<DEV>/api/version"
PROD_SSH="<ПСЕВДОНИМ_БОЕВОГО_СЕРВЕРА>"           # только для отметок тестов и сборки: к базе Вторума ssh не нужен
MARKS=/opt/test-marks                          # каталог отметок тестов на боевом сервере
LOCKS=/srv/agents/locks
STATE=/srv/agents/state

vt() { python3 /srv/agents/lib/vt.py "$@"; }    # единственный клиент Вторума (Приложение В)
vver() { curl -fsS --max-time 10 "$1" 2>/dev/null | sed -n 's/.*"version":"\([^"]*\)".*/\1/p'; }
vle()  { [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1)" = "$2" ]; }   # $1 <= $2 (числами по частям)
# Кодовое имя агента по номеру: ровно те значения, что заведены в select worker
vname() { case $1 in 1) echo "agent one";; 2) echo "agent two";; 3) echo "agent three";; *) echo "agent $1";; esac; }
# Статус задачи по id
tstatus() { vt get "$1" | cut -d'|' -f2; }
# Отметка тестов версии: green|red|running|none (файл на боевом сервере, строка status=)
vtests() { ssh -o BatchMode=yes "$PROD_SSH" "sed -n 's/^status=//p' $MARKS/$1.txt 2>/dev/null | head -1 | grep . || echo none" 2>/dev/null || echo none; }
```

```bash
# Claude на процессоре без SSE4.2 виснет: тогда модель не запускаем
model_ok() { grep -qw sse4_2 /proc/cpuinfo; }
log() { printf "[%s] %s\n" "$(date '+%F %T')" "$*" >> "${LOG:-/dev/stderr}"; }
# Строки задач из vt tasks (поля через «|»): id|status|version|err|upd|edited|locked|worker|created|blocked_by|tests|title
# Выбрать из потока строки без задач-человека (worker из HUMAN_WORKERS): для статусов, где человек работает сам
not_human() { awk -F'|' -v h="$HUMAN_WORKERS" 'BEGIN{n=split(h,H,",")} {for(i=1;i<=n;i++) if($8==H[i]) next; print}'; }
```

Обёртка для агентов (кладётся в /srv/agents/bin/vt, этот каталог стоит первым в PATH у run.sh): из команд модели vt вызывает клиент с настройками из db.sh.

```bash
#!/bin/bash
# /srv/agents/bin/vt — клиент Вторума для агентов (bin стоит первым в PATH у run.sh): vt get|set|tasks|lock|materials|file|summary
. /srv/agents/lib/db.sh
exec python3 /srv/agents/lib/vt.py "$@"
```

Клиент vt.py. Вся работа со Вторумом идёт через него: чтение записей, готовность задач (READY, включая зависимости), атомарный захват под файловым замком, запись свойств с автоматической перестановкой замка, материалы задания, скачивание файлов, резюме.

```bash
#!/usr/bin/env python3
"""vt.py — единственный доступ конвейера к Вторуму: только публичный API v2, без SQL.

Настройка (переменные окружения):
  VTORUM_URL         адрес Вторума, например https://<хост>
  VTORUM_TOKEN       ключ бота; или VTORUM_TOKEN_FILE — путь к файлу с ключом (права 600)
  VT_DB              id базы задач
  VT_LOCKDIR         каталог замков (по умолчанию /srv/agents/locks) — для захвата задачи
  VT_HUMAN_WORKERS   значения worker, которые агент не берёт: задачи, которые человек делает из чата (через запятую)

Команды (вывод — строки с полями через «|» или одна строка, ошибки — в stderr, код возврата не 0):
  tasks [статусы]           все записи не в done (или только с этими статусами через запятую)
  get <id>                  одна запись в том же формате
  ready                     id готовых задач из todo, самые старые первыми
  claim <worker>            атомарно взять самую старую готовую задачу: печатает «id|название» или ничего (код 1)
  set <id[,id…]> [--lock|--unlock] ключ=значение …   записать свойства (замок записи снимается и возвращается)
  lock <id[,id…]> true|false
  materials <id>            название, требование, вложения и тело записи с пометками F1, F2 …
  file <id> <F-номер> <куда> скачать вложение или файл из тела
  summary <id>              резюме со стандартного ввода дописать в конец тела записи
  redtask                   завести одну задачу на починку красной сборки (переменные RT_VER, RT_GREEN, RT_SUMMARY, RT_FAILS, RT_TASKS)
Ключи set: status, version, branch, commit, err, upd (now или ISO UTC), worker, tests, problem.
Формат строки tasks (разделитель «|», пустые поля остаются пустыми): id, status, version, err, upd, edited, locked(0|1), worker, created, blocked_by(через запятую), tests, title
"""
import datetime as dt
```

```bash
import fcntl
import json
import os
import re
import sys
import time
import urllib.error
import urllib.request

URL = os.environ.get("VTORUM_URL", "").rstrip("/")
DB = os.environ.get("VT_DB", "")
LOCKDIR = os.environ.get("VT_LOCKDIR", "/srv/agents/locks")
HUMAN = [w.strip() for w in os.environ.get("VT_HUMAN_WORKERS", "").split(",") if w.strip()]

def token():
    t = os.environ.get("VTORUM_TOKEN")
    if not t and os.environ.get("VTORUM_TOKEN_FILE"):
        t = open(os.environ["VTORUM_TOKEN_FILE"]).read()
    if not t:
        sys.exit("нет ключа: задайте VTORUM_TOKEN или VTORUM_TOKEN_FILE")
    return t.strip()

class ApiError(Exception):
    def __init__(self, status, body):
        super().__init__(f"{status} {body[:300]}")
        self.status = status

def call(method, path, body=None, retries=3):
    data = json.dumps(body, ensure_ascii=False).encode() if body is not None else None
    for attempt in range(retries):
        req = urllib.request.Request(URL + path, data=data, method=method, headers={
            "Authorization": "Bearer " + token(), "Content-Type": "application/json"})
        try:
            with urllib.request.urlopen(req, timeout=60) as r:
                raw = r.read()
                return json.loads(raw) if raw else {}
        except urllib.error.HTTPError as e:
            text = e.read().decode("utf-8", "replace")
            if e.code in (429, 502, 503, 504) and attempt < retries - 1:
                time.sleep(2 * (attempt + 1)); continue
            raise ApiError(e.code, text)
        except urllib.error.URLError as e:
            if attempt < retries - 1:
                time.sleep(2 * (attempt + 1)); continue
            raise ApiError(0, str(e))

def plain(prop):
    t = prop.get("type")
    if t in ("rich_text", "title"):
```

```bash
        return "".join(x.get("plain_text") or x.get("text", {}).get("content", "") for x in prop.get(t, []))
    if t == "select":
        return (prop.get("select") or {}).get("name", "")
    if t == "date":
        return ((prop.get("date") or {}).get("start")) or ""
    if t == "relation":
        return ",".join(x["id"] for x in prop.get("relation", []))
    return ""

def clean(s):
    """Поля строки разделены «|»: переносы и «|» внутри текста заменяем."""
    return re.sub(r"[\t\r\n]+", " ", (s or "")).replace("|", "¦")

def row(p):
    pr = p["properties"]
    g = lambda n: plain(pr.get(n, {}))
    return {
        "id": p["id"], "status": g("status"), "version": g("DEV · Версия"), "err": g("DEV · Последняя ошибка"),
        "upd": g("DEV · Обновлено"), "edited": p["last_edited_time"], "locked": bool(p.get("locked")),
        "worker": g("worker"), "created": p["created_time"], "blocked_by": g("Блокируется"),
        "tests": g("Тесты"), "title": g("Название"), "_page": p,
    }

def tsv(r):
    return "|".join([r["id"], r["status"], clean(r["version"]), clean(r["err"]), r["upd"], r["edited"],
                     "1" if r["locked"] else "0", clean(r["worker"]), r["created"], r["blocked_by"], clean(r["tests"]), clean(r["title"])])

def query(statuses=None):
    """Все записи базы, кроме done (или с перечисленными статусами). Постранично."""
    if statuses:
        conds = [{"property": "status", "select": {"equals": s}} for s in statuses]
        flt = conds[0] if len(conds) == 1 else {"or": conds}
    else:
        flt = {"property": "status", "select": {"does_not_equal": "done"}}
    out, cursor = [], None
    while True:
        body = {"filter": flt, "page_size": 100}
        if cursor:
            body["start_cursor"] = cursor
```

```bash
        d = call("POST", f"/v2/databases/{DB}/query", body)
        out += [row(p) for p in d["results"] if not p.get("in_trash")]
        if not d.get("has_more"):
            return out
        cursor = d["next_cursor"]

def parse_ts(s):
    s = s.replace("Z", "+00:00")
    t = dt.datetime.fromisoformat(s)
    return t if t.tzinfo else t.replace(tzinfo=dt.timezone.utc)

DONE_OK = {"commit", "deploy/dev", "test/dev", "deploy/prod", "test/prod", "done"}

def ready_tasks(rows=None):
    """Готовые к работе задачи из todo — единственное место, где решается «можно ли брать»."""
    rows = rows if rows is not None else query()
    status = {r["id"]: r["status"] for r in rows}
    res = []
    for r in sorted((x for x in rows if x["status"] == "todo"),
                    key=lambda x: (0 if x["title"].startswith("Починить красную сборку") else 1, x["created"])):   # починка красной сборки — первой
        if r["locked"] or r["worker"] in HUMAN:
            continue
        if r["err"] and not r["err"].startswith("Тесты сборки "):
            # отказ агента: ждём, пока человек что-то поправит (правка позже «Обновлено» больше чем на 2 минуты)
            if not r["upd"] or parse_ts(r["edited"]) <= parse_ts(r["upd"]) + dt.timedelta(minutes=2):
                continue
        # зависимости: блокирующая должна дойти до commit; отсутствующая в списке (done или в корзине) не мешает
        if any(b in status and status[b] not in DONE_OK for b in r["blocked_by"].split(",") if b):
            continue
        res.append(r)
    return res

def now_iso():
    return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")

def prop_payload(key, val):
    rt = lambda t: {"rich_text": [{"type": "text", "text": {"content": t}}] if t else []}
    if key == "status":
```

```bash
        return "status", {"select": {"name": val}}
    if key == "worker":
        return "worker", {"select": {"name": val}}
    if key == "tests":
        return "Тесты", ({"select": {"name": val}} if val else {"select": None})
    if key == "upd":
        return "DEV · Обновлено", {"date": {"start": now_iso() if val == "now" else val}}
    names = {"version": "DEV · Версия", "branch": "DEV · Ветка", "commit": "DEV · Commit",
             "err": "DEV · Последняя ошибка", "problem": "problem to solve"}
    if key in names:
        return names[key], rt(val)
    raise SystemExit(f"неизвестный ключ: {key}")

def set_lock(pid, locked):
    call("PATCH", f"/v2/pages/{pid}", {"locked": locked})

def write(pid, props, lock=None):
    """Записать свойства. Заблокированная запись отклоняет правки (423), поэтому замок снимаем и возвращаем.
    lock: True — оставить под замком, False — оставить свободной, None — как было."""
    was = bool(call("GET", f"/v2/pages/{pid}").get("locked"))
    if was:
        set_lock(pid, False)
    try:
        if props:
            call("PATCH", f"/v2/pages/{pid}", {"properties": props})
    finally:
        want = was if lock is None else lock
        if want:
            set_lock(pid, True)   # если замка не хотят, он уже снят

def cmd_set(args):
    ids = args[0].split(",")
    lock, props = None, {}
    for a in args[1:]:
        if a == "--lock":
            lock = True
        elif a == "--unlock":
            lock = False
        else:
            k, _, v = a.partition("=")
            n, p = prop_payload(k, v)
            props[n] = p
    for pid in ids:
        write(pid, props, lock)
    print("OK", len(ids))

def cmd_claim(worker):
    os.makedirs(LOCKDIR, exist_ok=True)
```

```bash
    with open(os.path.join(LOCKDIR, "claim.lock"), "w") as lf:
        fcntl.flock(lf, fcntl.LOCK_EX)  # все агенты на одной машине (и ноутбук — через ssh) берут задачи по очереди
        for r in ready_tasks():
            cur = call("GET", f"/v2/pages/{r['id']}")  # свежее состояние перед захватом
            c = row(cur)
            if c["status"] != "todo" or c["locked"]:
                continue
            props = dict([prop_payload("status", "in progress"), prop_payload("upd", "now"), prop_payload("worker", worker)])
            try:
                call("PATCH", f"/v2/pages/{r['id']}", {"properties": props})
                set_lock(r["id"], True)
            except ApiError as e:
                print(f"захват {r['id']}: {e}", file=sys.stderr)
                continue
            print(f"{r['id']}|{clean(r['title'])}")
            return 0
    return 1

def blocks(pid):
    out, cursor = [], None
    while True:
        d = call("GET", f"/v2/blocks/{pid}/children?page_size=100" + (f"&start_cursor={cursor}" if cursor else ""))
        out += d["results"]
        if not d.get("has_more"):
            return out
        cursor = d["next_cursor"]

def file_url(b):
    t = b["type"]
    f = b.get(t, {})
    return (f.get("file") or {}).get("url") or (f.get("external") or {}).get("url") or ""

def collect_files(pid, page):
    files = []
    for f in page["properties"].get("attaches", {}).get("files", []):
        files.append((f.get("name", "файл"), (f.get("file") or {}).get("url", "")))
    body = []
    for b in blocks(pid):
        t = b["type"]
        if t in ("file", "image", "pdf", "video", "audio"):
            u = file_url(b)
            nm = b.get(t, {}).get("name") or os.path.basename(u.split("?")[0]) or t
```

```bash
            files.append((nm, u)); body.append(("FILE", len(files), nm))
        else:
            txt = "".join(x.get("plain_text") or x.get("text", {}).get("content", "") for x in b.get(t, {}).get("rich_text", []))
            body.append((t, txt, ""))
    return files, body

def cmd_materials(pid):
    page = call("GET", f"/v2/pages/{pid}")
    r = row(page)
    files, body = collect_files(pid, page)
    print("### НАЗВАНИЕ (только ярлык, не требование)\n" + r["title"])
    print("\n### ТРЕБОВАНИЕ — поле «problem to solve»\n" + (plain(page["properties"].get("problem to solve", {})) or "(пусто — требование в теле записи)"))
    print("\n### ВЛОЖЕНИЯ — поле «attaches»   (номер | имя)")
    for i, f in enumerate(page["properties"].get("attaches", {}).get("files", []), 1):
        print(f"F{i} | {f.get('name')}")
    print("\n### ТЕЛО ЗАПИСИ по порядку   (тип | текст или ФАЙЛ F-номер)")
    for t, a, b in body:
        print(f"{t} | " + (f"ФАЙЛ F{a} {b}" if t == "FILE" else a.replace("\n", " ⏎ ")))

def cmd_file(pid, ref, out):
    page = call("GET", f"/v2/pages/{pid}")
    files, _ = collect_files(pid, page)
    i = int(ref.lstrip("Ff")) - 1
    name, u = files[i]
    # подписанная ссылка живёт час и не принимает заголовок авторизации
    with urllib.request.urlopen(urllib.request.Request(u), timeout=120) as r:
        data = r.read()
        ctype = r.headers.get("Content-Type", "-")
    open(out, "wb").write(data)
    print(f"OK {len(data)} {ctype} {name} → {out}")

def cmd_summary(pid):
    text = sys.stdin.read()
    if not text.strip():
        sys.exit("текст резюме пуст")
    if " " not in text.strip() and text.lstrip().startswith("$"):
        sys.exit("вместо текста пришло имя переменной — подай текст встроенным документом <<'RESUME'")
```

```bash
    rt = lambda t: [{"type": "text", "text": {"content": t[:1900]}}]
    bl = [{"type": "heading_2", "heading_2": {"rich_text": rt("Резюме агента · " + dt.datetime.now().strftime("%d.%m.%Y %H:%M"))}}]
    for line in text.split("\n"):
        s = line.strip()
        if not s:
            continue
        if s in ("Что сделано", "Что не получилось", "На что обратить внимание") or s.startswith(("Вернулась на разработку", "Упавшие тесты", "Что сделать")):
            bl.append({"type": "heading_3", "heading_3": {"rich_text": rt(s)}})
        elif s.startswith("- "):
            bl.append({"type": "bulleted_list_item", "bulleted_list_item": {"rich_text": rt(s[2:])}})
        else:
            bl.append({"type": "paragraph", "paragraph": {"rich_text": rt(s)}})
    was = bool(call("GET", f"/v2/pages/{pid}").get("locked"))
    if was:
        set_lock(pid, False)
    try:
        for i in range(0, len(bl), 40):
            call("PATCH", f"/v2/blocks/{pid}/children", {"children": bl[i:i + 40]})
    finally:
        if was:
            set_lock(pid, True)
    print("OK")

def cmd_redtask():
    """Завести ОДНУ задачу на починку красной сборки (параметры — переменные RT_VER, RT_GREEN, RT_SUMMARY, RT_FAILS, RT_TASKS, RT_STATUS)."""
    e = os.environ
    ver, green, summ = e["RT_VER"], e.get("RT_GREEN", ""), e.get("RT_SUMMARY", "причина не указана")
    fails = [l for l in e.get("RT_FAILS", "").split("\n") if l.strip()][:10]
    tasks = [l for l in e.get("RT_TASKS", "").split("\n") if l.strip()]
    title = f"Починить красную сборку {ver}"
    q = call("POST", f"/v2/databases/{DB}/query", {"filter": {"and": [
        {"property": "Название", "title": {"starts_with": title}},
        {"property": "status", "select": {"does_not_equal": "cancel"}}]}, "page_size": 5})
```

```bash
    if q["results"]:
        print("задача на починку уже есть")
        return 0
    rt = lambda t: [{"type": "text", "text": {"content": t[:1900]}}]
    H = lambda t: {"type": "heading_2", "heading_2": {"rich_text": rt(t)}}
    P = lambda t: {"type": "paragraph", "paragraph": {"rich_text": rt(t)}}
    B = lambda t: {"type": "bulleted_list_item", "bulleted_list_item": {"rich_text": rt(t)}}
    N = lambda t: {"type": "numbered_list_item", "numbered_list_item": {"rich_text": rt(t)}}
    blocks = [H("Что случилось"), P(f"Полный прогон тестов сборки {ver} красный: {summ}."),
              P(f"Последняя зелёная сборка перед ней: {green}." if green else "Зелёной сборки перед ней нет.")]
    if fails:
        blocks += [H("Упавшие проверки")] + [B(f) for f in fails]
    if tasks:
        blocks += [H("Что вошло в красную сборку"),
                   P("Код всех этих задач уже в central. Переделывать их не нужно: причина падения одна, её и надо найти.")] + [B(t) for t in tasks[:40]]
    blocks += [H("Что сделать"),
               N("Сначала проверить, не исправлена ли причина уже (правка в central после красной сборки, более поздняя зелёная сборка). Если исправлена: ничего не менять в коде, написать резюме «уже исправлено» и поставить commit."),
               N("Прочитать журнал прогона тестов этой версии и найти, какая проверка упала первой."),
               N("Найти причину: чья-то правка или слияние (типичные случаи: пропавшая при слиянии зависимость, расхождение типов, устаревший тест)."),
               N("Сделать минимальную правку поверх актуального main, прогнать упавшие проверки локально."),
               N("Версия, коммит и влив в central — по обычным правилам задачи; статус commit."),
```

```bash
               N("Задачи сборки не переоткрывать и не переделывать; чужие статусы не менять."),
               H("Приёмка"),
               B("Упавшие проверки проходят; следующая выкатка на dev даёт зелёные тесты."),
               B("Правка касается только причины падения.")]
    page = call("POST", "/v2/pages", {"parent": {"database_id": DB}, "properties": {
        "Название": {"title": [{"text": {"content": f"{title}: {summ[:90]}"}}]},
        "status": {"select": {"name": e.get("RT_STATUS", "todo")}},
        "problem to solve": {"rich_text": rt(f"Тесты сборки {ver} красные ({summ}): найти и исправить причину одним минимальным исправлением, задачи сборки не переделывать.")}},
        "children": blocks[:100]})
    print("создана задача на починку", page["id"])
    return 0

def main(argv):
    if not URL or not DB:
        sys.exit("задайте VTORUM_URL и VT_DB")
    c = argv[1] if len(argv) > 1 else ""
    a = argv[2:]
    try:
        if c == "tasks":
            for r in query(a[0].split(",") if a else None):
                print(tsv(r))
        elif c == "get":
            print(tsv(row(call("GET", f"/v2/pages/{a[0]}"))))
        elif c == "ready":
            for r in ready_tasks():
                print(r["id"])
        elif c == "claim":
            return cmd_claim(a[0])
        elif c == "set":
            cmd_set(a)
        elif c == "lock":
            for pid in a[0].split(","):
                set_lock(pid, a[1] == "true")
            print("OK")
        elif c == "materials":
            cmd_materials(a[0])
        elif c == "file":
            cmd_file(a[0], a[1], a[2])
        elif c == "summary":
            cmd_summary(a[0])
        elif c == "redtask":
            return cmd_redtask()
        else:
            print(__doc__); return 2
```

```bash
    except ApiError as e:
        print(f"ОШИБКА API: {e}", file=sys.stderr); return 3
    return 0

if __name__ == "__main__":
    sys.exit(main(sys.argv))
```

## Приложение Г. Планировщик и захват задачи

Проверено на тестовой базе: планировщик печатает todo при готовой задаче, prod при deploy/prod, devdeploy при пачке в commit без готовых задач; пустую базу и работу «ждёт человек» отличает; захват двумя агентами не даёт одной задачи дважды, а заблокированная зависимостью задача не берётся.

```bash
#!/bin/bash
# /srv/agents/lib/plan.sh — какая работа есть. Печатает режимы (prod, todo, devdeploy) или «error».
# Выход 0 — режимы напечатаны (или «error»), 1 — работы нет (тогда модель не запускается вовсе).
set -uo pipefail
. /srv/agents/lib/db.sh
rows=$(vt tasks "todo,in progress,commit,deploy/dev,test/dev,deploy/prod") || { echo error; exit 0; }
nready=$(vt ready | grep -c .) || true
# задачи, которые человек ведёт сам (worker из HUMAN_WORKERS), в in progress/commit/deploy/dev агента не касаются
rows=$(printf '%s\n' "$rows" | awk -F'|' -v h="$HUMAN_WORKERS" 'BEGIN{n=split(h,H,",")}
  {hum=0; for(i=1;i<=n;i++) if($8==H[i]) hum=1; if(hum && ($2=="in progress"||$2=="commit"||$2=="deploy/dev")) next; print}')
cnt() { printf '%s\n' "$rows" | awk -F'|' -v s="$1" -v c="${2:-}" '$2==s && (c=="" || (c=="err" && $4!="") || (c=="noerr" && $4==""))' | grep -c . || true; }
out=""; prod=$(vver "$PROD_URL"); p=0
# deploy/prod без ошибки — надо выкатывать; упавшая выкатка (ошибка у задач) ждёт человека
[ "$(cnt 'deploy/prod' noerr)" -gt 0 ] && p=1
# test/dev с версией не выше прода — это уже выкаченное, его пора перевести в test/prod
if [ $p -eq 0 ] && [ -n "$prod" ]; then
  while IFS='|' read -r id s v _; do
    [ "$s" = test/dev ] && [ -n "$v" ] && vle "$v" "$prod" && { p=1; break; }
  done <<< "$rows"
fi
# тесты dev идут или красные — промоут откажет, агента будить незачем
if [ $p -eq 1 ] && [ "$(cnt 'deploy/prod' noerr)" -gt 0 ]; then
  devv=$(vver "$DEV_URL"); [ -n "$devv" ] && case "$(vtests "$devv")" in running|red) p=0;; esac
fi
[ $p -eq 1 ] && out="$out prod"
[ "$nready" -gt 0 ] && out="$out todo"
# выкатка на dev: готовых todo и in progress нет, упавшей выкатки нет, есть что катить
```

```bash
if [ "$nready" -eq 0 ] && [ "$(cnt 'in progress')" -eq 0 ] && [ "$(cnt 'deploy/dev' err)" -eq 0 ] \
   && [ $(( $(cnt commit) + $(cnt 'deploy/dev' noerr) )) -gt 0 ]; then out="$out devdeploy"; fi
[ -z "$out" ] && exit 1
printf '%s\n' $out
```

```bash
#!/bin/bash
# /srv/agents/lib/claim.sh "<кодовое имя>" — берёт самую старую готовую задачу из todo.
# Печатает «<id>|<название>» или ничего (выход 1). Атомарность — замок на файле внутри vt claim:
# все захваты (в том числе с ноутбука, по ssh) идут через эту машину.
set -uo pipefail
. /srv/agents/lib/db.sh
exec python3 /srv/agents/lib/vt.py claim "${1:?кодовое имя агента}"
```

Условие готовности задачи (READY) живёт в vt.py (функция ready_tasks). Правила: не заблокирована; нет ошибки в «DEV · Последняя ошибка» либо её написал агент до последней правки человека (правка позже «DEV · Обновлено» больше чем на две минуты) либо она начинается с «Тесты сборки »; не взята человеком из чата (worker из HUMAN_WORKERS); все блокирующие задачи дошли до commit или дальше (отсутствующая в списке не-done — выполнена или в корзине — не мешает).

## Приложение Д. Номера версий, слияние, очередь проверок, гвардии

Проверено: next-version, integrate и heavy-run — действующие скрипты, обобщённые. Строка с версией в package.json ищется по формату «  "version": "x.y.z"» — поправьте под свой проект.

```bash
#!/bin/bash
# /srv/agents/lib/next-version.sh <рабочая копия> — свободный нечётный номер релиза под замком
set -uo pipefail
. /srv/agents/lib/db.sh
W=${1:?рабочая копия}
exec 9>"$LOCKS/version.lock"; flock -w 120 9 || { echo "замок номеров занят" >&2; exit 1; }
git -C "$W" fetch -q origin 2>/dev/null
main=$(git -C "$W" show origin/main:package.json 2>/dev/null | sed -n 's/^  "version": "\([^"]*\)".*/\1/p' | head -1)
here=$(sed -n 's/^  "version": "\([^"]*\)".*/\1/p' "$W/package.json" | head -1)
res=$(cat "$STATE/version-reserved" 2>/dev/null)
max=$(printf '%s\n' "$main" "$here" "$(vver "$DEV_URL")" "$(vver "$PROD_URL")" "$res" \
      | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1)
[ -n "$max" ] || { echo "не удалось узнать текущую версию" >&2; exit 1; }
IFS=. read -r a b c <<< "$max"
c=$((c+1)); [ $((c % 2)) -eq 0 ] && c=$((c+1))      # агентам — нечётные патчи, чётные оставлены людям
next="$a.$b.$c"; printf '%s\n' "$next" > "$STATE/version-reserved"; printf '%s\n' "$next"
```

```bash
#!/bin/bash
# /srv/agents/lib/integrate.sh <рабочая копия> — влить работу агента в central. Итог — последняя строка:
#   OK <хеш> | СЛИТО … (чужие коммиты влиты, проверь и запусти ещё раз) | КОНФЛИКТ <файлы> | ОШИБКА <причина>
set -uo pipefail
. /srv/agents/lib/db.sh
W=${1:?рабочая копия}
exec 9>"$LOCKS/integrate.lock"; flock -w 900 9 || { echo "ОШИБКА замок слияния занят больше 15 минут"; exit 2; }
cd "$W" || { echo "ОШИБКА нет рабочей копии $W"; exit 2; }
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
  [ -n "$(git diff --name-only --diff-filter=U)" ] && { echo "КОНФЛИКТ $(git diff --name-only --diff-filter=U | tr '\n' ' ')"; exit 4; }
  echo "ОШИБКА в рабочей копии незакоммиченные правки — закоммить их"; exit 3
fi
for i in 1 2 3; do
  git fetch -q origin || { echo "ОШИБКА central недоступен"; exit 5; }
  if [ "$(git rev-list --count HEAD..origin/main)" -gt 0 ]; then
    if ! git merge --no-edit -q origin/main >/tmp/integrate.$$ 2>&1; then
      files=$(git diff --name-only --diff-filter=U | tr '\n' ' ')
      [ -n "$files" ] && { echo "КОНФЛИКТ $files"; exit 4; }
      echo "ОШИБКА слияние не прошло: $(tail -2 /tmp/integrate.$$ | tr '\n' ' ')"; rm -f /tmp/integrate.$$; exit 5
    fi
    rm -f /tmp/integrate.$$
    echo "СЛИТО влиты чужие коммиты: прогони типы и тесты затронутых мест, затем запусти скрипт ещё раз"; exit 6
  fi
  git push -q origin HEAD:main 2>/dev/null && { echo "OK $(git rev-parse --short HEAD)"; exit 0; }
done
echo "ОШИБКА push в central не прошёл три раза подряд"; exit 5
```

```bash
#!/bin/bash
# /srv/agents/lib/heavy-run.sh <команда…> — очередь тяжёлых проверок: не больше SLOTS одновременно
SLOTS=${HEAVY_SLOTS:-2}; DIR=/srv/agents/locks
if [ -z "${HEAVY_HELD:-}" ]; then
  start=$(date +%s); got=""; waited=0
  while [ -z "$got" ]; do
    for i in $(seq 1 "$SLOTS"); do
      exec 7>"$DIR/heavy-$i.lock"
      if flock -n 7; then got=$i; break; fi
      exec 7>&-
    done
    [ -n "$got" ] && break
    [ $(( $(date +%s) - start )) -ge 1200 ] && { echo "[очередь] 20 минут без слота — запускаю без очереди" >&2; break; }
    waited=1; sleep 3
  done
  export HEAVY_HELD=1                  # вложенные вызовы слот второй раз не берут
fi
exec "$@"
```

```bash
#!/bin/bash
# /srv/agents/bin/pnpm (и такая же npx): тяжёлые команды — через очередь. bin стоит первым в PATH у run.sh
me=$(basename "$0"); real=/usr/bin/$me; heavy=""
for a in "$@"; do case "$a" in test|typecheck|build|vitest|tsc|test:*|typecheck:*) heavy=1; break;; esac; done
[ -n "$heavy" ] && exec /srv/agents/lib/heavy-run.sh "$real" "$@"
exec "$real" "$@"
```

```bash
#!/bin/bash
# /srv/agents/lib/guards.sh <рабочая копия> — все гвардии сборки параллельно; ноль найденных гвардий — тоже ошибка
set -uo pipefail
W=${1:?рабочая копия}; cd "$W" || { echo "ГВАРДИИ УПАЛИ нет рабочей копии"; exit 1; }
start=$(date +%s)
list=$(grep -oE "node scripts/test-[A-Za-z0-9._-]+\.mjs" Dockerfile | awk '{print $2}' | awk '!seen[$0]++')
total=$(printf '%s\n' "$list" | grep -c .)
[ "$total" -gt 0 ] || { echo "ГВАРДИИ УПАЛИ: в Dockerfile не найдено ни одной гвардии"; exit 1; }
fails=$(printf '%s\n' "$list" | xargs -P 4 -I{} sh -c 'out=$(node "{}" 2>&1) || printf "%s: %s\n" "{}" "$(printf "%s" "$out" | grep -v "^\s*$" | tail -3 | tr "\n" " " | cut -c1-300)"')
[ -n "$fails" ] && { printf '%s\n' "$fails"; echo "ГВАРДИИ УПАЛИ $(printf '%s\n' "$fails" | grep -c .) из $total"; exit 1; }
echo "ГВАРДИИ OK $total за $(( $(date +%s) - start )) с"
```

## Приложение Е. Материалы задачи и резюме

Проверено на тестовой базе: материалы показывают название, требование, вложения (F1, F2…) и тело записи по порядку; файл скачивается по свежей подписанной ссылке; резюме дописывается в конец тела записи даже у заблокированной записи (замок снимается и возвращается); текст со знаками $ и кавычками доходит как есть, имя переменной вместо текста отвергается.

Три тонких скрипта-обёртки над vt.py (вся работа — в клиенте, Приложение В):

```bash
#!/bin/bash
# /srv/agents/lib/task-materials.sh <id задачи> — название, требование, вложения (F1, F2…), тело записи
. /srv/agents/lib/db.sh; exec python3 /srv/agents/lib/vt.py materials "${1:?нужен id задачи}"
```

```bash
#!/bin/bash
# /srv/agents/lib/fetch-asset.sh <id задачи> <F-номер из task-materials> <куда положить>
# Печатает «OK <байт> <тип> <имя> → <путь>»; ссылки на файлы подписаны и живут час — скрипт берёт свежую.
. /srv/agents/lib/db.sh; exec python3 /srv/agents/lib/vt.py file "${1:?нужен id задачи}" "${2:?нужен F-номер}" "${3:?нужен путь вывода}"
```

```bash
#!/bin/bash
# /srv/agents/lib/summary.sh <id задачи> <<'RESUME' … RESUME — резюме в конец тела записи через API
. /srv/agents/lib/db.sh; exec python3 /srv/agents/lib/vt.py summary "${1:?нужен id задачи}"
```

Формат вывода task-materials: заголовки «НАЗВАНИЕ», «ТРЕБОВАНИЕ — поле problem to solve», «ВЛОЖЕНИЯ — поле attaches» (строки «F1 | имя») и «ТЕЛО ЗАПИСИ по порядку» (строки «тип | текст», а картинки и файлы тела — «ФАЙЛ F2 имя»). Файлы из свойства и из тела нумеруются сквозным списком F1…Fn: fetch-asset.sh <id задачи> F2 /tmp/имя.png скачивает нужный. Ссылки на файлы подписаны и живут час, поэтому каждый вызов берёт свежую.

Резюме подаётся на стандартный ввод встроенным документом с именем в кавычках; строки «Что сделано», «Что не получилось», «На что обратить внимание» становятся подзаголовками, строки с «- » — пунктами.

## Приложение Ж. Скрипты выкатки (без модели)

Проверено на песочнице с заглушками установщика, промоута и стенда версий (локальный сервер): полный цикл commit → deploy/dev → test/dev → deploy/prod → test/prod прошёл, сбой гвардий оставил пачку в deploy/dev под замками, проверка пачки (замки и статусы) сработала. Два места вам нужно заполнить: переменные INSTALL_CMD (установщик на dev: ставит сборку на dev и печатает маркер с версией) и PROMOTE_CMD (промоут на прод: печатает маркер успеха). Мы используем маркеры вида __DEV_OK__:<версия> и __PROMOTE_OK__:<версия>; в вашем установщике сделайте то же самое — успех определяется по маркеру в выводе, а не по коду возврата. Правила замков и кодов возврата описаны в разделе 10. Пачка обновляется запросом на каждую задачу (у API нет транзакций), после чего скрипт проверяет, что у всех статус и замок правильные; при расхождении — сбой. Повтор установщика при «central ушёл вперёд» (до трёх попыток без модели, с добавлением влитых за это время задач в пачку) проверен на песочнице: функция add_new_commits добавляет только новые задачи из commit, не дублирует и не трогает остальные статусы.

```bash
#!/bin/bash
# /srv/agents/lib/devdeploy.sh — общая выкатка пачки на dev. Под замком выкаток (его держит run.sh).
# Выход: 0 — выкачено; 1 — сбой, нужна модель (описание в $STATE/devdeploy-fail.txt); 2 — выкатывать нечего.
set -uo pipefail
. /srv/agents/lib/db.sh
MAIN="/srv/dev/<ПРОЕКТ>"
INSTALL_CMD="timeout 1800 ./install-dev.sh"   # установщик dev: ставит сборку и печатает маркер __DEV_OK__:<версия>
FAIL=$STATE/devdeploy-fail.txt; OUT=$STATE/devdeploy-installer.log; LOGF=${LOGF:-/dev/stderr}
say(){ printf "[%s] devdeploy: %s\n" "$(date '+%F %T')" "$*" >> "$LOGF"; }
fail(){ printf '%s\n' "$*" > "$FAIL"; say "СБОЙ: $*"; exit 1; }
rm -f "$FAIL"; t0=$(date +%s)
# Задачи, влитые в central за время выкатки: сразу в пачку (deploy/dev + замок), иначе их код уедет на dev,
# а статус останется commit. Вызывается при повторе установщика под замком слияния.
add_new_commits(){
  local extra eids
  extra=$(vt tasks commit | not_human | awk -F'|' -v ids=",$ids," 'index(ids, ","$1",")==0 {print $1"|"$3"|"$12}') || return 1
  [ -n "$extra" ] || return 0
  eids=$(printf '%s\n' "$extra" | cut -d'|' -f1 | paste -sd, -)
  vt set "$eids" status=deploy/dev --lock >/dev/null || return 1
  batch="$batch"$'\n'"$extra"; ids="$ids,$eids"; n=$(printf '%s\n' "$batch" | grep -c .)
  say "в пачку добавлено задач, влитых за время выкатки: $(printf '%s\n' "$extra" | grep -c .)"
}
# 1. Под замком слияния: чистота общей копии, pull без rebase, выбор пачки (commit и deploy/dev без ошибки)
exec 9>"$LOCKS/integrate.lock"; flock -w 900 9 || fail "замок слияния занят больше 15 минут"
cd "$MAIN" || fail "нет $MAIN"
[ -z "$(git status --porcelain --untracked-files=no)" ] || fail "в $MAIN незакоммиченные правки"
```

```bash
git pull -q --no-rebase origin main >/tmp/dd-pull.$$ 2>&1 || { m=$(tail -3 /tmp/dd-pull.$$ | tr '\n' ' '); rm -f /tmp/dd-pull.$$; fail "git pull не прошёл: $m"; }
rm -f /tmp/dd-pull.$$
VERSION=$(node -p "require('./package.json').version")
rows=$(vt tasks "commit,deploy/dev") || fail "Вторум не ответил при выборе пачки"
batch=$(printf '%s\n' "$rows" | not_human | awk -F'|' '$2=="commit" || ($2=="deploy/dev" && $4=="") {print $1"|"$3"|"$12}')
[ -n "$batch" ] || { say "выкатывать нечего"; exit 2; }
ids=$(printf '%s\n' "$batch" | cut -d'|' -f1 | paste -sd, -); n=$(printf '%s\n' "$batch" | grep -c .)
# 2. Вся пачка -> deploy/dev и под замок; проверка, что у всех именно так
vt set "$ids" status=deploy/dev --lock >/dev/null || fail "не удалось перевести пачку в deploy/dev"
ok=0; for id in ${ids//,/ }; do
  r=$(vt get "$id") || continue
  [ "$(printf '%s' "$r" | cut -d'|' -f2)" = deploy/dev ] && [ "$(printf '%s' "$r" | cut -d'|' -f7)" = 1 ] && ok=$((ok+1)); done
[ "$ok" = "$n" ] || fail "в deploy/dev под замком $ok задач из $n"
exec 9>&-; say "пачка из $n задач в deploy/dev под замками, версия в дереве $VERSION"
# 3. Гвардии
g=$(/srv/agents/lib/guards.sh "$MAIN" 2>&1) || fail "гвардии упали: $(printf '%s\n' "$g" | tail -15)"
# 4. Установщик на dev (таймаут 30 минут); успех — по маркеру в выводе
# До трёх попыток без модели: пока шла подготовка, агенты могли влить новое в central, и установщик отказывается
# («central не является предком локального HEAD»). Тогда подтягиваем central под замком слияния,
# добавляем влитые задачи в пачку и сразу повторяем установщик.
attempt=0
while :; do
  attempt=$((attempt+1))
  bash -c "$INSTALL_CMD" > "$OUT" 2>&1; rc=$?
  grep -q "^__DEV_OK__:$VERSION" "$OUT" && break
  if [ $attempt -lt 3 ] && grep -q "не является предком" "$OUT"; then
```

```bash
    say "central ушёл вперёд за время выкатки (попытка $attempt из 3) — подтягиваю и повторяю установщик"
    exec 9>"$LOCKS/integrate.lock"; flock -w 600 9 || fail "замок слияния занят больше 10 минут при повторе"
    [ -z "$(git status --porcelain --untracked-files=no)" ] || fail "при повторе: в $MAIN незакоммиченные правки"
    git pull -q --no-rebase origin main >/tmp/dd-pull.$$ 2>&1 || { m=$(tail -3 /tmp/dd-pull.$$ | tr '\n' ' '); rm -f /tmp/dd-pull.$$; fail "git pull при повторе не прошёл: $m"; }
    rm -f /tmp/dd-pull.$$
    VERSION=$(node -p "require('./package.json').version")
    add_new_commits || fail "при повторе не удалось добавить влитые задачи в пачку"
    exec 9>&-
    continue
  fi
  fail "установщик не выкатил $VERSION на dev (код $rc, попыток $attempt). Конец вывода: $(grep -v '^\s*$' "$OUT" | tail -40)"
done
dev=$(vver "$DEV_URL"); [ "$dev" = "$VERSION" ] || fail "dev отвечает «$dev», а выкатывали $VERSION"
# 5. Самой свежей задаче — номер, который уехал; вся пачка -> test/dev, ошибки пустые, замки сняты
fresh=$(printf '%s\n' "$batch" | awk -F'|' '$2!=""{print $2"|"$1}' | sort -t'|' -k1,1V | tail -1 | cut -d'|' -f2)
[ -n "$fresh" ] && { vt set "$fresh" version="$VERSION" branch="$VERSION" >/dev/null || fail "не удалось записать версию в задачу"; }
vt set "$ids" status=test/dev err= upd=now --unlock >/dev/null || fail "dev на $VERSION, но статусы пачки записать не удалось — пачка в deploy/dev под замками"
say "готово: $n задач в test/dev, замки сняты, всего $(( $(date +%s) - t0 )) с"
printf '%s\n' "$VERSION" > "$STATE/devdeploy-last.txt"; exit 0
```

```bash
#!/bin/bash
# /srv/agents/lib/proddeploy.sh — выкатка на прод. Выход: 0 — сделано; 1 — сбой, нужна модель; 2 — делать нечего или ждём тесты.
set -uo pipefail
. /srv/agents/lib/db.sh
MAIN="/srv/dev/<ПРОЕКТ>"
PROMOTE_CMD="timeout 1200 ./promote-to-prod.sh"   # промоут: переключает прод на версию dev и печатает __PROMOTE_OK__:<версия>
FAIL=$STATE/proddeploy-fail.txt; OUT=$STATE/proddeploy-promote.log; LOGF=${LOGF:-/dev/stderr}
say(){ printf "[%s] proddeploy: %s\n" "$(date '+%F %T')" "$*" >> "$LOGF"; }
fail(){ printf '%s\n' "$*" > "$FAIL"; say "СБОЙ: $*"; exit 1; }
csv(){ printf '%s' "$1" | xargs | tr ' ' ','; }                       # id через пробел -> через запятую
to_testprod(){ vt set "$(csv "$1")" status=test/prod err= upd=now --unlock >/dev/null; }
rm -f "$FAIL"; t0=$(date +%s)
dev=$(vver "$DEV_URL"); prod=$(vver "$PROD_URL")
[ -n "$dev" ] && [ -n "$prod" ] || fail "не узнать версии стендов: dev «$dev», prod «$prod»"
# deploy/prod без ошибки и test/dev: id|статус|версия|название
rows=$(vt tasks "deploy/prod,test/dev" | awk -F'|' '($2=="deploy/prod" && $4=="") || $2=="test/dev" {print $1"|"$2"|"$3"|"$12}') || fail "Вторум не ответил"
# deploy/prod без версии — на dev не выкатывали: пометить и не трогать
for id in $(printf '%s\n' "$rows" | awk -F'|' '$2=="deploy/prod" && $3==""{print $1}'); do
  vt set "$id" err="Ожидает выкатки на dev" >/dev/null
done
# 1. Уже выкаченное (версия <= прода) — сразу в test/prod
done_ids=""; while IFS='|' read -r id st ver title; do [ -n "$ver" ] && vle "$ver" "$prod" && done_ids="$done_ids $id"; done <<< "$rows"
[ -n "$done_ids" ] && { to_testprod "$done_ids" || fail "не удалось перевести выкаченное в test/prod"; set -- $done_ids; say "в test/prod по версии прода $prod: $# задач"; }
```

```bash
# 2. Есть ли что выкатывать: deploy/prod с версией выше прода и не выше dev
want=$(printf '%s\n' "$rows" | awk -F'|' '$2=="deploy/prod" && $3!=""{print $1"|"$3}' | while IFS='|' read -r id ver; do
  vle "$ver" "$prod" && continue; vle "$ver" "$dev" && echo "$id"; done)
[ -n "$want" ] || { say "выкатывать на прод нечего (prod $prod, dev $dev)"; [ -n "$done_ids" ] && exit 0 || exit 2; }
# 3. Тесты версии dev: green — едем; running/red — ждём; нет отметки — сбой
case "$(vtests "$dev")" in
  green) ;;
  running) say "тесты $dev ещё идут — ждём"; exit 2;;
  red) say "тесты $dev красные — ждём новой зелёной сборки"; exit 2;;
  *) fail "для версии $dev нет отметки тестов ($MARKS/$dev.txt) — промоут откажет";;
esac
# 4. Пачка: всё из test/dev и deploy/prod с версией в (prod; dev] — их код на dev и уедет вместе
batch=""; while IFS='|' read -r id st ver title; do
  [ -n "$ver" ] || continue; vle "$ver" "$prod" && continue; vle "$ver" "$dev" && batch="$batch $id"; done <<< "$rows"
ids=$(csv "$batch"); set -- $batch; n=$#
vt set "$ids" status=deploy/prod --lock >/dev/null || fail "не удалось перевести пачку в deploy/prod"
ok=0; for id in $batch; do
  r=$(vt get "$id") || continue
  [ "$(printf '%s' "$r" | cut -d'|' -f2)" = deploy/prod ] && [ "$(printf '%s' "$r" | cut -d'|' -f7)" = 1 ] && ok=$((ok+1)); done
[ "$ok" = "$n" ] || fail "в deploy/prod под замком $ok задач из $n"
say "пачка из $n задач в deploy/prod под замками: prod $prod -> $dev"
# 5. Промоут (таймаут 20 минут); успех — по маркеру; затем версия прода
t1=$(date +%s); cd "$MAIN" || fail "нет $MAIN"
bash -c "$PROMOTE_CMD" > "$OUT" 2>&1; rc=$?
grep -q "__PROMOTE_OK__:$dev" "$OUT" || fail "промоут $dev не прошёл (код $rc). Пачка в deploy/prod под замками. Конец вывода: $(grep -v '^\s*$' "$OUT" | tail -30)"
```

```bash
newp=$(vver "$PROD_URL"); [ "$newp" = "$dev" ] || fail "после промоута прод отвечает «$newp», ожидали $dev. Пачка в deploy/prod под замками"
# 6. Пачка -> test/prod, замки сняты
to_testprod "$batch" || fail "прод на $dev, но статусы пачки записать не удалось — пачка в deploy/prod под замками"
say "готово: $n задач в test/prod, всего $(( $(date +%s) - t0 )) с"
printf '%s\n' "$dev" > "$STATE/proddeploy-last.txt"; exit 0
```

## Приложение З. Скрипт запуска агента (run.sh)

Действующий run.sh, сокращённый до сути (все обращения ко Вторуму — через функцию tstatus и скрипты на vt.py; синтаксис проверен, с моделью на песочнице не запускался) (без резервного агента и уведомлений; добавьте по вкусу). Режимы: resume, prod, task, devdeploy, плюс режимы починки prod-fix и devdeploy-fix для модели.

```bash
#!/bin/bash
# /srv/agents/dev-agent/run.sh <номер агента> — запускается таймером раз в минуту
set -uo pipefail
export PATH="/srv/agents/bin:/usr/local/bin:/usr/bin:/bin"; export HOME=/root
[ -s /root/.claude/agent-oauth-token ] && export CLAUDE_CODE_OAUTH_TOKEN="$(cat /root/.claude/agent-oauth-token)"
N=${1:?номер агента}
DIR=/srv/agents/dev-agent; LIB=/srv/agents/lib
W=/srv/agents/work/agent-$N          # личная рабочая копия: клон central, ветка agent-N
MAIN=/srv/dev/<ПРОЕКТ>               # общая копия — только для выкаток
LOG=/var/log/dev-agent-$N.log
. $LIB/db.sh
TASKFILE=$STATE/agent-$N.task        # «моя незавершённая задача»: id|название
NAME=$(vname $N)

# Работы нет — выходим, но незавершённую задачу всё равно доводим
plan=$($LIB/plan.sh) || { [ -s "$TASKFILE" ] || exit 0; plan=""; }
[ "$plan" = error ] && { log "база не ответила — пропускаю"; exit 0; }
take_deploy_lock(){ exec 8>"$LOCKS/deploy.lock"; flock -n 8; }   # дескриптор 8 держится весь прогон и наследуется claude

MODE="" TASK="" TITLE="" WORK=$MAIN PRODFAIL="" FAILNOTE=""
if [ -s "$TASKFILE" ]; then
  IFS='|' read -r TASK TITLE < "$TASKFILE"
  st=$(tstatus "$TASK")
  if [ "$st" = "in progress" ]; then MODE=resume; WORK=$W
  else
    if [ -n "$(git -C $W status --porcelain)" ] || [ "$(git -C $W rev-list --count origin/main..HEAD 2>/dev/null || echo 0)" -gt 0 ]; then
      log "задача «$TITLE» уже не in progress ($st), а в копии остались правки — нужен человек"; exit 0
    fi
    rm -f "$TASKFILE"; TASK="" TITLE=""
  fi
fi

# 1. Прод — сначала скриптом, без модели
if [ -z "$MODE" ] && printf '%s\n' "$plan" | grep -qx prod && take_deploy_lock; then
  LOGF=$LOG $LIB/proddeploy.sh; rc=$?
  if   [ $rc -eq 0 ]; then log "=== прогон завершён (prod, скрипт)"; exit 0
```

```bash
  elif [ $rc -eq 1 ]; then MODE=prod-fix; PRODFAIL=$(cat $STATE/proddeploy-fail.txt 2>/dev/null)
  else exec 8>&-; fi                  # делать на проде нечего — отпускаем замок, идём дальше
fi
# 2. Новая задача
if [ -z "$MODE" ] && { model_ok || false; } && printf '%s\n' "$plan" | grep -qx todo; then
  [ -n "$(git -C $W status --porcelain)" ] && { log "рабочая копия $W не чиста — нужен человек"; exit 0; }
  git -C $W fetch -q origin && git -C $W switch -q -C agent-$N origin/main \
    && (cd $W && pnpm install --frozen-lockfile --prefer-offline >/dev/null 2>&1) || { log "не удалось подготовить $W"; exit 0; }
  if r=$($LIB/claim.sh "$NAME"); then IFS='|' read -r TASK TITLE <<< "$r"; printf '%s\n' "$r" > "$TASKFILE"; MODE=task; WORK=$W; fi
fi
# 3. Выкатка на dev — скриптом
if [ -z "$MODE" ] && printf '%s\n' "$plan" | grep -qx devdeploy && take_deploy_lock; then MODE=devdeploy; fi
[ -z "$MODE" ] && exit 0
if [ "$MODE" = devdeploy ]; then
  LOGF=$LOG $LIB/devdeploy.sh; rc=$?
  [ $rc -eq 0 ] && { log "=== прогон завершён (devdeploy, скрипт)"; exit 0; }
  [ $rc -eq 2 ] && exit 0
  FAILNOTE=$(cat $STATE/devdeploy-fail.txt 2>/dev/null); MODE=devdeploy-fix
fi
# Без модели чинить некому: сбой выкатки записывается в задачи пачки без модели (ошибка, замки сняты; Приложение Н)
if ! model_ok; then
  log "модель недоступна — режим $MODE пропущен"
  case $MODE in
    devdeploy-fix) $LIB/nomodel-fail.sh deploy/dev "$FAILNOTE" >> "$LOG";;
    prod-fix)      $LIB/nomodel-fail.sh deploy/prod "$PRODFAIL" >> "$LOG";;
  esac
  exit 0
fi

case $MODE in
  task)   what="РЕЖИМ task. Выполни ТОЛЬКО шаг 2 для задачи «$TITLE» (id $TASK). Её уже взял запускающий скрипт: статус in progress, замок и «DEV · Обновлено» стоят. Сам задачи не выбирай. Шаги 1 и 3 не трогай.";;
```

```bash
  resume) what="РЕЖИМ task (продолжение). Прошлый прогон оборвался на задаче «$TITLE» (id $TASK): она in progress под замком. Посмотри, что уже сделано (git status, git log origin/main..HEAD), и доведи шаг 2 до конца.";;
  prod-fix) what="РЕЖИМ prod — ПОЧИНКА. Скрипт выкатки на прод споткнулся: $PRODFAIL
Сначала посмотри статусы: пачка уже в deploy/prod под замками — работай с ней. Устрани причину и доведи шаг 1; не вышло — по «Если выкатка не удалась». Шаги 2 и 3 не трогай.";;
  devdeploy-fix) what="РЕЖИМ devdeploy — ПОЧИНКА. Скрипт выкатки на dev споткнулся: $FAILNOTE
Пачка уже в deploy/dev под замками — не перевыбирай. Устрани причину, повтори установщик и доведи шаг 3; не вышло — по «Если выкатка не удалась». Шаги 1 и 2 не трогай.";;
esac
HEADER="## ЗАДАНИЕ ЭТОГО ПРОГОНА — главнее всего остального в инструкции
Ты — агент разработки № $N, кодовое имя «$NAME». Параллельно работают другие агенты, у каждого своя рабочая копия. Поле worker при захвате уже заполнено скриптом, сам его не меняй.
$what
Рабочая копия этого прогона: \`$WORK\`. Везде, где в инструкции написано \`$MAIN\`, работай в \`$WORK\`."
log "=== старт прогона: $MODE${TITLE:+ «$TITLE»}"
cd "$WORK" || exit 1
PROMPT=$(cat "$DIR/PROMPT.md"); [ "$WORK" != "$MAIN" ] && PROMPT=${PROMPT//$MAIN/$WORK}
timeout 5400 claude -p "$HEADER

$PROMPT" --model <МОДЕЛЬ> --effort medium \
  --allowedTools "Bash,Read,Edit,Write,Glob,Grep,TodoWrite,ToolSearch,WebFetch,WebSearch,Agent" >> "$LOG" 2>&1
code=$?
if [ -n "$TASK" ]; then   # задача ушла из in progress — отметку «моя незавершённая» снимаем
  st=$(tstatus "$TASK")
  [ "$st" != "in progress" ] && rm -f "$TASKFILE"
fi
log "=== прогон завершён ($MODE), код $code"
```

Подготовка перед первым запуском: создать каталоги (lib, bin, state, locks, work), клонировать central в /srv/agents/work/agent-N для каждого агента и в общую копию, положить скрипты и сделать их исполняемыми, положить долгоживущий токен Claude, включить таймеры (раздел 16).

## Приложение И. Шаблон промпта агента (PROMPT.md)

Структура и формулировки взяты из действующего промпта (225 строк у нас); здесь они сведены к шаблону и переписаны под доступ к Вторуму через команду vt. Всё, что в угловых скобках, заполняется под ваш проект. Раздел за разделом копируйте в PROMPT.md, а правила, на которых ваша команда обожглась, дописывайте.

```bash
Ты — автообработчик задач разработки <ПРОЕКТ>. Работай молча и по делу; отчёт — только в конце, коротко и по фактам.
Никаких вопросов пользователю: если задача невыполнима или неясна — не угадывай, запиши причину в задачу и иди дальше.

## Как ты запущен
Тебя раз в минуту запускает systemd-таймер командой claude -p без интерактивного ввода. Режим этого прогона написан в «ЗАДАНИИ ЭТОГО ПРОГОНА»
в самом начале; другие режимы не трогай, вторую задачу не бери.

## Язык
Всё пишешь по-<ЯЗЫК>: отчёт, резюме, тексты в задаче, сообщения коммитов, комментарии в коде, строки интерфейса.

## Среда
- Сервер, пользователь, репозитории: <ПУТИ>. Личная рабочая копия — в задании; общая копия — только для выкаток.
- Запрещено: git commit --amend, reset, stash, checkout чужих файлов, rm -rf, docker compose up/down, форматирование и рефакторинг вне задачи.
- Вторум — ТОЛЬКО через команду vt (она в PATH). SQL и прямой доступ к базе недоступны.
- Стенды: dev <АДРЕС>, prod <АДРЕС>; версия — эндпоинт version.

## База задач
- Записи — база задач Вторума; свойства (имя -> тип): status (select), problem to solve (rich_text), attaches (files), DEV · Версия, DEV · Ветка, DEV · Commit, DEV · Последняя ошибка (rich_text), DEV · Обновлено (date), worker (select), Тесты (select), Блокируется / Блокирует (relation).
- Команды (все значения — по имени):
  vt get <id>                      — строка записи: id|status|версия|ошибка|обновлено|правка|замок|worker|создана|блокируется|тесты|название
  vt set <id> status=… version=… branch=… commit=… err=… upd=now [--lock|--unlock]   — запись свойств; err= (пусто) очищает; замок записи на время записи снимается и возвращается, --unlock оставляет запись свободной
  vt tasks [статусы через запятую] — записи; vt lock <id> true|false
```

```bash
- Требование задачи — «problem to solve» + «attaches» + тело записи. Читай все три: /srv/agents/lib/task-materials.sh <id>; вложения — /srv/agents/lib/fetch-asset.sh <id> F1 /tmp/файл.

## Замок задачи
Взятая задача под замком (запись «заблокирована»): человек не может её править. Замок на задачу в режиме task уже поставил скрипт; vt set сам снимает и возвращает его на время записи. Ты ОБЯЗАН
оставить запись разблокированной перед выходом в ЛЮБОМ случае: закончил, не справился, встал на паузу, прогон обрывается: vt set <id> … --unlock (или vt lock <id> false). Повторное снятие безвредно. Чужой замок не трогай.

## Резюме (обязательно, даже при неудаче, ДО снятия замка и смены статуса)
/srv/agents/lib/summary.sh <id> <<'RESUME'
Что сделано
- …
Что не получилось
- …
На что обратить внимание
- …
RESUME
Текст — для человека: без путей на сервере и внутренних подробностей. Не клади резюме в переменную. Один блок на прогон.

## Не хватает данных -> error
Не сказано, где править; нет макета, на который ссылается текст; требование противоречиво; вложение не скачивается — не угадывай. Откати свои незакоммиченные правки,
напиши резюме с конкретикой «что человеку дописать», запиши то же коротко в «DEV · Последняя ошибка», затем одной командой: vt set <id> status=error err="<причина>" upd=now --unlock.
Задачу в error больше не трогай. Технический сбой (не нашёл место в коде, тесты не сходятся после трёх попыток) — не нехватка данных: верни todo с причиной в ошибке.

## Жизненный цикл
todo -> in progress -> commit -> deploy/dev -> test/dev -> deploy/prod -> test/prod -> done. done ставит ТОЛЬКО человек. commit = «код уже в central».

## Зависимости
Блокируется / Блокирует: пока блокирующая не дошла до commit, зависимая не берётся (это делают скрипты). Поле не меняй.

```

```bash
## Шаг 2. Задача: todo -> commit
Статус — переключатель: перед каждым шагом перечитывай статус; стал НЕ todo и НЕ in progress — остановись, закоммить готовое, сними замок, отчитайся.
1. task-materials: прочитай ВСЁ. Найди место в коде, внеси минимальные правки в стиле соседнего кода.
2. Тесты: типы + тесты, связанные с изменёнными файлами (<КОМАНДА>); полный прогон — только при правке общих мест.
3. Гвардия сборки: scripts/test-v<версия>-<slug>.mjs, дописать в цепочку RUN Dockerfile через &&.
4. Версия: /srv/agents/lib/next-version.sh <копия>; замени во ВСЕХ местах: <СПИСОК ФАЙЛОВ С ВЕРСИЕЙ>; обнови документацию/список изменений.
5. /srv/agents/lib/guards.sh <копия> — «ГВАРДИИ OK»; упали — почини.
6. Коммит: «<версия> — <заголовок>» + 2–5 строк «что было, почему, что сделано».
7. /srv/agents/lib/integrate.sh <копия>: OK — дальше; СЛИТО — прогони типы и тесты, запусти ещё раз; КОНФЛИКТ — разреши (конфликт версий: оставь БОЛЬШИЙ номер во всех местах,
   в списке изменений сохрани обе строки), закоммить слияние, запусти снова; ОШИБКА — запиши причину, верни todo, сними замок. Пока нет OK — commit не ставь.
8. Резюме (summary.sh), затем одной командой: vt set <id> version=<версия> branch=<версия> commit=<хеш> status=commit upd=now err= --unlock. Номер версии пиши в DEV · Ветка сразу, как получил. Отчёт.

## Шаги 1 и 3 (только когда скрипт выкатки споткнулся — режимы prod-fix и devdeploy-fix)
Пачка уже в deploy/* под замками — её не перевыбирай. Прочитай описание сбоя из задания, устрани причину (конфликт, гвардия, номер версии, установщик), повтори выкатку,
затем статусы и замки по <ПРАВИЛАМ из раздела 10>. Не вышло (не больше трёх попыток) — оставь пачку в deploy/*, ВСЕМ задачам пачки запиши одну и ту же причину (vt set <id,id,…> err="<причина>" --unlock),
```

```bash
повторно сам не выкатывай. Задачу из пачки, которую человек перевёл в другой статус, не трогай.

## Ограничения
Одна задача (шаг 2) или один режим выкатки за прогон. Не трогай задачи в in progress у других, test/*, done, cancel, backlog, error. Отчёт по-<ЯЗЫК>: что взято, версия, был ли коммит,
какие статусы поставлены. Делать было нечего — одна строка «Задач нет».
```

## Приложение К. Отметки тестов: прогон в фоне и защита прода

Проверено: это действующий скрипт прогона, обобщённый (хост и ключ заменены ssh-псевдонимом боевого сервера, команды проекта — заглушками). Идея: полный набор тестов и проверка типов идут параллельно сборке и выкатке на dev и не задерживают её; итог ложится файлом-отметкой на боевой сервер, а промоут на прод без зелёной отметки отказывает. Тест идёт из чистой копии того коммита, что уехал в образ: рабочее дерево установщик пакует, трогать его во время выкатки нельзя.

```bash
#!/bin/bash
# scripts/run-release-tests.sh <версия> <коммит> <путь к репозиторию>
# Код возврата: 0 — зелёные, 1 — красные или прогон не состоялся. Установщик его не наследует:
# красные тесты не отменяют выкатку на dev, они лишь закрывают дорогу на прод.
set -uo pipefail
VERSION="${1:-}"; COMMIT="${2:-}"; REPO="${3:-}"
[[ -n "$VERSION" && -n "$COMMIT" && -n "$REPO" ]] || { echo "[Тесты] использование: run-release-tests.sh <версия> <коммит> <репозиторий>" >&2; exit 1; }
PROD_SSH="${PROD_SSH:-<ПСЕВДОНИМ_БОЕВОГО_СЕРВЕРА>}"
MARK_DIR="${TEST_MARK_DIR:-/opt/test-marks}"
SSH=(-o BatchMode=yes -o ServerAliveInterval=20 -o ServerAliveCountMax=6)
LOG="${TEST_LOG:-${TMPDIR:-/tmp}/release-tests-${VERSION}.log}"; : >"$LOG"

# Отметка — простые строки «ключ=значение»: её читает bash промоута и tests-sync
write_mark() {
  local status="$1" summary="$2"
  ssh "${SSH[@]}" "$PROD_SSH" "mkdir -p '$MARK_DIR' && cat > '$MARK_DIR/$VERSION.txt'" <<MARK || echo "[Тесты] не удалось записать отметку ($status)"
version=$VERSION
commit=$COMMIT
status=$status
updated=$(date -u +%Y-%m-%dT%H:%M:%SZ)
summary=$summary
MARK
}
finish() {
  write_mark "$1" "$2"
  if [[ "$1" == green ]]; then echo "[Тесты] ЗЕЛЕНО: $2"; exit 0; fi
  echo "[Тесты] КРАСНО: $2 (подробности: $LOG)"; exit 1
}
started=$(date +%s)
write_mark running "прогон запущен параллельно сборке"
WORK="$(mktemp -d "${TMPDIR:-/tmp}/release-tests-${VERSION}.XXXXXX")"; trap 'rm -rf "$WORK"' EXIT
git -C "$REPO" archive --format=tar "$COMMIT" 2>>"$LOG" | tar -x -C "$WORK" 2>>"$LOG" || finish red "не удалось сделать чистую копию коммита"
cd "$WORK" || finish red "не удалось перейти в чистую копию"
<КОМАНДА_УСТАНОВКИ_ЗАВИСИМОСТЕЙ> >>"$LOG" 2>&1 || finish red "не удалось поставить зависимости"        # pnpm install --frozen-lockfile
```

```bash
<КОМАНДА_ПРОВЕРКИ_ТИПОВ> >>"$LOG" 2>&1 || finish red "проверка типов не прошла"                          # pnpm -r typecheck
if ! <КОМАНДА_ПОЛНЫХ_ТЕСТОВ> >>"$LOG" 2>&1; then                                                        # pnpm test
  failed="$(grep -oE '^ *(FAIL|❯ ) *[^ ]+\.test\.[tj]sx?' "$LOG" | grep -oE '[^ ]+\.test\.[tj]sx?' | sort -u | head -5 | tr '\n' ' ')"
  finish red "упали тесты${failed:+ — $failed}"
fi
finish green "полный прогон и проверка типов за $(( ($(date +%s) - started) / 60 )) мин"
```

Запуск из установщика dev, до упаковки архива (прогон должен пережить выход установщика и выход прогона агента, поэтому setsid + nohup, а у systemd-сервиса агента стоит KillMode=process):

```bash
TESTS_COMMIT="$(git -C "$REPO" rev-parse HEAD)"
TESTS_LOG="$(mktemp "${TMPDIR:-/tmp}/tests-${VERSION}.XXXXXX.log")"
setsid nohup bash scripts/run-release-tests.sh "$VERSION" "$TESTS_COMMIT" "$REPO" >"$TESTS_LOG" 2>&1 </dev/null &
```

Защита прода — в начале скрипта промоута (вторая линия после proddeploy):

```bash
mark=$(ssh <ПСЕВДОНИМ_БОЕВОГО_СЕРВЕРА> "sed -n 's/^status=//p' /opt/test-marks/$VERSION.txt | head -1")
[ "$mark" = green ] || { echo "ОТКАЗ: тесты версии $VERSION не зелёные (статус: ${mark:-нет отметки})"; exit 1; }
```

## Приложение Л. tests-sync: отметки тестов в задачах, возврат виноватых

Действующий скрипт, переписанный под API; на песочнице проверены перенос отметок, создание одной задачи на починку при красных тестах (без дубликата при повторе), отметка «зелёные» у задач, если позже есть зелёная сборка, и отсутствие задачи, когда красная уже исправлена. Запускается таймером раз в минуту. Делает четыре вещи: (1) отметку running без обновления дольше 20 минут при мёртвом процессе прогона переводит в red; (2) пишет в поле «Тесты» каждой задачи в deploy/dev, test/dev, deploy/prod отметку выкатки, в которую она вошла: если какая-то более поздняя сборка зелёная, задача зелёная (сборки накопительные), иначе отметка ближайшей сборки не ниже «DEV · Версия» задачи; (3) очищает поле у задач, вернувшихся в работу (по задаче — запрос API); (4) для каждой новой красной отметки заводит ОДНУ задачу на починку («Починить красную сборку <версия>: <причина>») с упавшими проверками, списком вошедших в сборку задач и инструкцией; статусы задач сборки НЕ меняет: их код уже в central, и переделывать его не нужно. Задача на починку берётся агентами первой (в очереди захвата она впереди остальных), а первым её шагом агент проверяет, не исправлена ли причина уже. Если позже красной уже есть зелёная сборка, задача не заводится. Повторно задача на ту же версию не создаётся. Каждую красную отметку разбирают один раз (файл-метка в каталоге состояния).

```bash
#!/bin/bash
# /srv/agents/lib/tests-sync.sh — отметки тестов -> поле «Тесты»; красные тесты -> виноватая пачка обратно в todo
set -uo pipefail
. /srv/agents/lib/db.sh
SSHP=(ssh -o ControlMaster=auto -o ControlPath=/tmp/ssh-agents-%C -o ControlPersist=10m -o ConnectTimeout=10 -o BatchMode=yes "$PROD_SSH")

marks=$("${SSHP[@]}" 'for f in '"$MARKS"'/*.txt; do [ -f "$f" ] && echo "$(basename "$f" .txt)|$(sed -n "s/^status=//p" "$f")"; done' 2>/dev/null) || exit 0
[ -n "$marks" ] || exit 0

# Сторож: running без итога дольше 20 минут и процесса прогона на этой машине — прогон оборвался
stale=$("${SSHP[@]}" 'now=$(date +%s); for f in '"$MARKS"'/*.txt; do [ -f "$f" ] || continue; grep -qx "status=running" "$f" || continue;
   u=$(sed -n "s/^updated=//p" "$f"); [ -n "$u" ] || continue; age=$(( now - $(date -d "$u" +%s 2>/dev/null || echo $now) ));
   [ $age -gt 1200 ] && echo "$(basename "$f" .txt)|$((age/60))"; done' 2>/dev/null)
while IFS='|' read -r v mins; do
  [ -n "$v" ] || continue
  pgrep -f "run-release-tests.sh $v" >/dev/null && continue
  why="прогон тестов не дал итога за $mins мин — похоже, оборвался; перезапуск: запустить scripts/run-release-tests.sh $v <коммит> <репозиторий>"
  "${SSHP[@]}" "f=$MARKS/$v.txt; sed -i -e 's/^status=running\$/status=red/' -e \"s|^summary=.*|summary=$why|\" -e \"s/^updated=.*/updated=\$(date -u +%Y-%m-%dT%H:%M:%SZ)/\" \$f" 2>/dev/null
  marks=$(printf '%s\n' "$marks" | sed "s/^$v|running\$/$v|red/")
done <<< "$stale"
marks=$(printf '%s\n' "$marks" | sort -t'|' -k1,1V)

# id|версия|текущая отметка — задачи, которым отметка нужна
trows=$(vt tasks "deploy/dev,test/dev,deploy/prod") || exit 0
tasks=$(printf '%s\n' "$trows" | awk -F'|' '{print $1"|"$3"|"$11}')

```

```bash
# Задача вернулась в работу — прежняя отметка относится к старому коду: очищаем поле
for id in $(vt tasks "todo,in progress,commit,error" | awk -F'|' '$11!=""{print $1}'); do vt set "$id" tests= >/dev/null; done

while IFS='|' read -r id ver cur; do
  [ -n "$id" ] && [ -n "$ver" ] || continue
  # Сборки накопительные: если более поздняя сборка уже зелёная, код задачи прошёл тесты (красная ниже исправлена)
  st=""; first=""
  while IFS='|' read -r mv ms; do
    vle "$ver" "$mv" || continue
    [ -z "$first" ] && first=$ms
    [ "$ms" = green ] && { st=green; break; }
  done <<< "$marks"
  [ -z "$st" ] && st=$first
  case "$st" in running) want=идут;; green) want=зелёные;; red) want=красные;; *) continue;; esac
  [ "$want" = "$cur" ] && continue
  vt set "$id" tests="$want" >/dev/null
done <<< "$tasks"

# Красные тесты -> виноватая пачка обратно на разработку (каждую красную отметку разбираем один раз)
while IFS='|' read -r rv rs; do
  [ "$rs" = red ] || continue
  [ -e "$STATE/red-handled-$rv" ] && continue
  pg=""; while IFS='|' read -r mv ms; do [ "$ms" = green ] && ! vle "$rv" "$mv" && pg=$mv; done <<< "$marks"   # последняя зелёная ниже красной
  fixed=""; while IFS='|' read -r mv ms; do [ "$ms" = green ] && vle "$rv" "$mv" && [ "$rv" != "$mv" ] && fixed=$mv; done <<< "$marks"   # зелёная сборка ПОСЛЕ красной
  summary=$("${SSHP[@]}" "sed -n 's/^summary=//p' $MARKS/$rv.txt" 2>/dev/null)
  L=/tmp/release-tests-$rv.log; fails=""
  [ -f "$L" ] && fails=$(sed 's/\x1b\[[0-9;]*m//g' "$L" | grep -E "^ *(FAIL|×|✗) " | sed 's/^ *//' | awk '!s[$0]++' | head -8)
  cul=""
  while IFS='|' read -r id ver cur; do
    [ -n "$id" ] && [ -n "$ver" ] || continue
    vle "$ver" "$rv" || continue                       # версия выше красной — не виновата
```

```bash
    [ -n "$pg" ] && vle "$ver" "$pg" && continue        # не выше последней зелёной — чинить нечего
    cul="$cul $id"
  done <<< "$tasks"
  # Красные тесты -> ОДНА задача на починку причины. Статусы задач сборки не трогаем: их код уже в central,
  # переделывать нечего. Если позже уже есть зелёная сборка — причина исправлена, задача не нужна.
  if [ -z "$fixed" ]; then
    rtasks=$(printf '%s\n' "$trows" | awk -F'|' -v ids=" $cul " 'index(ids, " "$1" ")>0 {print $3" — "$12}')
    RT_VER="$rv" RT_GREEN="$pg" RT_SUMMARY="${summary:-подробности в журнале выкатки}" RT_FAILS="$fails" RT_TASKS="$rtasks" \
      vt redtask >/dev/null || continue            # не вышло — без метки «разобрано», повторим через минуту
  fi
  touch "$STATE/red-handled-$rv"
done <<< "$marks"
exit 0
```

```bash
# /etc/systemd/system/tests-sync.service
[Unit]
Description=Отметки тестов -> поле «Тесты»
[Service]
Type=oneshot
ExecStart=/srv/agents/lib/tests-sync.sh

# /etc/systemd/system/tests-sync.timer
[Unit]
Description=Отметки тестов раз в минуту
[Timer]
OnBootSec=2min
OnUnitActiveSec=1min
AccuracySec=5s
[Install]
WantedBy=timers.target
```

Включение: systemctl enable --now tests-sync.timer.

## Приложение М. Вложения и файлы через API

Проверено на тестовой базе: загрузка файла, прикрепление к задаче, блок file в теле, скачивание обоими путями. Раньше скачивание вложений зависело от устройства контейнера Вторума; теперь всё идёт публичным API (скрипт fetch-asset.sh из Приложения Е, клиент vt.py).

Как человек или ассистент прикрепляет файл к задаче (например, макет) через API:

```bash
# 1. получить ссылку на загрузку
curl -s -X POST "$VTORUM_URL/v2/files/upload-url" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"parent":{"page_id":"<ID_ЗАДАЧИ>"},"filename":"maket.png","name":"maket.png","content_type":"image/png","size":<БАЙТ>}' > up.json
# 2. отправить файл по выданной ссылке (без заголовка авторизации)
curl -s -X PUT -H "Content-Type: image/png" --data-binary @maket.png "$(python3 -c 'import json;print(json.load(open("up.json"))["upload_url"])')"
# 3. прикрепить к свойству attaches (или положить блоком в тело)
FILE=$(python3 -c 'import json;print(json.dumps(json.load(open("up.json"))["file"]))')
curl -s -X PATCH "$VTORUM_URL/v2/pages/<ID_ЗАДАЧИ>" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"properties\":{\"attaches\":{\"files\":[$FILE]}}}"
```

Скачивание: ссылка file.url из свойства или блока подписана и живёт час; заголовок Authorization к ней добавлять нельзя. Перед работой над задачей агент вызывает fetch-asset.sh: он берёт свежую ссылку при каждом запуске. Размер одного файла ограничен лимитом пользователя, которому принадлежит ключ (по умолчанию 2 ГБ).

## Приложение Н. Сбой выкатки, когда модели нет

Действующие скрипты, переписанные под API; на песочнице проверено: сбой помечает пачку ошибкой и снимает замки, флаг починки выставляется, repair-done retry очищает ошибку. Если скрипт выкатки споткнулся, а модель на сервере не запускается (защита model_ok, раздел 12), пачку нельзя оставлять под замками. nomodel-fail снимает замки, пишет причину в «DEV · Последняя ошибка» и ставит флаг починки. Флаг подхватывает резервный агент на ноутбуке (Приложение О): чинит код, вливает его и командой repair-done возвращает пачку на повтор (серверный скрипт выкатит её сам). Счётчик сбоев растёт; после трёх подряд флаг становится human, и дальше решает человек. Если резервного агента нет, оставьте только человека: пачка стоит в deploy/* с ошибкой, замки сняты.

```bash
#!/bin/bash
# /srv/agents/lib/nomodel-fail.sh <deploy/dev|deploy/prod> "<описание сбоя>" — сбой выкатки, а модели нет
. /srv/agents/lib/db.sh
st="$1"; kind=dev; [ "$st" = deploy/prod ] && kind=prod
FLAG=$STATE/repair-$kind.txt
prev=$(sed -n 's/^status=//p' $FLAG 2>/dev/null); n=$(sed -n 's/^attempt=//p' $FLAG 2>/dev/null)
if [ "$prev" = retry ]; then n=$(( ${n:-1} + 1 )); else n=1; fi
short=$(printf '%s' "$2" | head -1 | cut -c1-300)
if [ $n -ge 3 ]; then status=human; why="Выкатка не прошла $n раза подряд, починка не помогла — нужен человек: $short"
else status=pending; why="Выкатка не прошла (сбой $n из 3), чинит резервный агент: $short"; fi
printf 'status=%s\nkind=%s\nattempt=%s\nsince=%s\nwhy=%s\n' "$status" "$kind" "$n" "$(date -u +%FT%TZ)" "$short" > $FLAG
# задачи пачки: в нужном статусе и под замком; ошибка записывается, замок снимается
ids=$(vt tasks "$st" | not_human | awk -F'|' '$7=="1"{print $1}' | paste -sd, -)
[ -n "$ids" ] && vt set "$ids" err="$(printf '%s' "$why" | cut -c1-900)" --unlock >/dev/null
echo "[$(date '+%F %T')] флаг починки $kind: $status, сбой $n"
```

```bash
#!/bin/bash
# /srv/agents/lib/repair-done.sh dev|prod retry|human "<текст>" — итог починки резервным агентом
. /srv/agents/lib/db.sh
kind=${1:?dev|prod}; act=${2:?retry|human}; text=${3:-}
st=deploy/dev; [ "$kind" = prod ] && st=deploy/prod
FLAG=$STATE/repair-$kind.txt
[ -f $FLAG ] || { echo "ОШИБКА флага починки $kind нет"; exit 1; }
ids=$(vt tasks "$st" | awk -F'|' '$4 ~ /^Выкатка не прошла/{print $1}' | paste -sd, -)
n=$(printf '%s' "$ids" | tr ',' '\n' | grep -c .)
if [ "$act" = retry ]; then
  [ -n "$ids" ] && vt set "$ids" err= >/dev/null
  sed -i -e 's/^status=.*/status=retry/' $FLAG; printf 'done=%s\n' "$text" >> $FLAG
  echo "OK пачка ($n задач) возвращена на повтор: серверный скрипт выкатит её в течение минуты-двух"
else
  [ -n "$ids" ] && vt set "$ids" err="Нужен человек (резервный агент не починил): $(printf '%s' "$text" | cut -c1-400)" >/dev/null
  sed -i -e 's/^status=.*/status=human/' $FLAG; printf 'done=%s\n' "$text" >> $FLAG
  echo "OK оставлено человеку ($n задач)"
fi
```

Эти вызовы уже стоят в run.sh (Приложение З): в точке «модели нет» после сбоя devdeploy вызывается nomodel-fail.sh deploy/dev, после сбоя proddeploy — nomodel-fail.sh deploy/prod. Скрипты нужны и без резервного агента: они снимают замки и записывают причину в задачи.

## Приложение О. Резервный агент на ноутбуке (macOS, launchd)

Действующая установка, переписанная под API (ноутбук ходит во Вторум напрямую с ключом бота; захват задачи и номер версии — через сервер агентов). Резервный агент нужен на случай, когда на сервере не работает модель или сервер недоступен. Он делает только разработку (шаг 2) и починку выкатки (режим repair); выкатки ведут серверные скрипты. Устройство: каталог ~/agents/backup-agent с run.sh, PROMPT.md (копия серверного), REPAIR.md (шаблон задания починки), ssh_config, bin (обёртки ssh и scp со своим конфигом), lib (обёртки серверных скриптов через ssh), state, logs и work (образ диска с рабочей копией).

Образ диска с учётом регистра. На macOS файловая система по умолчанию не различает регистр: репозитории, где есть файлы с именами, отличающимися только регистром, ломаются (частые коммиты «в рабочем дереве изменений нет, а git status не чист»). Рабочую копию держат на отдельном образе APFS с учётом регистра:

```bash
mkdir -p ~/agents/backup-agent/{state,logs,work,bin,lib}
hdiutil create -size 20g -type SPARSE -fs "Case-sensitive APFS" -volname agentwork ~/agents/backup-agent/work.sparseimage
hdiutil attach -nobrowse -mountpoint ~/agents/backup-agent/work ~/agents/backup-agent/work.sparseimage
git clone <АДРЕС_CENTRAL> ~/agents/backup-agent/work/repo
(cd ~/agents/backup-agent/work/repo && pnpm install --frozen-lockfile)
```

ssh_config (отдельный, чтобы не зависеть от пользовательского): из сети ноутбука сервер агентов может быть недоступен напрямую (VPN режет), поэтому ходим через боевой сервер:

```bash
Include ~/.ssh/config
Host prod
  HostName <АДРЕС_БОЕВОГО_СЕРВЕРА>
  User root
  IdentityFile ~/.ssh/<КЛЮЧ>
  IdentitiesOnly yes
  ControlMaster auto
  ControlPath /tmp/ssh-backup-%C
  ControlPersist 10m
  ServerAliveInterval 20
Host agents
  ProxyJump prod
```

Обёртки. bin/ssh и bin/scp подставляют свой конфиг (git ходит через GIT_SSH_COMMAND=bin/ssh); lib/* вызывают серверные скрипты:

```bash
# bin/ssh
#!/bin/bash
exec /usr/bin/ssh -F $HOME/agents/backup-agent/ssh_config "$@"
# bin/scp
#!/bin/bash
exec /usr/bin/scp -F $HOME/agents/backup-agent/ssh_config "$@"

# lib/env.sh — настройки для клиента Вторума на ноутбуке (подключается, source)
export VTORUM_URL="https://<ХОСТ_ВТОРУМА>"
export VTORUM_TOKEN_FILE=$HOME/agents/backup-agent/state/vtorum-token      # ключ бота, права 600
export VT_DB=<ИД_БАЗЫ_ЗАДАЧ>
export VT_HUMAN_WORKERS="human chat"

# lib/vt — клиент Вторума (копия vt.py лежит рядом: lib/vt.py); с ноутбука ходим в API напрямую
#!/bin/bash
. $HOME/agents/backup-agent/lib/env.sh
exec python3 $HOME/agents/backup-agent/lib/vt.py "$@"

# lib/summary.sh, lib/task-materials.sh, lib/fetch-asset.sh — тонкие обёртки над lib/vt
#!/bin/bash
# summary.sh:        exec $HOME/agents/backup-agent/lib/vt summary "$@"
# task-materials.sh: exec $HOME/agents/backup-agent/lib/vt materials "$@"
# fetch-asset.sh:    exec $HOME/agents/backup-agent/lib/vt file "$@"      (<id задачи> <F-номер> <куда>)

# lib/next-version.sh — номер выдаёт сервер под тем же замком, что и серверным агентам
#!/bin/bash
exec /usr/bin/ssh -F $HOME/agents/backup-agent/ssh_config -n -o BatchMode=yes agents /srv/agents/lib/next-version.sh /srv/dev/<ПРОЕКТ>

# lib/repair-done.sh — итог починки: флаг лежит на сервере, поэтому вызываем серверный скрипт
#!/bin/bash
exec /usr/bin/ssh -F $HOME/agents/backup-agent/ssh_config -n -o BatchMode=yes agents "/srv/agents/lib/repair-done.sh $(printf '%q ' "$@")"
```

Гвардии и слияние (guards.sh, integrate.sh) на ноутбуке запускаются локально над рабочей копией: скопируйте их в lib, уберите строку подключения db.sh и задайте LOCKS=$R/state (им нужен только каталог для замка). Замок слияния при этом локальный: слияния резервного и серверных агентов могут совпасть, но push в central всё равно отвергнет устаревшую ветку, а integrate повторит слияние (до трёх попыток). Номера версий выдавайте только через lib/next-version.sh — он ходит на сервер и держит общий замок.

run.sh резервного агента:

```bash
#!/bin/bash
set -uo pipefail
R=$HOME/agents/backup-agent
W=$R/work/repo; LIB=$R/lib
NAME="<ИМЯ_РЕЗЕРВНОГО_АГЕНТА>"           # значение select worker
LOG=$R/logs/agent.log; TASKFILE=$R/state/agent.task; REPAIRFILE=$R/state/repair.kind; LOCK=$R/state/run.lock
. $R/lib/env.sh
export PATH="$R/bin:/opt/homebrew/opt/node@24/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export GIT_SSH_COMMAND="$R/bin/ssh"
[ -s $R/state/oauth-token ] && export CLAUDE_CODE_OAUTH_TOKEN="$(cat $R/state/oauth-token)"
log(){ printf "[%s] %s\n" "$(date "+%F %T")" "$*" >> "$LOG"; }
SRV(){ /usr/bin/ssh -F $R/ssh_config -o BatchMode=yes -o ConnectTimeout=15 agents "$@"; }
status_of(){ $LIB/vt get "$1" 2>/dev/null | cut -d'|' -f2; }

# Замок прогона: mkdir атомарен; в нём PID. Мёртвый PID (ноутбук выключали) или возраст > 100 минут — замок снимаем
if ! mkdir "$LOCK" 2>/dev/null; then
  pid=$(cat "$LOCK/pid" 2>/dev/null); age=$(( $(date +%s) - $(stat -f %m "$LOCK") ))
  if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null && [ $age -lt 6000 ]; then exit 0; fi
  log "замок прогона ничей — снимаю"; rm -rf "$LOCK"; mkdir "$LOCK" || exit 0
fi
echo $$ > "$LOCK/pid"; trap 'rm -rf "$LOCK"' EXIT
# Образ диска после перезагрузки отключён — подключаем сами
if [ ! -d "$W/.git" ]; then
  hdiutil attach -quiet -nobrowse -mountpoint $R/work $R/work.sparseimage 2>>"$LOG" || { log "не удалось подключить образ"; exit 0; }
  [ -d "$W/.git" ] || { log "нет рабочей копии $W"; exit 0; }
fi

MODE="" TASK="" TITLE=""
if [ -s "$TASKFILE" ]; then                         # незавершённая задача
  IFS='|' read -r TASK TITLE < "$TASKFILE"; st=$(status_of "$TASK")
  if [ "$st" = "in progress" ]; then MODE=resume
  elif [ -z "$st" ]; then exit 0                    # база не ответила — через минуту
  else
```

```bash
    if [ -n "$(git -C $W status --porcelain)" ] || [ "$(git -C $W rev-list --count origin/main..HEAD 2>/dev/null || echo 0)" -gt 0 ]; then
      log "задача «$TITLE» уже не in progress ($st), а в копии остались правки — нужен человек"; exit 0; fi
    rm -f "$TASKFILE"; TASK="" TITLE=""
  fi
fi
if [ -z "$MODE" ]; then
  [ -e $R/state/paused ] && exit 0                  # выключатель: touch state/paused
  if [ -s "$REPAIRFILE" ]; then KIND=$(cat "$REPAIRFILE"); MODE=repair   # починка выкатки
  else
    for k in dev prod; do
      [ "$(SRV "sed -n 's/^status=//p' /srv/agents/state/repair-$k.txt 2>/dev/null")" = pending ] || continue
      SRV "sed -i 's/^status=pending/status=taken/' /srv/agents/state/repair-$k.txt" && { echo $k > "$REPAIRFILE"; KIND=$k; MODE=repair; break; }
    done
  fi
fi
if [ -z "$MODE" ]; then                             # новая задача
  plan=$(SRV /srv/agents/lib/plan.sh) || exit 0
  printf '%s\n' "$plan" | grep -qx todo || exit 0
  [ -n "$(git -C $W status --porcelain)" ] && { log "рабочая копия не чиста — нужен человек"; exit 0; }
  git -C $W fetch -q origin && git -C $W switch -q -C agent-backup origin/main \
    && (cd $W && pnpm install --frozen-lockfile --prefer-offline >/dev/null 2>&1) || { log "не удалось подготовить копию"; exit 0; }
  r=$(SRV "/srv/agents/lib/claim.sh '$NAME'") || exit 0
  [ -n "$r" ] || exit 0
  IFS='|' read -r TASK TITLE <<< "$r"; printf '%s\n' "$r" > "$TASKFILE"; MODE=task
fi
case $MODE in
  task)   what="РЕЖИМ task. Выполни ТОЛЬКО шаг 2 для задачи «$TITLE» (id $TASK). Её уже взял скрипт: статус in progress, замок, «DEV · Обновлено» и worker («$NAME») стоят. Шаги 1 и 3 не трогай.";;
```

```bash
  resume) what="РЕЖИМ task (продолжение). Прошлый прогон оборвался на задаче «$TITLE» (id $TASK): она in progress под замком. Посмотри git status и git log origin/main..HEAD и доведи шаг 2 до конца.";;
  repair)
    if [ "$KIND" = dev ]; then F=devdeploy-fail.txt; O=devdeploy-installer.log; WH="на dev"; SCR=devdeploy.sh; ST=deploy/dev
    else F=proddeploy-fail.txt; O=proddeploy-promote.log; WH="на прод"; SCR=proddeploy.sh; ST=deploy/prod; fi
    TITLE="починка выкатки $WH"
    SRV "cat /srv/agents/state/repair-$KIND.txt" > $R/state/repair.flag 2>/dev/null
    # длинный текст — в REPAIR.md: bash 3.2 в macOS не разбирает длинные строки с кавычками внутри case
    what=$(python3 -c 'import sys; t=open(sys.argv[1],encoding="utf-8").read(); d=dict(a.split("=",1) for a in sys.argv[3:]); d["FLAG"]=open(sys.argv[2],encoding="utf-8").read().strip(); [t:=t.replace("{"+k+"}",v) for k,v in d.items()]; print(t)' \
      $R/REPAIR.md $R/state/repair.flag "WH=$WH" "SCR=$SCR" "F=$F" "O=$O" "ST=$ST" "W=$W" "LIB=$LIB" "KIND=$KIND");;
esac
HEADER="## ЗАДАНИЕ ЭТОГО ПРОГОНА — главнее всего остального в инструкции
Ты — резервный агент разработки, кодовое имя «$NAME». Ты работаешь НЕ на сервере агентов, а на ноутбуке (macOS). Инструкция ниже написана для серверных агентов — читай её с поправками:
- Рабочая копия — \`$W\` (ветка \`agent-backup\`). Каталоги человека не трогай.
- Служебные скрипты — в \`$LIB\` (обёртки): task-materials, fetch-asset, summary, next-version, guards, integrate.
- В macOS нет GNU-утилит: \`sed -i\` пиши как \`sed -i ''\`, \`timeout\` нет, \`grep -P\` нет.
- Поле worker уже заполнил скрипт. На dev и прод НЕ выкатывай: выкатку ведут серверные скрипты.
$what"
log "=== старт прогона: $MODE «$TITLE»"
cd "$W" || exit 1
```

```bash
# через 90 минут прогон снимается (в macOS нет timeout — perl alarm)
perl -e 'alarm shift; exec @ARGV' 5400 claude -p "$HEADER

$(cat $R/PROMPT.md)" --model <МОДЕЛЬ> --effort medium --allowedTools "Bash,Read,Edit,Write,Glob,Grep,TodoWrite,ToolSearch,WebFetch,WebSearch,Agent" >> "$LOG" 2>&1
code=$?
if [ "$MODE" = repair ]; then
  st=$(SRV "sed -n 's/^status=//p' /srv/agents/state/repair-$KIND.txt 2>/dev/null")
  [ "$st" = taken ] && { $LIB/repair-done.sh $KIND human "прогон починки закончился без итога (код $code) — смотри logs/agent.log на ноутбуке" >> "$LOG" 2>&1; st=human; }
  rm -f "$REPAIRFILE"
else
  st=$(status_of "$TASK"); [ -n "$st" ] && [ "$st" != "in progress" ] && rm -f "$TASKFILE"
fi
log "=== прогон завершён ($MODE), код $code, итог: ${st:-?}"
```

REPAIR.md (шаблон задания починки; подстановки {…} делает run.sh):

```bash
РЕЖИМ repair — ПОЧИНКА ВЫКАТКИ {WH}. Выкатку ведёт скрипт /srv/agents/lib/{SCR} на сервере (без модели), и он споткнулся; модели на сервере нет, поэтому чинишь ты. Флаг починки:
{FLAG}
Порядок:
1. Прочитай описание сбоя и конец вывода: `ssh agents cat /srv/agents/state/{F}` и `ssh agents tail -120 /srv/agents/state/{O}`. Пачка — задачи в `{ST}` с ошибкой «Выкатка не прошла…».
2. Найди причину. Установщик и промоут НЕ запускай ни здесь, ни на сервере; на серверах ничего не правь руками; серверные рабочие копии не трогай.
3. Причина в коде (гвардия, тест сборки, Dockerfile, скрипты выкатки, конфликт, номер версии): `cd {W} && git fetch -q origin && git switch -q -C agent-backup origin/main`, почини, прогони `{LIB}/guards.sh {W}` и затронутые тесты, закоммить («<версия из package.json> — починка выкатки: <суть>»; номер версии не поднимай, если причина не в нём) и влей `{LIB}/integrate.sh {W}` до OK.
4. Причина не в коде (сеть, хранилище, временный отказ сервера) — ничего не правь.
5. Итог — ОБЯЗАТЕЛЬНО одной из команд: `{LIB}/repair-done.sh {KIND} retry "<что сделал>"` (серверный скрипт сам повторит выкатку) или, если без человека не починить, `{LIB}/repair-done.sh {KIND} human "<почему>"`.
Статусы задач руками не меняй, резюме в задачи не пиши, новых задач не бери.
```

launchd (~/Library/LaunchAgents/<ИМЯ>.plist) — запуск раз в минуту:

```bash
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
  <key>Label</key><string>net.example.backup-agent</string>
  <key>ProgramArguments</key><array><string>/bin/bash</string><string>/Users/<ПОЛЬЗОВАТЕЛЬ>/agents/backup-agent/run.sh</string></array>
  <key>StartInterval</key><integer>60</integer>
  <key>RunAtLoad</key><true/>
  <key>StandardOutPath</key><string>/Users/<ПОЛЬЗОВАТЕЛЬ>/agents/backup-agent/logs/launchd.log</string>
  <key>StandardErrorPath</key><string>/Users/<ПОЛЬЗОВАТЕЛЬ>/agents/backup-agent/logs/launchd.log</string>
  <key>ProcessType</key><string>Background</string>
</dict></plist>
```

Управление: включить — launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/<ИМЯ>.plist; выключить совсем — launchctl bootout gui/$(id -u)/<Label>; пауза без отключения службы — touch ~/agents/backup-agent/state/paused (недоделанную задачу агент всё равно доведёт), снять — rm state/paused. Файл токена Claude для ноутбука — state/oauth-token (права 600). Скрипт запуска правьте только заменой файла целиком, как и серверные.

## Приложение П. Сторож системы

Действующий скрипт, переписанный под API (задачи читает vt tasks); сам скрипт на песочнице не запускался. Печатает в стандартный вывод только новые отклонения, по одному разу (повторы гасит файл уже показанных строк). Его запускают как наблюдателя из сессии оператора: в цикле раз в две-три минуты по ssh, и каждая напечатанная строка — событие. Проверяет: вернулась ли поддержка нужных процессору инструкций, зависшие процессы claude, отметки тестов (red, running слишком долго, green), версии стендов и их доступность, замки задач дольше 40 минут, ошибки у задач, статус error, deploy/dev без версии, задачи в test/* без версии, флаги починки выкатки, строки СБОЙ/ОШИБКА в журналах агентов, упавшие службы systemd.

```bash
#!/bin/bash
# /srv/agents/lib/system-watch.sh — сторож: печатает только новые отклонения, по одному разу
. /srv/agents/lib/db.sh 2>/dev/null
SEEN=/srv/agents/state/system-watch.seen; touch $SEEN
emit(){ grep -qxF "$1" $SEEN || { echo "$1" >> $SEEN; echo "$2"; }; }
grep -qw sse4_2 /proc/cpuinfo && emit cpu-ok "ПРОЦЕССОР: нужные инструкции есть — Claude может работать"
for p in $(ps -o pid= -C claude); do e=$(ps -o etimes= -p $p | tr -d ' '); [ "${e:-0}" -gt 1800 ] && emit "claude-$p" "CLAUDE: процесс $p идёт $((e/60)) мин"; done
ssh -o ControlMaster=auto -o ControlPath=/tmp/ssh-agents-%C -o ControlPersist=10m -o BatchMode=yes -o ConnectTimeout=10 "$PROD_SSH" \
 'now=$(date +%s); for f in '"$MARKS"'/*.txt; do s=$(sed -n "s/^status=//p" $f); u=$(sed -n "s/^updated=//p" $f); a=$(( (now-$(date -d "$u" +%s))/60 )); [ $a -lt 1440 ] && echo "$(basename $f .txt)|$s|$a|$(sed -n "s/^summary=//p" $f)"; done' 2>/dev/null |
while IFS='|' read -r v s a sum; do
  case $s in
    running) [ "$a" -ge 12 ] && emit "run-$v" "ТЕСТЫ $v: running уже $a мин — возможно, прогон оборвался";;
    red)     emit "red-$v" "ТЕСТЫ $v: КРАСНЫЕ — $sum";;
    green)   emit "green-$v" "тесты $v зелёные — $sum";;
  esac
done
d=$(vver "$DEV_URL"); p=$(vver "$PROD_URL")
[ -n "$d" ] && emit "dev-$d" "dev теперь $d"; [ -n "$p" ] && emit "prod-$p" "прод теперь $p"
[ -z "$d" ] && emit "devdown-$(date +%H%M)" "DEV НЕ ОТВЕЧАЕТ"; [ -z "$p" ] && emit "proddown-$(date +%H%M)" "ПРОД НЕ ОТВЕЧАЕТ"
# задачи: замок дольше 40 минут, ошибки, статус error, deploy/dev без версии, test/* без версии
vt tasks "in progress,commit,deploy/dev,test/dev,deploy/prod,test/prod,error,todo" 2>/dev/null |
while IFS='|' read -r id st ver err upd edited lk worker created blocked tests title; do
```

```bash
  age=$(( ($(date +%s) - $(date -d "$edited" +%s)) / 60 ))
  [ "$lk" = 1 ] && [ "$age" -ge 40 ] && emit "lock-$id-$st" "ЗАМОК: «$title» ($st) заперта $age мин"
  [ -n "$err" ] && emit "err-$id-$err" "ОШИБКА В ЗАДАЧЕ «$title» ($st): ${err:0:120}"
  [ "$st" = error ] && emit "st-err-$id" "СТАТУС error: «$title»"
  [ "$st" = "deploy/dev" ] && [ -z "$ver" ] && [ -n "$d" ] && [ "$age" -ge 20 ] && emit "nover-$id" "«$title» в deploy/dev $age мин без «DEV · Версия»"
  case $st in test/dev|test/prod|deploy/prod) [ -z "$ver" ] && [ "$age" -lt 1440 ] && emit "nover2-$id" "БЕЗ ВЕРСИИ: «$title» в $st — не заполнено «DEV · Версия»";; esac
done
for k in dev prod; do F=$STATE/repair-$k.txt; [ -f $F ] || continue
  s=$(sed -n "s/^status=//p" $F); a=$(sed -n "s/^attempt=//p" $F); age=$(( ($(date +%s) - $(stat -c %Y $F)) / 60 ))
  case $s in
    pending) emit "rep-$k-$a-p" "ПОЧИНКА $k: нужна (сбой $a из 3)"; [ $age -ge 15 ] && emit "rep-$k-$a-late" "ПОЧИНКА $k: резервный агент не берёт её уже $age мин";;
    human)   emit "rep-$k-$a-h" "ПОЧИНКА $k: НУЖЕН ЧЕЛОВЕК — $(sed -n "s/^done=//p;s/^why=//p" $F | tail -1)";;
  esac
done
for f in /var/log/dev-agent-*.log; do
  grep -E "СБОЙ|ОШИБКА|не удалось" $f 2>/dev/null | tail -3 | while read -r l; do emit "log-$(echo "$l" | md5sum | cut -c1-12)" "ЖУРНАЛ $(basename $f .log): $(echo "$l" | cut -c1-250)"; done
done
systemctl --failed --no-legend --plain | awk '{print $1}' | while read -r u; do [ -n "$u" ] && emit "fail-$u-$(date +%F)" "СЛУЖБА УПАЛА: $u"; done
exit 0
```

Наблюдатель из сессии оператора (одна строка — одно событие; перезагрузки сервера ловит и по росту аптайма, добавьте при желании строку uptime -s в скрипт):

```bash
while true; do ssh -n -o BatchMode=yes -o ConnectTimeout=15 agents /srv/agents/lib/system-watch.sh 2>&1; sleep 150; done
```

## Приложение Р. Установка и запуск

### Автоматическая установка (рекомендуется)

Установщик — файл vtorum-pipeline-kit.py, он приложен к этой странице (блок в начале). В нём лежат все скрипты из приложений и вся логика установки: больше ничего скачивать не нужно. Python 3.8+, только стандартная библиотека. Что он делает сам:

- проверяет ключ бота и доступ ко Вторуму;
- создаёт базу задач (все свойства, 11 статусов, значения worker для ваших агентов и человека, пара связей зависимостей) на указанной странице, либо проверяет уже готовую (--db);
- раскладывает скрипты, подставляя ваши адреса, пути, команды установщика и промоута, имена агентов, модель, язык отчётов; пишет файл ids.sh;
- создаёт каталоги, клонирует общую копию проекта и личные рабочие копии агентов из central;
- пишет юниты systemd (при запуске от root устанавливает их, с флагом --enable включает таймеры агентов и tests-sync);
- прогоняет самопроверку на тестовых задачах: готовность и зависимости, захват, замок записи, запись под замком, возврат ошибочной задачи; тестовые задачи убирает в корзину;
- печатает, что осталось сделать руками.
Руками остаётся только то, что принципиально делает человек: (1) выпустить во Вторуме ключ бота (настройки пространства, «API для интеграций») и дать боту доступ к странице, где будет база; (2) войти в Claude Code и выпустить долгоживущий токен агентов (claude setup-token); (3) дать свой установщик dev и промоут на прод (их команды передаются флагами). Команда:

```bash
python3 vtorum-pipeline-kit.py install \
  --url https://<ХОСТ_ВТОРУМА> --token-file ~/.vtorum-bot-token --parent <ID_СТРАНИЦЫ_ДЛЯ_БАЗЫ> \
  --project "Название проекта" --agents 3 --human "human chat" \
  --central <АДРЕС_CENTRAL_РЕПОЗИТОРИЯ> --project-dir /srv/dev/<ПРОЕКТ> \
  --dev-url https://<DEV>/api/version --prod-url https://<ПРОД>/api/version \
  --prod-ssh <ПСЕВДОНИМ_БОЕВОГО_СЕРВЕРА> --marks /opt/test-marks \
  --install-cmd "timeout 1800 ./install-dev.sh" --promote-cmd "timeout 1200 ./promote-to-prod.sh" \
  --model claude-sonnet-5-5 --lang русски
```

Параметры: --parent (создать новую базу на странице) или --db (проверить готовую); --agents (сколько серверных агентов) и --agent-prefix (имена «agent one», «agent two»…); --backup-name (резервный агент, необязательно); --human (значение worker для задач, которые человек делает сам из чата; агенты их не берут); --root (каталог конвейера, по умолчанию /srv/agents); --test-cmd, --version-files, --deps-cmd, --typecheck-cmd, --fulltest-cmd (команды и список мест с версией: они попадают в промпт и в скрипт тестов); --systemd no и --enable; --no-selftest. Полный список: python3 vtorum-pipeline-kit.py install --help. Отдельная проверка уже установленного: python3 vtorum-pipeline-kit.py selftest --url … --token-file … --root …; список файлов внутри: python3 vtorum-pipeline-kit.py files.

Повторный запуск перезаписывает скрипты и PROMPT.md (ваши правки промпта сохраните копией). Таймеры без --enable не включаются: сначала проверьте вручную (plan.sh, один прогон run.sh 1 на тестовой задаче), потом включайте.

Проверено: установка в чистый каталог с созданием настоящей базы во Вторуме и самопроверка (семь проверок), повторная установка на готовую базу (--db), клонирование копий, подстановки в скриптах и промпте, синтаксис всех установленных скриптов, запуск установленных plan.sh и vt. Не проверено в этой среде: установка юнитов systemd и включение таймеров (проверка шла на macOS без systemd; код записи юнитов простой), запуск run.sh с моделью.

### Ручная установка: то же, что делает установщик

Порядок взят из действующей установки; конкретные имена подставьте свои.

1. Создайте бота и ключ во Вторуме и выдайте боту доступ на правку к будущей базе; создайте базу задач (Приложение А) с ключом бота; сохраните вывод в /srv/agents/lib/ids.sh.
1. Каталоги: mkdir -p /srv/agents/{lib,bin,state,locks,work} /srv/agents/dev-agent /var/log. Положите скрипты из приложений В–Ж в lib (в том числе vt.py и ids.sh), обёртки pnpm/npx и vt — в bin, run.sh и PROMPT.md — в dev-agent. chmod +x для всех скриптов. Заполните заглушки в db.sh (адрес Вторума, файл ключа бота, адреса версий).
1. Токен Claude: на машине с браузером выполните claude setup-token, сохраните ключ в /root/.claude/agent-oauth-token (chmod 600). Проверьте: CLAUDE_CODE_OAUTH_TOKEN=$(cat …) claude -p "ответь ок".
1. Репозитории: git clone central в общую копию и в /srv/agents/work/agent-N для каждого агента; в личных копиях один раз выполните pnpm install.
1. ssh: ключи до central и до боевого сервера, псевдонимы, заранее принятые отпечатки (ssh -o StrictHostKeyChecking=accept-new <хост> true).
1. Проверка скриптов вручную по порядку: vt tasks (строки записей или пусто, без ошибок API: 403 значит, у бота нет доступа к базе), plan.sh (код 1 при пустой базе), создайте задачу в todo и снова plan.sh (todo), claim.sh "agent one" (id|название, статус стал in progress, замок включён, worker заполнен), верните задачу в todo: vt set <id> status=todo worker= --unlock. Проверьте зависимость: свяжите две задачи и убедитесь, что vt ready не отдаёт зависимую, пока блокирующая не в commit. Затем next-version.sh, guards.sh, integrate.sh на тестовой ветке.
1. Запустите run.sh 1 вручную на тестовой задаче («поменять подпись кнопки») и следите за журналом: должны появиться захват, коммит, integrate OK, статус commit, резюме, снятый замок.
1. Включите таймеры: systemctl daemon-reload && systemctl enable --now dev-agent@1.timer. Добавляйте агентов по одному, следя за нагрузкой.
1. Проверьте выкатки на тестовой задаче: общая выкатка на dev (Приложение Ж) должна поставить версию и перевести задачу в test/dev; затем вручную поставьте deploy/prod и убедитесь, что proddeploy.sh выкатил её и поставил test/prod.
1. Тесты и защита прода (Приложения К–Л): положите scripts/run-release-tests.sh в проект, запустите его из установщика dev в фоне, добавьте проверку отметки в начало промоута, включите tests-sync.timer. Проверьте: после выкатки на dev на боевом сервере появился файл отметки со status=running, затем green; поле «Тесты» у задач заполнилось; промоут без зелёной отметки отказывает.
1. Вложения (Приложения Е и М): прикрепите файл к тестовой задаче и прогоните fetch-asset.sh <id> F1 /tmp/x; убедитесь, что файл скачался и открывается.
1. Сбой без модели (Приложение Н): временно создайте на сервере файл /srv/agents/state/force-nomodel и в model_ok проверьте его наличие, либо проверьте скрипты вручную: nomodel-fail.sh deploy/dev "тест" на тестовой пачке должен снять замки и записать ошибку; repair-done.sh dev retry "тест" — очистить её.
1. Резервный агент и сторож (Приложения О и П) — по желанию, после того как основной конвейер отработал несколько дней.
1. Только после этого отдавайте агентам рабочие задачи.
## Что в инструкции проверено, а что нет

- Установщик (файл на странице): проверен установкой в чистый каталог с созданием настоящей базы во Вторуме и самопроверкой; повторно — на готовой базе; systemd и run.sh с моделью в этой среде не проверялись.
- Проверено на песочнице (тестовая база Вторума, локальные заглушки установщика, промоута и стенда версий; сама песочница убрана): создание базы и пары связей; клиент vt.py (tasks, get, ready, claim, set с замками, materials, file, summary); готовность задач и зависимости при всех статусах блокирующей; атомарный захват двумя агентами; номера версий, слияние в central (integrate); полный цикл commit → deploy/dev → test/dev → deploy/prod → test/prod скриптами devdeploy и proddeploy; отметки тестов и tests-sync (перенос в поле «Тесты», возврат виноватой пачки в todo с резюме); запись сбоя без модели и повтор после починки; загрузка и скачивание вложений.
- Не проверено на песочнице: run.sh с настоящей моделью (проверен синтаксис), резервный агент на ноутбуке, сторож, шаблон промпта в работе агента, очередь тяжёлых проверок под нагрузкой. Эти части сведены из действующей системы; проверьте их на этапах 1–5 внедрения.
- Что остаётся за вами: заглушки в угловых скобках; установщик dev и промоут на прод вашего проекта (скрипты выкатки вызывают их через INSTALL_CMD и PROMOTE_CMD и ждут маркеры); список мест, где в проекте зашит номер версии; Dockerfile с гвардиями.
- Условия: файлы тестировались на Linux-подобных командах; на macOS нет flock (на сервере агентов он есть), поэтому для проверки скриптов на ноутбуке понадобится его замена.
