# Как конкурирующие AI-агенты сталкиваются в production-аккаунтах

Два автономных запуска в одном production-аккаунте не становятся безопасными только потому, что у каждого своя формулировка задачи. Они работают с изменяемой системой, и любой составленный ими план может устареть ещё до следующего вызова API. Если оба могут менять один ресурс, нужна граница владения, которую проверяет сам сервис.

Обычно сбой выглядит тише, чем впечатляющая авария. Один агент добавляет участника в группу, пока другой заменяет полный список участников группы на основе более старого чтения. Оба запроса завершаются успешно. Более поздний запрос удаляет участника, которого добавил первый. Каждый запуск следовал своим инструкциям. Ошибочную последовательность принял ваш API.

Считайте запуск агента ненадёжным конкурентным клиентом с реальными полномочиями и несовершенным временем выполнения. Дайте ему узкую область владения, делайте записи условными относительно прочитанной версии и сохраняйте достаточно контекста, чтобы позже объяснить отклонённое или принятое изменение. Проверка человеком полезна, но не заменяет получатель, который обнаруживает устаревшее состояние.

## Разделяйте границу задачи и границу записи

Граница задачи определяет, чего агент должен добиться. Граница записи определяет, какие изменяемые объекты он может менять. Это разные вещи, и проблемы начинаются, когда их считают взаимозаменяемыми.

Фраза «обновить staging-развёртывание» звучит узко. На деле она может затронуть общий тег образа, указатель релиза, правило маршрутизации, DNS-запись, журнал миграций базы данных и канал уведомлений. Агент может выполнить формулировку задачи и одновременно столкнуться с запуском релиза, которому принадлежит один из этих объектов.

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

- одно окружение и одна запись развёртывания
- один арендатор или клиентский аккаунт
- один pull request и его ветка
- один тикет инцидента и ресурсы, перечисленные в наборе изменений
- одно окно обслуживания с явным списком целей

Избегайте границ вроде «работа с backend» или «очистка production». Это понятные людям ярлыки, но они не говорят API, какая запись должна завершиться ошибкой.

Полезная запись владения содержит ID запуска, ID ресурса, разрешённую операцию и срок действия. Храните её рядом с сервисом, которому принадлежит ресурс. Если контроллер развёртывания владеет указателем релиза, именно он должен проверять, кто может его продвинуть. Таблица, сообщение в чате или инструкция в промпте не заблокируют запрос, пришедший после того, как автор этой записи ушёл домой.

### Составьте небольшую карту конфликтов до выдачи прав на запись

Для каждой автоматизированной задачи перечислите ресурсы, которые она читает, изменяет, удаляет или использует как общий параметр по умолчанию. Затем отметьте пары задач, которые могут записывать один и тот же идентификатор или когда входные данные одной задачи может изменить другая. Это не бюрократия. Такая карта показывает конфликты, которые скрывают ролевые разрешения.

Например, агент, меняющий сервисный токен, и агент, обновляющий конфигурацию интеграции, могут никогда не обращаться к одной конечной точке. Автор конфигурации может прочитать текущую ссылку на токен, а затем опубликовать полный документ конфигурации уже после того, как ротация изменила эту ссылку. Конфликт возникает в версии документа, а не в одинаковой команде.

Если вы не можете описать набор записей запуска, не выдавайте ему широкие права на запись в production. Пусть он подготовит предложение или работает только в пространстве имён ресурса, пока граница не станет ясной.

## Успешный ответ всё равно может стереть правильное изменение

Стратегия «побеждает последняя запись» становится политикой потери данных, когда клиенты отправляют полные представления ресурсов. В демонстрациях она выглядит безобидно, потому что каждый клиент сразу читает и записывает данные. Агент же может несколько минут изучать журналы, составлять план, ждать подтверждения и повторять вызов после тайм-аута.

Рассмотрим сервис с ресурсом `notification-policy`. Он возвращает Agent A такое представление:

```json
{
  "id": "prod-alerts",
  "version": 41,
  "destinations": ["oncall@example.test"],
  "severity": "high"
}
```

Agent A планирует добавить резервный адрес. Во время проверки Agent B меняет `severity` с `high` на `critical` и успешно записывает версию 42. Затем Agent A отправляет полную замену на основе версии 41:

```json
{
  "destinations": ["oncall@example.test", "backup@example.test"],
  "severity": "high"
}
```

Если конечная точка примет этот запрос, изменение B будет незаметно отменено. Для этого не нужен дефект ни в одном агенте. API позволил старому наблюдению перезаписать более новый факт.

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

