Рабочие копии¶
Как runner готовит каждой задаче изолированную рабочую копию репозитория, раскладывает рядом соседние репозитории на закреплённых ревизиях, превращает результат в evidence (коммит) и публикует ветку задачи в forge. Статья для инженера, настраивающего runner, и для оператора, который принимает результат.
Зачем отдельная копия на задачу¶
Адаптер, работающий в текущем каталоге процесса, сериализует исполнителя на одну задачу и
смешивает изменения разных задач — коммит перестаёт быть доказательством. Поэтому каждая
задача получает собственный git worktree на детерминированной ветке task/<publicId>, а
результат фиксируется коммитом, на который Control Plane хранит ссылку, а не копию.
Пул рабочих копий включается, когда заданы обе переменные:
CONTROL_PLANE_AGENT_REPO=/opt/runner/<repo>.git # источник копий (bare-зеркало)
CONTROL_PLANE_AGENT_WORKTREE_ROOT=/opt/runner/worktrees # где живут копии
Без них адаптер получает workspace=None и работает в каталоге процесса — так удобно
проверять протокол, но не выполнять реальные задачи.
Не путайте две похожие переменные
CONTROL_PLANE_AGENT_WORKSPACE — это Workspace в Control Plane, из которого берутся
задачи. CONTROL_PLANE_AGENT_WORKTREE_ROOT — каталог на диске для рабочих копий.
Раскладка: контейнер задачи¶
Рабочая копия — не один каталог, а небольшой контейнер: сама копия и рядом соседи, от которых она собирается.
<WORKTREE_ROOT>/
├── .locks/
│ └── <publicId>.lock эксклюзивная блокировка копии (flock)
└── <publicId>/ контейнер задачи
├── <REPO_DIR>/ рабочая копия, ветка task/<publicId>
├── platform-auth-sdk/ сосед на ревизии, закреплённой суперпроектом
└── memory-service/ ещё один сосед (например сервис-контракт)
Зачем соседи. Репозиторий, который собирается против соседа path-зависимостью
(../platform-auth-sdk), не соберётся из копии самого себя. А сосед должен стоять на той
ревизии, которую закрепляет суперпроект, а не на вершине своей ветки: иначе зелёный прогон
тестов проверил комбинацию ревизий, которой нет ни в одном коммите.
Имя каталога соседа совпадает с путём сабмодуля в суперпроекте — ровно то, что называет
path-зависимость ../<neighbour>. Поэтому плоская раскладка сабмодулей суперпроекта
обязательна.
Настройка соседей¶
CONTROL_PLANE_AGENT_REPO_DIR=control-plane
CONTROL_PLANE_AGENT_NEIGHBOURS=platform-auth-sdk=/opt/runner/platform-auth-sdk.git,memory-service=/opt/runner/memory-service.git
CONTROL_PLANE_AGENT_SUPERPROJECT=/opt/runner/superproject.git
CONTROL_PLANE_AGENT_SUPERPROJECT_REF=HEAD
CONTROL_PLANE_AGENT_SUPERPROJECT_REMOTE=origin
| Переменная | Смысл |
|---|---|
CONTROL_PLANE_AGENT_REPO_DIR |
имя каталога рабочей копии внутри контейнера; по умолчанию — имя репозитория без .git. Важно, когда соседи ссылаются на него относительным путём |
CONTROL_PLANE_AGENT_NEIGHBOURS |
пары имя=путь-к-зеркалу через запятую или пробел; имя — путь сабмодуля в суперпроекте |
CONTROL_PLANE_AGENT_SUPERPROJECT |
зеркало суперпроекта: ревизии соседей читаются из его дерева (git ls-tree, gitlink 160000) |
CONTROL_PLANE_AGENT_SUPERPROJECT_REF |
ref суперпроекта, из которого берутся ревизии; по умолчанию HEAD |
CONTROL_PLANE_AGENT_SUPERPROJECT_REMOTE |
remote, из которого суперпроект обновляется перед раскладкой; пусто — используется то, что лежит на диске |
Правила, которые проверяются при старте и на каждой задаче:
- соседи без суперпроекта — ошибка конструкции пула (
neighbours require a superproject that pins their revisions): раскладывать соседей «на то, что сейчас в main» запрещено; - некорректная пара в
NEIGHBOURS— ошибка, а не молчаливый пропуск: пропущенный сосед вернулся бы сборочной ошибкой внутри копии агента, где причины уже не видно; - сосед, которого нет среди сабмодулей суперпроекта на заданном ref, — ошибка
<name> is not a submodule of the superproject; - если в зеркале соседа нет закреплённого коммита, демон делает
fetchзеркала сам (best-effort); без доступа к forge задача упадёт с понятной ошибкойinvalid reference.
Соседи — рабочие копии только для чтения по смыслу: агенту в файле соглашений стоит прямо сказать, что правки вне своего репозитория он не делает, а контракты сервисов-соседей берёт из их кода, а не придумывает.
Сервис-контракт как сосед
Если репозиторий вызывает API другого сервиса, добавьте этот сервис соседом. Агент без него склонен выдумать контракт и написать тесты под собственный фейк — такое ловит только ревью. С соседом он читает настоящие схемы запросов и может закрепить их contract-тестом.
Жизненный цикл копии¶
stateDiagram-v2
[*] --> acquire: задача взята
acquire --> working: lock, prune, fetch базы,<br/>worktree add / reuse, соседи
working --> committed: адаптер закончил
committed --> published: push task/<publicId>
committed --> kept_local: push не удался / remote не задан
published --> released
kept_local --> released
working --> released_failed: ошибка / потеря аренды
released --> removed: успех и не KEEP_WORKSPACES
released --> kept: есть незакоммиченное
released_failed --> kept: копия сохраняется как есть
removed --> [*]
kept --> [*]
acquire¶
- Проверка ключа:
publicIdдолжен соответствовать^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$— он становится именем каталога и ветки. - Эксклюзивная блокировка
<root>/.locks/<publicId>.lock. Занята другим процессом —WorkspaceBusyError, run проваливается сfailure_reason=workspace_busy. git worktree pruneв зеркале — снять записи о копиях, удалённых мимо git.- Обновление базы: если задан
CONTROL_PLANE_AGENT_PUSH_REMOTE, демон делаетgit fetch <remote> <base-branch>и ветвится отFETCH_HEAD. Базовая ветка —CONTROL_PLANE_AGENT_BASE_REF, а приHEAD— ветка, на которую указывает HEAD зеркала. Без remote или при недоступном forge — ветвление отBASE_REFкак есть. - Копия:
- уже есть (повторная попытка) — проверяется, что это worktree на ветке
task/<publicId>, и она переиспользуется вместе с незакоммиченными изменениями; - ветка есть, копии нет (копию убрали после прошлого успеха) —
worktree addна существующую ветку: вторая попытка продолжает работу, а не начинает с нуля и не перебазируется на свежую базу; - ничего нет —
worktree add -b task/<publicId> <base>.
- уже есть (повторная попытка) — проверяется, что это worktree на ветке
- Раскладка соседей на ревизиях суперпроекта. Сосед с локальными изменениями не переключается — такие правки не уничтожаются молча.
- Checkpoint
execution.workspaceв run.
Копия на ветке, отличной от ожидаемой, — ошибка: молча переключить значило бы смешать две задачи в одной копии.
Checkpoint execution.workspace¶
Что уходит в Control Plane — только переносимое состояние, без путей хоста:
{
"kind": "execution.workspace",
"data": {
"workspaceKey": "<publicId>",
"branch": "task/<publicId>",
"baseCommit": "3f1c…",
"reused": false,
"neighbours": {"platform-auth-sdk": "a8c5…"}
}
}
После коммита пишется второй checkpoint того же вида с head и published.
neighbours — часть evidence: зелёный прогон осмыслен только вместе с ревизиями, против
которых он шёл.
commit — evidence¶
После успешного хода демон:
git add -A;- если дерево чистое — сравнивает HEAD с
baseCommit. Агент мог закоммитить сам: ветка ушла вперёд, и это тоже evidence, его надо опубликовать. Если HEAD не сдвинулся — «изменений нет», артефактаcommitне будет; - иначе — коммит от имени
control-plane-agent <agent@control-plane.local>с--no-verify; сообщение —<publicId>: <title>, и публичный id задачи в сообщении есть всегда.
publish — ветка в forge¶
Если задан CONTROL_PLANE_AGENT_PUSH_REMOTE, демон публикует ветку:
Три сознательных ограничения:
- пушится только ветка задачи, обе стороны названы явно — runner предлагает работу к рассмотрению и не двигает ветку, на которой строят остальные;
- push никогда не форсируется — разошедшаяся ветка в forge разбирается человеком; перезапись уничтожила бы историю ревью;
- неудача push не валит run — коммит уже является evidence, недоступный forge не должен превращать сделанную работу в проваленную.
Публикует именно демон, а не агент внутри: claim и fencing token держит демон, поэтому опубликованное атрибутируется run'у, который это произвёл. Агенту прямо сказано не пушить.
stderr неудачного push остаётся в журнале runner'а и в Control Plane не уходит: в нём бывают URL remote и локальные пути.
Артефакт commit¶
{
"type": "commit",
"name": "task/<publicId>@3f1c2a9b7e10",
"uri": "git:3f1c2a9b7e10d4…",
"metadata": {
"branch": "task/<publicId>",
"commit": "3f1c2a9b7e10d4…",
"workspaceKey": "<publicId>",
"published": true,
"repository": "https://git.example.com/<org>/service.git",
"targetBranch": "main"
}
}
published: false означает, что ветки в forge нет, коммит существует только на runner'е, и
ревьюеру смотреть нечего: критерии ревью и вливания типа coding-task такой коммит
пропускают. repository и targetBranch пишутся, когда задан remote публикации:
адрес remote и ветка, от которой отведена копия (поле задачи baseBranch, иначе ветка
по умолчанию), — туда скилл вливания вольёт одобренный коммит (см. Ревью и вливание
кода).
Задача, вернувшаяся после проваленной проверки, продолжает ту же ветку task/<publicId>:
демон берёт локальную ветку зеркала, иначе опубликованную в forge, и только если нет ни
той, ни другой — отводит новую от базы. Следующая публикация проходит без --force.
release¶
| Исход run | Что с копией |
|---|---|
| успех | копия удаляется (worktree remove --force), если в ней нет незакоммиченных изменений и не задан CONTROL_PLANE_AGENT_KEEP_WORKSPACES=1; соседи удаляются вместе с ней; ветка остаётся всегда |
| провал, потеря аренды, исключение | копия сохраняется как есть — это состояние, с которого продолжит следующая попытка |
--force при удалении ничего ценного не уничтожает: пустой git status --porcelain уже
доказал, что в копии нет работы, остались только игнорируемые файлы (.venv, кэши).
Бюджет диска¶
После каждого release демон держит не больше CONTROL_PLANE_AGENT_MAX_WORKSPACES (по
умолчанию 8) простаивающих контейнеров: самые старые по времени изменения удаляются. Не
трогаются:
- копия текущей задачи;
- копии с незакоммиченными изменениями;
- копии, чья блокировка занята (в них сейчас работают);
- каталоги, не похожие на контейнер пула.
Ветки при чистке не удаляются никогда.
Переносимость: что не покидает хост¶
Всё, что уходит в Control Plane (checkpoints, артефакты, failure_reason), проходит проверку
assert_portable. Отклоняются:
- строки с корнями путей хоста (
/Users/,/home/,/root/,/private/,/var/,/tmp/,/opt/,/mnt/),file://,~/, пути Windows, строки целиком вида абсолютного пути; - значения по ключам вида
token,secret,password,authorization,apikey,credential; - строки с префиксами
cp_,sk-,ghp_,github_pat_,xox,-----BEGIN.
Нарушение в артефакте, который демон собирается записать, проваливает run — поэтому
адаптеры заранее редактируют summary (<path> вместо абсолютного пути), а тексты ошибок
перед записью в failure_reason проходят ту же редакцию.
Типичные проблемы¶
| Симптом | Причина |
|---|---|
failed: workspace_busy |
копию держит другой процесс (второй экземпляр runner'а на тех же каталогах) |
<dir> is on branch X, expected task/<id> |
в копии кто-то переключил ветку руками |
invalid reference при создании соседа |
в зеркале соседа нет закреплённого коммита, а fetch не удался |
<name> is not a submodule of the superproject |
опечатка в имени соседа или сабмодуль переименован |
| диф ветки выглядит как откат чужих коммитов | ветка отведена от старой базы; ревьюер должен смотреть диф от merge-base с targetBranch |
| агент «чинит код, которого уже нет» | зеркало не обновляется: не задан CONTROL_PLANE_AGENT_PUSH_REMOTE и базу никто не подтягивает |
| на диске копятся копии | много проваленных задач (их копии не удаляются) или копии с незакоммиченными файлами; разберите и удалите вручную через git worktree remove |