Практика · Multi-agent systems
Как координировать двух coding-агентов через Git refs и CRDT
Параллельным агентам нельзя поручать совместное редактирование одного файла и надеяться, что обычный merge восстановит намерения. Надёжнее разделить транспорт и модель состояния: каждый агент публикует неизменяемую операцию в собственный Git ref, а координатор объединяет операции как CRDT-журнал и детерминированно строит итоговую задачу. Ниже — полностью локальный тест, в котором два агента стартуют от одного состояния, не видят работу друг друга, добавляют разные данные и сохраняют оба изменения без записи в общую рабочую ветку.
Что именно мы построим
Coding-агент в этой схеме — отдельный исполнитель с собственным каталогом, процессом и историей. Он получает идентификатор задачи и известную версию базы, но не получает право переключать или изменять общую ветку.
Состояние передаётся через Git ref: именованный указатель на объект Git. Для каждого исполнителя создаётся собственное пространство имён:
refs/agents/alice/head
refs/agents/bob/head
Содержимое задачи представим как небольшой CRDT — структуру данных, допускающую независимое применение совместимых операций с последующим слиянием. Здесь не понадобится полноценная библиотека распределённых структур. Для теста достаточно grow-only-наборов: агенты только добавляют заметки и метки, а результат определяется объединением уникальных операций.
К концу эксперимента должны выполняться пять условий:
- оба агента начали с одного базового commit;
- каждый агент создал отдельную неизменяемую операцию;
- агенты опубликовали результаты в разных refs;
- координатор включил оба commit в интеграционную историю;
- материализованная задача содержит изменения Alice и Bob, а общая ветка не использовалась для их промежуточной работы.
Почему обычная параллельная работа ломается
Пусть два агента читают один файл:
{
"id": "TASK-42",
"status": "open",
"notes": [],
"labels": []
}
Alice добавляет заметку, Bob — метку. Если каждый переписывает документ целиком, оба формируют новую версию от одного родителя. Последний записавший файл способен стереть изменение первого. Git может показать текстовый конфликт, но может и выполнить формально успешное слияние, не сохранив семантическое намерение.
Проблема особенно неприятна для агентов по четырём причинам:
- Снимок быстро устаревает. Модель рассуждает на основании состояния, прочитанного в начале сессии.
- Полный файл выглядит авторитетно. Агент склонен заменить объект своей версией, хотя изменил только одно поле.
- Повторный запуск создаёт дубли. После тайм-аута исполнитель может не знать, была ли его запись принята.
- Контекст живёт меньше задачи. Новая сессия не помнит локальные договорённости, если они остались только в диалоге.
Общая рабочая ветка усугубляет ситуацию. Одновременные команды switch, add, commit и reset воздействуют на один индекс и один HEAD. Даже если Git блокирует отдельные служебные файлы, он не превращает набор агентских намерений в транзакцию.
Git должен переносить и удостоверять агентские операции, но не обязан угадывать, как объединять предметное состояние.
Разделяем транспорт, операции и представление
В предлагаемой схеме три слоя:
- Git хранит происхождение. Кто создал изменение, от какой базы оно произошло и какой объект опубликован.
- CRDT-правила определяют объединение. Уникальные операции можно применять в любом порядке без потери добавлений.
- Материализатор строит читаемый файл. Он проверяет операции и создаёт актуальный снимок задачи.
Commit агента содержит не переписанный task.json, а новый файл операции. Поэтому Alice и Bob добавляют разные пути:
ops/TASK-42/alice-add-note-001.json
ops/TASK-42/bob-add-label-001.json
Имена являются частью протокола, но истинный идентификатор хранится и внутри документа. Пример операции:
{
"schema": 1,
"op_id": "alice-add-note-001",
"task_id": "TASK-42",
"actor": "alice",
"basis": "BASE_COMMIT",
"kind": "add_note",
"value": "Добавить проверку пустого токена"
}
Это журнал операций, а не журнал снимков. Добавление нового файла не изменяет старые операции и минимизирует текстовые конфликты. Поле op_id обеспечивает идемпотентность: повторное применение той же логической операции не должно создавать вторую заметку.
Границы тестовой CRDT-модели
Мы используем две монотонные структуры:
notes— отображениеop_id → текст заметки;labels— отображениеop_id → значение метки.
Состояние только растёт. Одинаковый op_id с одинаковым содержимым безопасно схлопывается. Одинаковый op_id с разным содержимым считается повреждением протокола и останавливает материализацию.
Для таких операций выполняются основные свойства сходимости:
- Коммутативность: применение A, затем B даёт тот же результат, что B, затем A.
- Ассоциативность: группировка партий операций не меняет итог.
- Идемпотентность: повторное применение A не меняет результат после первого применения.
Git merge сам по себе этих свойств не гарантирует. Они возникают потому, что предметная операция определена как добавление уникального элемента, а материализатор реализует проверенные правила объединения.
Предварительные требования
Тест рассчитан на Unix-подобную оболочку и использует:
- Git версии, поддерживающей
git worktree; - Python 3;
- стандартные команды
mktemp,findиsed; - локальную файловую систему.
Проверьте инструменты:
git --version
python3 --version
git worktree --help >/dev/null
Эксперимент не требует сети, удалённого репозитория, токенов или доступа к GitHub. Запускайте его в новом временном каталоге, а не внутри рабочего проекта.
Шаг 1. Создаём репозиторий и исходную задачу
Выполните команды последовательно:
LAB_DIR="$(mktemp -d)"
REPO_DIR="$LAB_DIR/repo"
git init "$REPO_DIR"
cd "$REPO_DIR"
git config user.name "Agent Lab Test"
git config user.email "agent-lab-test@example.invalid"
mkdir -p state ops scripts
cat > state/TASK-42.json <<'JSON'
{
"id": "TASK-42",
"status": "open",
"notes": [],
"labels": [],
"applied_ops": []
}
JSON
git add state/TASK-42.json
git commit -m "seed TASK-42"
BASE_COMMIT="$(git rev-parse HEAD)"
git update-ref refs/tasks/TASK-42/base "$BASE_COMMIT"
refs/tasks/TASK-42/base закрепляет точную базу. Это важнее имени ветки: ветка способна переместиться между планированием и запуском агента, а идентификатор commit остаётся неизменным.
Проверьте указатель:
test "$(git rev-parse refs/tasks/TASK-42/base)" = "$BASE_COMMIT"
git show-ref refs/tasks/TASK-42/base
Шаг 2. Изолируем рабочие каталоги агентов
Git worktree позволяет нескольким рабочим каталогам использовать одну объектную базу Git, сохраняя отдельные HEAD и индексы. Создадим два отсоединённых каталога от одной базы:
git worktree add --detach "$LAB_DIR/alice" "$BASE_COMMIT"
git worktree add --detach "$LAB_DIR/bob" "$BASE_COMMIT"
test "$(git -C "$LAB_DIR/alice" rev-parse HEAD)" = "$BASE_COMMIT"
test "$(git -C "$LAB_DIR/bob" rev-parse HEAD)" = "$BASE_COMMIT"
Режим --detach намеренный. Агенту не нужна общая ветка: он создаёт commit в собственном рабочем каталоге, после чего атомарно публикует его в свой ref.
В реальном оркестраторе агенту следует передавать явный контракт:
task_id=TASK-42
actor=alice
basis=<40-символьный commit>
publish_ref=refs/agents/alice/head
allowed_paths=ops/TASK-42/
forbidden_paths=state/, .git/, scripts/
operation_kinds=add_note
Ограничение путей должно проверяться координатором, а не только промптом. Модель может ошибиться, тогда как проверка diff даёт детерминированный ответ.
Шаг 3. Alice независимо добавляет заметку
В каталоге Alice создайте одну операцию. Подстановка базы выполняется оболочкой, поэтому маркер документа не заключён в кавычки:
cd "$LAB_DIR/alice"
mkdir -p ops/TASK-42
cat > ops/TASK-42/alice-add-note-001.json <<JSON
{
"schema": 1,
"op_id": "alice-add-note-001",
"task_id": "TASK-42",
"actor": "alice",
"basis": "$BASE_COMMIT",
"kind": "add_note",
"value": "Добавить проверку пустого токена"
}
JSON
git add ops/TASK-42/alice-add-note-001.json
git commit -m "agent alice: add TASK-42 note"
ALICE_COMMIT="$(git rev-parse HEAD)"
git update-ref refs/agents/alice/head "$ALICE_COMMIT"
git update-ref обновляет указатель без переключения общей ветки. В сетевой системе аналогом может быть push конкретного refspec:
git push origin \
"$ALICE_COMMIT:refs/agents/alice/head"
Если один агент способен публиковать несколько поколений, используйте compare-and-swap:
git update-ref \
refs/agents/alice/head \
"$NEW_COMMIT" \
"$EXPECTED_OLD_COMMIT"
Третий аргумент запрещает незаметно затереть указатель, если другая сессия Alice успела опубликовать новый результат.
Шаг 4. Bob независимо добавляет метку
Bob всё ещё находится на исходной базе и не читает ref Alice:
cd "$LAB_DIR/bob"
mkdir -p ops/TASK-42
cat > ops/TASK-42/bob-add-label-001.json <<JSON
{
"schema": 1,
"op_id": "bob-add-label-001",
"task_id": "TASK-42",
"actor": "bob",
"basis": "$BASE_COMMIT",
"kind": "add_label",
"value": "security"
}
JSON
git add ops/TASK-42/bob-add-label-001.json
git commit -m "agent bob: label TASK-42"
BOB_COMMIT="$(git rev-parse HEAD)"
git update-ref refs/agents/bob/head "$BOB_COMMIT"
Теперь история разошлась, что является нормальным состоянием:
git -C "$REPO_DIR" log \
--graph \
--oneline \
--decorate \
--all
Оба агентских commit имеют одного родителя. Ни Alice, ни Bob не переписывали state/TASK-42.json, поэтому они не конкурировали за общий снимок.
Шаг 5. Проверяем публикации до интеграции
Координатор не должен автоматически доверять содержимому agent ref. Сначала он проверяет происхождение и область изменений.
Проверка базы:
git -C "$REPO_DIR" merge-base --is-ancestor \
"$BASE_COMMIT" \
refs/agents/alice/head
git -C "$REPO_DIR" merge-base --is-ancestor \
"$BASE_COMMIT" \
refs/agents/bob/head
Проверка изменённых путей:
git -C "$REPO_DIR" diff \
--name-only \
"$BASE_COMMIT..refs/agents/alice/head"
git -C "$REPO_DIR" diff \
--name-only \
"$BASE_COMMIT..refs/agents/bob/head"
Для автоматического отказа при выходе Alice за разрешённую директорию:
if git -C "$REPO_DIR" diff --name-only \
"$BASE_COMMIT..refs/agents/alice/head" |
sed '/^ops\/TASK-42\//d' |
grep -q .; then
echo "Alice изменила запрещённый путь" >&2
exit 1
fi
Повторите проверку для Bob. Дополнительно следует ограничить число commit, размер файлов, допустимые типы операций, кодировку и отсутствие символических ссылок.
Шаг 6. Пишем детерминированный материализатор
Материализованное представление — производный снимок, который всегда можно заново построить из исходных операций. Создайте в основном репозитории scripts/materialize.py:
cd "$REPO_DIR"
cat > scripts/materialize.py <<'PY'
#!/usr/bin/env python3
import hashlib
import json
import pathlib
import sys
ROOT = pathlib.Path(__file__).resolve().parents[1]
TASK_ID = sys.argv[1] if len(sys.argv) > 1 else "TASK-42"
OPS_DIR = ROOT / "ops" / TASK_ID
STATE_FILE = ROOT / "state" / f"{TASK_ID}.json"
allowed_kinds = {"add_note", "add_label"}
seen = {}
notes = {}
labels = {}
paths = sorted(OPS_DIR.glob("*.json")) if OPS_DIR.exists() else []
for path in paths:
raw = path.read_bytes()
try:
op = json.loads(raw)
except json.JSONDecodeError as exc:
raise SystemExit(f"{path}: invalid JSON: {exc}")
required = {
"schema", "op_id", "task_id",
"actor", "basis", "kind", "value"
}
missing = required - op.keys()
if missing:
raise SystemExit(
f"{path}: missing fields: {sorted(missing)}"
)
if op["schema"] != 1:
raise SystemExit(f"{path}: unsupported schema")
if op["task_id"] != TASK_ID:
raise SystemExit(f"{path}: wrong task_id")
if op["kind"] not in allowed_kinds:
raise SystemExit(f"{path}: unsupported kind")
if not isinstance(op["value"], str) or not op["value"].strip():
raise SystemExit(f"{path}: value must be non-empty")
if not isinstance(op["op_id"], str) or not op["op_id"]:
raise SystemExit(f"{path}: invalid op_id")
canonical = json.dumps(
op,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":")
).encode("utf-8")
digest = hashlib.sha256(canonical).hexdigest()
previous = seen.get(op["op_id"])
if previous is not None and previous != digest:
raise SystemExit(
f"conflicting payloads for op_id={op['op_id']}"
)
seen[op["op_id"]] = digest
if op["kind"] == "add_note":
notes[op["op_id"]] = op["value"]
elif op["kind"] == "add_label":
labels[op["op_id"]] = op["value"]
state = {
"id": TASK_ID,
"status": "open",
"notes": [
notes[key] for key in sorted(notes)
],
"labels": sorted(set(labels.values())),
"applied_ops": sorted(seen)
}
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
STATE_FILE.write_text(
json.dumps(state, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8"
)
PY
chmod +x scripts/materialize.py
git add scripts/materialize.py
git commit -m "add deterministic task materializer"
MATERIALIZER_COMMIT="$(git rev-parse HEAD)"
Сортировка путей, идентификаторов и меток устраняет зависимость от порядка обхода файловой системы. Каноническое представление JSON используется только для обнаружения двух разных payload с одним op_id.
Материализатор намеренно не доверяет имени файла. Он проверяет обязательные поля внутри операции и останавливается на неизвестной схеме или типе команды.
Шаг 7. Интегрируем refs без общей рабочей ветки агентов
Основной репозиторий сейчас содержит materializer commit поверх базы. Создадим отдельный ref координатора и включим обе агентские истории:
cd "$REPO_DIR"
git update-ref \
refs/coordinator/TASK-42/head \
"$MATERIALIZER_COMMIT"
git switch --detach refs/coordinator/TASK-42/head
git merge --no-ff --no-edit refs/agents/alice/head
git merge --no-ff --no-edit refs/agents/bob/head
Merge здесь решает только задачу переноса файлов операций и происхождения commit. Семантическое объединение будет выполнено следующим шагом:
python3 scripts/materialize.py TASK-42
git add state/TASK-42.json
git commit -m "materialize TASK-42 from agent operations"
INTEGRATED_COMMIT="$(git rev-parse HEAD)"
git update-ref \
refs/tasks/TASK-42/integrated \
"$INTEGRATED_COMMIT"
git update-ref \
refs/coordinator/TASK-42/head \
"$INTEGRATED_COMMIT" \
"$MATERIALIZER_COMMIT"
Последняя команда использует ожидаемое старое значение. Если параллельный координатор уже передвинул ref, операция завершится ошибкой вместо потери его результата.
Шаг 8. Проверяем результат автоматическими утверждениями
Не оценивайте эксперимент по красивому графу Git. Проверяйте инварианты предметного состояния:
cd "$REPO_DIR"
python3 - <<'PY'
import json
from pathlib import Path
state = json.loads(
Path("state/TASK-42.json").read_text(encoding="utf-8")
)
assert state["id"] == "TASK-42"
assert state["status"] == "open"
assert state["notes"] == [
"Добавить проверку пустого токена"
]
assert state["labels"] == [
"security"
]
assert state["applied_ops"] == [
"alice-add-note-001",
"bob-add-label-001"
]
print("OK: оба независимых изменения сохранены")
PY
Затем докажите присутствие обоих агентских commit в интеграционной истории:
git merge-base --is-ancestor \
refs/agents/alice/head \
refs/tasks/TASK-42/integrated
git merge-base --is-ancestor \
refs/agents/bob/head \
refs/tasks/TASK-42/integrated
Обе команды должны завершиться с кодом 0. Проверка отсутствия изменений исходного файла в агентских commit:
test -z "$(git diff --name-only \
"$BASE_COMMIT..refs/agents/alice/head" \
-- state/TASK-42.json)"
test -z "$(git diff --name-only \
"$BASE_COMMIT..refs/agents/bob/head" \
-- state/TASK-42.json)"
И, наконец, проверьте refs и граф:
git show-ref |
grep -E 'refs/(agents|tasks|coordinator)/'
git log \
--graph \
--oneline \
--decorate \
--all
Тест считается пройденным только при успехе всех утверждений. Сам текст OK не является опубликованным измерением: он появляется локально лишь после выполнения проверок.
Шаг 9. Доказываем независимость от порядка применения
Для CRDT недостаточно одного успешного порядка. Скопируйте операции во временные каталоги в обратном порядке либо протестируйте функцию применения отдельно. В текущей реализации материализатор сортирует операции, поэтому одинаковый набор файлов обязан давать одинаковые байты.
Зафиксируйте контрольную сумму:
python3 scripts/materialize.py TASK-42
FIRST_HASH="$(git hash-object state/TASK-42.json)"
touch ops/TASK-42/bob-add-label-001.json
touch ops/TASK-42/alice-add-note-001.json
python3 scripts/materialize.py TASK-42
SECOND_HASH="$(git hash-object state/TASK-42.json)"
test "$FIRST_HASH" = "$SECOND_HASH"
echo "OK: материализация детерминирована"
touch меняет только файловое время и не изменяет содержимое commit. Если итоговый hash различается, materializer зависит от недекларированного порядка или нестабильных данных.
Шаг 10. Проверяем повторную доставку
Скопируем операцию Alice под другим именем. Поскольку внутренний op_id и payload совпадают, результат не должен получить вторую заметку:
cp \
ops/TASK-42/alice-add-note-001.json \
ops/TASK-42/retry-of-alice-add-note-001.json
python3 scripts/materialize.py TASK-42
python3 - <<'PY'
import json
from pathlib import Path
state = json.loads(
Path("state/TASK-42.json").read_text(encoding="utf-8")
)
assert len(state["notes"]) == 1
assert state["applied_ops"].count(
"alice-add-note-001"
) == 1
print("OK: повторная доставка не создала дубль")
PY
rm ops/TASK-42/retry-of-alice-add-note-001.json
python3 scripts/materialize.py TASK-42
В производственной схеме повторная доставка чаще возникает не как второй файл, а как повторное чтение одной операции из нескольких refs или очередей. Правило остаётся тем же: логическая идентичность определяется op_id, а не транспортным адресом.
Шаг 11. Проверяем конфликт идентификатора
Теперь создадим другую операцию с уже занятым идентификатором:
cat > ops/TASK-42/conflicting-alice-op.json <<JSON
{
"schema": 1,
"op_id": "alice-add-note-001",
"task_id": "TASK-42",
"actor": "mallory",
"basis": "$BASE_COMMIT",
"kind": "add_note",
"value": "Другое содержимое под тем же ID"
}
JSON
if python3 scripts/materialize.py TASK-42; then
echo "ОШИБКА: конфликтующий op_id был принят" >&2
exit 1
else
echo "OK: конфликтующий op_id отклонён"
fi
rm ops/TASK-42/conflicting-alice-op.json
python3 scripts/materialize.py TASK-42
Такой случай нельзя разрешать правилом «последняя запись побеждает». Порядок доставки между узлами может различаться, поэтому разные координаторы выберут разные payload. Безопасный результат — остановка и расследование нарушения уникальности.
Как сохранять контекст между сессиями
Git refs решают перенос объектов, но не заменяют контракт продолжения работы. Новая сессия должна получать маленький машинно-проверяемый конверт:
{
"task_id": "TASK-42",
"actor": "alice",
"basis_ref": "refs/tasks/TASK-42/base",
"basis_oid": "0123456789abcdef...",
"input_refs": [
"refs/agents/alice/head"
],
"publish_ref": "refs/agents/alice/head",
"allowed_operation_kinds": [
"add_note"
],
"allowed_path_prefix": "ops/TASK-42/",
"last_op_id": "alice-add-note-001",
"protocol_version": 1
}
Не подставляйте вымышленный hash из примера. Оркестратор должен получить реальный OID командой git rev-parse и передать одновременно ref и разрешённое значение. Ref показывает канал, OID фиксирует прочитанную версию.
В журнале сессии полезно хранить:
- идентификатор задачи и исполнителя;
- входные и выходные OID;
- созданные
op_id; - версию схемы операций;
- результат проверок политики;
- причину завершения или отказа.
Не следует хранить только свободный пересказ агента. Он полезен человеку, но не позволяет доказать, какие данные были фактически прочитаны и опубликованы.
Когда нужны leases, а когда достаточно CRDT
Lease — ограниченное по времени право выполнять несовместимую операцию. Для добавления независимых заметок блокировка не нужна. Но не все изменения монотонны.
Разделите команды на три класса:
- Коммутативные. Добавление заметки, наблюдения, ссылки или тега. Их можно принимать параллельно.
- Разрешимые политикой. Одновременное назначение приоритета можно объединить правилом максимума или отдельным регистром, если это соответствует предметной области.
- Требующие сериализации. Закрытие задачи, удаление артефакта, публикация релиза или расходование единственного ресурса. Для них нужен lease, compare-and-swap или подтверждение координатора.
Не превращайте любое поле в CRDT только ради отсутствия конфликтов. Автоматическая сходимость бессмысленна, если итог нарушает бизнес-инвариант.
Удаление и изменение существующих значений
Grow-only-набор прост, потому что ничего не удаляет. Для удаления заметки недостаточно стереть её из снимка: другой узел способен снова доставить исходную операцию добавления.
Практические варианты:
- Tombstone. Новая операция
remove_noteссылается на идентификатор добавления. Материализатор скрывает элемент, но сохраняет сведения об удалении. - Observed-remove set. Удаление перечисляет известные добавления конкретного значения. Параллельное новое добавление остаётся.
- LWW-регистр. Побеждает значение с максимальной логической меткой, но часы и tie-breaker должны быть частью протокола.
- Сериализованная команда. Для критического перехода координатор проверяет ожидаемую версию и принимает ровно одну операцию.
Часы Лэмпорта задают причинно согласованный логический порядок событий, но не доказывают, что более позднее значение семантически правильнее. Не используйте локальное время агента как единственный арбитр: часы машин могут расходиться, а повторная доставка меняет время наблюдения.
Что обычно ломается
Оба агента переписывают материализованный файл
Это возвращает систему к конфликту снимков. Запретите агентам изменять state/; право на запись туда должно быть только у materializer.
Ref используется как очередь
Один ref хранит только один текущий указатель. Если два процесса без compare-and-swap двигают refs/agents/alice/head, один результат может стать недостижимым через этот ref. Используйте отдельные refs с идентификатором запуска либо проверку ожидаемого старого OID.
refs/runs/run-001/agents/alice/result
refs/runs/run-001/agents/bob/result
Идентификатор операции генерируется заново при retry
Повтор превращается в новое добавление. op_id должен назначаться логической команде до первой попытки публикации и сохраняться между сессиями.
Материализатор молча принимает неизвестные поля
При эволюции протокола один агент способен считать поле значимым, а старый координатор — проигнорировать его. Версионируйте схему и отклоняйте неподдерживаемые операции.
Merge прошёл, значит состояние корректно
Успешный merge означает лишь отсутствие неразрешённого файлового конфликта. После него всё равно нужны валидация операций, материализация и проверка инвариантов.
Агент публикует commit от неверной базы
Проверяйте, что разрешённая база является предком результата. Если требуется строго один родитель, сравните первый parent agent commit с переданным OID.
Неизменяемые операции редактируются задним числом
Запрещайте изменение и удаление уже принятых путей. Исправление должно быть новой компенсирующей операцией. Иначе аудит и повторная материализация перестают быть надёжными.
Операции имеют уникальные имена, но одинаковый смысл
CRDT устранит технический конфликт, однако два агента могут независимо создать одинаковые заметки с разными op_id. Семантическая дедупликация — отдельная политика. Она может сравнивать нормализованные значения, но не должна незаметно удалять различия без заданного правила.
Сборщик читает refs во время их изменения
Сначала снимите набор входных OID, затем работайте только с ним. Повторное разрешение движущихся refs в середине запуска создаёт состояние, которое трудно воспроизвести.
Как перенести протокол в рабочую систему
Локальный тест доказывает базовую механику, но производственному координатору нужен явный жизненный цикл:
- закрепить base OID задачи;
- создать идентификатор запуска;
- назначить каждому агенту отдельный publish ref;
- создать изолированные worktrees или клоны;
- передать разрешённые типы операций и пути;
- дождаться публикаций или зафиксировать тайм-аут;
- снять неизменяемый список входных OID;
- проверить происхождение, подпись и diff;
- объединить commit в карантинном ref;
- валидировать и материализовать состояние;
- проверить предметные инварианты;
- атомарно передвинуть интеграционный ref;
- сохранить отчёт запуска.
Ожидание обоих агентов не всегда обязательно. Монотонные операции можно материализовать инкрементально. Но публикация внешнего результата — например, закрытие задачи или выпуск релиза — должна учитывать полноту набора участников и правила дедлайна.
Безопасность и доверие
Собственный namespace ref не является полноценной границей доступа, если все процессы имеют одинаковые права на каталог .git. Локальный агент технически способен передвинуть чужой ref.
Для более строгого контура:
- запускайте агентов в отдельных клонах или контейнерах;
- разрешайте push только в namespace конкретного исполнителя;
- проверяйте server-side hook или политикой принимающего сервиса;
- не передавайте агенту credentials с правом записи в интеграционные refs;
- подписывайте результаты, если необходимо удостоверять автора;
- рассматривайте содержимое операции как недоверенный ввод;
- не выполняйте команды, найденные в заметках или generated files.
Git обеспечивает хешированную адресацию объектов, но не утверждает, что содержимое безопасно, правдиво или авторизовано.
Что измерять
Минимальный журнал координатора должен позволять ответить, где потерялось изменение:
{
"run_id": "run-001",
"task_id": "TASK-42",
"base_oid": "...",
"inputs": {
"alice": "...",
"bob": "..."
},
"accepted_ops": [
"alice-add-note-001",
"bob-add-label-001"
],
"rejected_ops": [],
"materialized_oid": "...",
"integrated_ref": "refs/tasks/TASK-42/integrated",
"protocol_version": 1
}
Полезные показатели:
- число принятых и отклонённых операций;
- повторы
op_id; - коллизии одного
op_idс разными payload; - публикации от устаревшей или запрещённой базы;
- выходы за разрешённые пути;
- время ожидания каждого агента;
- расхождения hash при повторной материализации;
- неуспешные compare-and-swap обновления refs.
Не называйте отсутствие текстового merge-конфликта показателем успешной координации. Главная метрика — сохранность допустимых намерений при выполнении инвариантов.
Ограничения подхода
- Подходит не для всех изменений кода. Два независимых патча одной функции могут требовать содержательного решения, которое невозможно выразить объединением множеств.
- Журнал растёт. Нужны snapshots, compaction и политика хранения tombstones, не разрушающая возможность синхронизации отставших участников.
- CRDT сохраняет операции, а не качество. Две неверные заметки также успешно сойдутся.
- Схема усложняет миграции. Materializer должен понимать старые версии либо выполнять проверяемое преобразование.
- Git не является низколатентной шиной сообщений. Для большого потока мелких событий может понадобиться база или брокер, а Git останется слоем контрольных точек и аудита.
- Ref легко удалить. Настройте защиту, reflog и резервное хранение в соответствии с требованиями восстановления.
- Семантические конфликты остаются. Параллельные команды «закрыть» и «переоткрыть» требуют модели причинности и предметной политики.
- Локальный тест не моделирует сеть. Он не проверяет частичные push, потерю соединения, репликацию, задержки и серверную авторизацию.
Контрольный список перед внедрением
- У каждой логической операции есть стабильный
op_id. - Агенты публикуют результаты в разные refs.
- Входной base OID закреплён и записан.
- Agent commit меняет только разрешённые пути.
- Материализованный снимок недоступен агентам для записи.
- Неизвестные схемы и типы операций отклоняются.
- Коллизия
op_idостанавливает интеграцию. - Повторная доставка не создаёт дубль.
- Порядок применения не меняет итоговые байты.
- Интеграционный ref обновляется compare-and-swap операцией.
- Критические некоммутативные команды сериализуются.
- Отчёт запуска содержит реальные OID и результаты проверок.
Очистка лабораторного окружения
После завершения сначала вернитесь из временных worktrees, затем удалите их штатными командами Git. Убедитесь, что переменная указывает именно на созданный тестовый каталог:
cd "$REPO_DIR"
git worktree remove "$LAB_DIR/alice"
git worktree remove "$LAB_DIR/bob"
git worktree prune
printf '%s\n' "$LAB_DIR"
Последняя команда только показывает путь. Удаляйте временный каталог отдельно и только после визуальной проверки значения. Если хотите сохранить доказательства эксперимента, оставьте репозиторий или создайте bundle:
git bundle create \
"$LAB_DIR/agent-coordination.bundle" \
--all
Итог
Надёжная координация начинается не с более подробного промпта, а с разделения ответственности. Агент создаёт минимальную неизменяемую операцию. Git фиксирует её происхождение и переносит через отдельный ref. CRDT-правила объединяют совместимые намерения. Координатор проверяет политику, материализует снимок и единолично двигает интеграционный указатель.
В результате Alice и Bob могут работать от одной базы, не синхронизировать внутренний контекст и не касаться общей ветки. Их добавления сохраняются не благодаря удачному порядку выполнения, а благодаря проверяемому протоколу.