Поэтому фраза «мы разрешаем агентам только PATCH» не описывает стратегию работы с конкуренцией. Это лишь меньший объём записи. Всё равно нужно условие, связывающее запись с состоянием, которое наблюдал агент.

## Делайте каждую запись изменения состояния условной

Оптимистический контроль конкуренции обычно становится первой защитой для записей агентов. Клиент читает версию, ETag, номер поколения или токен ревизии, а затем отправляет его вместе с обновлением. Сервис принимает запись только тогда, когда текущее значение всё ещё совпадает с прочитанным.

RFC 9110 определяет `If-Match` именно для таких запросов. Сервер проверяет условие до применения метода. Если тег сущности больше не совпадает, сервер отклоняет метод с ответом `412 Precondition Failed`. Это не неудобство API. Так сервер отказывается притворяться, будто устаревший план всё ещё корректен.

Условное обновление HTTP может выглядеть так:

```http
GET /v1/notification-policies/prod-alerts

HTTP/1.1 200 OK
ETag: "41"
Content-Type: application/json

{"destinations":["oncall@example.test"],"severity":"high"}
```

Агент переносит этот ETag в запись:

```http
PATCH /v1/notification-policies/prod-alerts
If-Match: "41"
Content-Type: application/json
Idempotency-Key: run-7f3c-add-backup

{"destinations":["oncall@example.test","backup@example.test"]}
```

Если другой автор уже создал ETag `"42"`, верните понятный отказ:

```http
HTTP/1.1 412 Precondition Failed
Content-Type: application/json

{
  "error": "stale_version",
  "resource": "notification-policy/prod-alerts",
  "expected_etag": "41",
  "current_etag": "42",
  "retryable": false
}
```

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

Для баз данных используйте эквивалентное условие прямо в операции изменения. Типичное обновление проверяет версию в предложении `WHERE` и считает конфликтом ноль затронутых строк:

```sql
UPDATE notification_policy
SET destinations = :destinations,
    version = version + 1
WHERE id = :id
  AND version = :observed_version;
```

Никогда не читайте версию, а затем не выполняйте безусловное обновление второй операцией. Проверка и изменение состояния должны происходить вместе в том месте, где хранится состояние.

## Идемпотентность предотвращает дубликаты, но не разногласия

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

Тайм-аут сети хорошо показывает разницу. Агент отправляет запрос на создание развёртывания, но теряет ответ. Повтор с тем же ключом идемпотентности должен вернуть исходный результат, а не создать второе развёртывание. Это подавление дубликатов.

Теперь представьте двух агентов, каждый из которых выбирает отдельного кандидата релиза для одного production-окружения. Они отправляют разные тела и разные ключи идемпотентности. Оба запроса могут быть полностью идемпотентными, но один всё равно должен проиграть, потому что указатель релиза изменился.

Используйте оба механизма для важных конечных точек записи:

- Ключ идемпотентности связывает повторы и повторные доставки с одной завершённой операцией.
- Предварительное условие с версией отклоняет запись, решение для которой основано на устаревшем состоянии ресурса.
- Инвариант на стороне сервера проверяет правила, которые должны выполняться даже для записи по актуальной версии, например максимальное число активных учётных данных.

Храните ключ идемпотентности вместе с отпечатком запроса и завершённым ответом. Если вызывающая сторона повторно использует ключ с другим телом, отклоняйте запрос. Возврат первого ответа для другой операции создаёт путаницу при отладке и может скрыть ошибку клиента.

Строго задавайте срок хранения. Сервис должен хранить ключ достаточно долго, чтобы покрыть реальные повторы, но хранилище идемпотентности не заменяет постоянную историю команд. Историю нужно хранить в журнале аудита.

## Используйте аренды только для работы, которая не может пересекаться

Некоторые операции занимают достаточно много времени, и одних оптимистических проверок для удобства пользователя мало. Миграция базы данных, разрушительная задача сверки или переключение могут включать множество зависимых записей. В таких случаях выдайте одному запуску короткоживущую аренду ресурса.

У аренды должны быть владелец, срок действия и маркер ограждения. Маркер важен потому, что просроченный процесс может проснуться и продолжить работу после того, как другой процесс получил новую аренду. Каждая защищённая запись должна содержать монотонно возрастающий токен аренды, а сервис должен отклонять токен, который старше последнего принятого.

Без такого ограждения сервис блокировок может сообщить Agent A, что его аренда истекла, но не сможет остановить задержанный запрос A, который дошёл до базы данных. Отклонить его должен конечный сервис. Именно эту часть часто пропускают, когда говорят о распределённой блокировке.

Делайте аренды узкими и короткими. Не блокируйте «production» на всё время автономного расследования. Блокируйте `migration/customer-1842` или `release/prod-eu`, а запуск должен продлевать аренду только пока действительно продвигается. Аренда должна безопасно истекать, если исчез процесс агента, его ноутбук или сеть.

Не используйте аренду для обычных изменений конфигурации, которые поддерживают проверку версии. Долгие блокировки превращают рутинные изменения в очередь, после чего люди начинают обходить блокировку. Ответ `412` с последующим новым планом дешевле аварии из-за устаревшего владельца блокировки.

## Идентичность агента должна сохраняться за шлюзом

Общий production-токен даёт каждому запуску одно и то же имя на стороне сервиса. После конфликта видно, что действовал этот токен, но нельзя понять, какой процесс планировал изменение, какое подтверждение его покрывало и какой запуск нужно остановить. Из-за этого очистка занимает больше времени, а отзыв доступа получается слишком широким.

Дайте каждому процессу агента отдельную идентичность, а затем передавайте во все запросы к целевым сервисам стабильный идентификатор корреляции. Целевой сервис должен записывать идентичность, ID запуска, ID запроса, целевой ресурс, наблюдавшуюся версию, результат и собственную новую версию. Не прячьте эту информацию в тексте commit message.

Sallyport не даёт API- и SSH-учётным данным попадать в процесс агента во время выполнения действия. Это помогает разделить контекст планирования агента и сам секрет. Авторизация на уровне сессии может определить только что запущенный процесс агента ещё до начала запуска. Такая авторизация контролирует, кто может действовать, но не заменяет предварительные условия на стороне целевого сервиса.

Не позволяйте агенту выбирать эффективную идентичность через произвольный заголовок. Шлюз или целевой сервис должны связывать идентичность с аутентифицированной сессией. Иначе запуск сможет задним числом заявить, что он координатор развёртывания, а ваши журналы превратятся в спектакль.

Для SSH действует тот же принцип, хотя протокол передачи отличается. Используйте отдельные субъекты или ограниченные аккаунты для разных классов работы. Добавляйте идентификатор запуска в журналы удалённых команд и не используйте один общий shell-аккаунт, которому разрешено редактировать каталоги всех приложений.

## Время подтверждения не равно времени транзакции

Человек может подтвердить запрос агента и всё равно подтвердить запись, которая через десять секунд станет неправильной. Для конкурентной системы это нормально. Подтверждение относится к полномочиям и намерению на момент проверки, но не замораживает ресурс.

Опасная конструкция предлагает человеку подтвердить широкую фразу вроде «обновить production-конфигурацию», а затем позволяет агенту выполнять последовательность чтений и записей в любой момент. Безопаснее показать цель и предполагаемый эффект, а затем позволить сервису проверить версию или аренду в момент поступления записи.

Если предварительное условие не выполнено уже после подтверждения, не используйте это подтверждение автоматически для изменившегося плана. Агент должен конкретно описать конфликт: какой ресурс изменился, какую версию он наблюдал, какое поле изменилось, если сервис может это определить, и нужен ли ещё предлагаемый результат. После этого человек может подтвердить новое действие, либо агент перечитает состояние и безопасно выполнит no-op.

Подтверждение каждого вызова уместно для операций с существенным риском, например удаления production-учётных данных или изменения внешнего правила маршрутизации. Для обычных наборов узких условных записей чаще полезнее подтверждение запуска: оператор видит идентичность и область действия, не превращая процесс в привычное механическое нажатие.

Не принимайте множество подтверждений за контроль. Если операторы не видят ID ресурса, операцию и текущий результат конфликта, они подтверждают фразу, пока сервис выполняет настоящую работу где-то ещё.

## Для ответа на конфликт должен быть назначен владелец

Отклонённая устаревшая запись становится успешным результатом безопасности только тогда, когда запуск понимает, что делать дальше. Правило «повторять при ошибке» здесь не подходит. Оно превращает разногласие в автоматическую гонку.

До разрешения автономного выполнения классифицируйте каждый путь записи. Класс определяет, кто разрешает конфликт:

| Тип изменения | Действие при конфликте версии | Владелец |
| --- | --- | --- |
| Добавление уникального независимого ресурса | Перечитать и повторить, если имя всё ещё свободно | Агент |
| Обновление вычисляемого поля на основе актуальных исходных данных | Перечитать, пересчитать и повторить | Агент |
| Продвижение общего указателя релиза | Остановиться и показать оба кандидата | Владелец релиза |
| Изменение состава доступа или разрешений | Остановиться и запросить проверку | Владелец аккаунта |
| Удаление или замена общего документа конфигурации | Остановиться, если это не покрывает явная аренда | Назначенный оператор |

Цель не в том, чтобы сделать агентов робкими. Нужно отличать пересчёт от суждения. Агент может безопасно повторить создание отчёта на основе актуальных входных данных. Но он не должен выбирать между двумя одобренными production-версиями, двумя решениями о доступе или двумя планами отката только потому, что получил `412`.

Делайте ответы на конфликты машиночитаемыми. Включайте идентичность ресурса, текущую версию, категорию конфликта и признак того, допускает ли конечная точка новую автоматическую попытку. Расплывчатый `409` с HTML-страницей ошибки заставляет агента гадать.

### Проверьте ожидаемый конфликт заранее

Не ждите production-трафика, чтобы убедиться в работе проверок. Создайте тест, который приостанавливает запуск между чтением и записью, позволяет второму запуску изменить тот же ресурс, а затем продолжает первый. Проверьте четыре результата:

1. Первая запись завершается ошибкой и не меняет ресурс.
2. Ответ указывает на устаревшую версию, а не на общую ошибку сервера.
3. Агент не отправляет старое тело автоматически.
4. Журналы связывают обе попытки с их идентичностями запусков и подтверждениями.

Повторите этот тест с тайм-аутом и повторной отправкой, чтобы доказать независимость поведения идемпотентности. Это разные пути сбоя, и для них нужны разные ожидаемые результаты.

## Аудитируйте и попытку действия, и итоговое состояние

После конфликта агента production-аккаунту нужны две записи: путь выполнения команды и авторитетная история ресурса. Журналы шлюза объясняют, кто запросил действие и через какую подтверждённую сессию. Журналы сервиса объясняют, изменилось ли состояние, какая версия победила и почему запрос завершился ошибкой.

Не ограничивайтесь записью «PATCH выполнен успешно». Фиксируйте идентификатор ресурса, метод, ID корреляции запроса, отправленное клиентом предварительное условие, ключ идемпотентности или безопасную ссылку на него, статус ответа и полученный ETag. Если сервис хранит историю на уровне полей, записывайте изменённые поля там, а не пытайтесь восстановить их по расшифровке действий агента.

Sallyport строит журналы Sessions и Activity на основе зашифрованного аудита с хеш-цепочкой. Если вы используете его для действий агентов, во время расследования спорной последовательности выполните проверку:

```sh
sp audit verify
```

Команда проверяет цепочку офлайн по шифротексту и не требует ключа хранилища. Она помогает установить, сохранилась ли локальная история без изменений. Прежде чем делать выводы, сравните её с журналами запросов целевого сервиса.

Здесь важны правила хранения и доступа. Расшифровка агента может содержать ошибочные рассуждения или скопированные операционные данные, а журнал запросов должен оставаться компактной фактической записью. Храните доказательства, необходимые для восстановления полномочий и переходов состояния, и ограничивайте круг людей, которые могут их просматривать.

## Параллельность должна работать с независимыми наборами ресурсов

Для всех автономных запусков не нужна одна глобальная очередь. Нужны правила, которые позволяют независимой работе продолжаться и явно обозначают общие изменения. Разделяйте работу по арендатору, окружению, ветке репозитория, сервису или другому пространству ресурсов, которое может проверить сервис.

Практичная production-схема использует координатор, который назначает каждому запуску набор записей и выдаёт полномочия или доступ к шлюзу только для этого набора. Координатор не решает, разумно ли каждое изменение. Он не даёт двум исполнителям случайно получить пересекающиеся полномочия. При этом целевые сервисы всё равно должны проверять версии и инварианты, поскольку координаторы ломаются, назначения меняются, а люди запускают экстренные задачи в обход обычного пути.

Когда действие затрагивает несколько ресурсов, не называйте его атомарным изменением, если сервисы действительно не могут провести общую транзакцию. Запишите целевое состояние, выполняйте записи в порядке, позволяющем последующим шагам проверять предыдущие, и заранее определите компенсацию. Компенсационное действие тоже должно проверять актуальное состояние. Откат к старому снимку может стереть корректное изменение, произошедшее после исходного запуска.

Первый production-тест должен быть намеренно скучным: выберите один общий ресурс конфигурации, запустите два агента на одной версии и попросите их предложить несовместимые изменения. Если сервис примет оба, исправьте эту конечную точку, прежде чем расширять полномочия любого запуска. После конфликта автономность становится менее интересной, и именно поэтому конфликт стоит сначала вызвать в контролируемом тесте.
