Почему kill процесса — не отмена агента
Агент выполняет цепочку действий, часть которых живёт вне его процесса. Платёж мог пройти, письмо — попасть в очередь провайдера, а файл — сохраниться наполовину. Завершение контейнера останавливает CPU, но не откатывает внешний мир.
Без протокола shutdown появляются дубли после повторной доставки, потерянные checkpoints и задачи, навсегда оставшиеся в статусе running. Поэтому остановка должна быть частью доменной модели, а не обработчиком сигнала на несколько строк.
Четыре причины остановки с разной семантикой
User cancel выражает намерение пользователя. Deadline означает, что результат больше не нужен вовремя. Worker shutdown — инфраструктурное событие: задачу обычно следует продолжить на другом worker. Safety abort срабатывает при нарушении политики и запрещает новые эффекты.
Передавайте структурированную причину, инициатора и timestamp. Один безликий флаг cancelled=true не позволяет решить, нужно ли компенсировать действие, переочередить задачу или закрыть её окончательно.
Протокол graceful shutdown по шагам
- Перевести instance в
drainingи снять readiness. - Прекратить получение новых задач.
- Дождаться безопасной точки активных runs.
- Передать cooperative cancellation тем, что превысили бюджет ожидания.
- Сохранить checkpoint и выпустить lease.
- Закрыть соединения и flush телеметрию.
- После grace deadline разрешить hard kill.
Порядок важен: если сначала закрыть базу, активные задачи не смогут сохранить состояние; если сначала отменить всё, обычный rolling deploy превратится в поток пользовательских ошибок.
Сначала снимите readiness, потом завершайте работу
Readiness отвечает на вопрос, можно ли отправлять instance новый трафик. При получении SIGTERM endpoint должен быстро стать неготовым, а liveness оставаться успешным, пока процесс корректно дренирует работу. Иначе оркестратор может убить живой shutdown как зависший.
Учтите задержку распространения endpoint-состояния через ingress и load balancer. Короткий controlled delay после снятия readiness допустим, но он входит в общий бюджет завершения.
PreStop и terminationGracePeriodSeconds в Kubernetes
Kubernetes запускает PreStop перед TERM, но отсчёт terminationGracePeriodSeconds уже идёт. Если hook занимает 25 секунд из 30, приложению остаётся около пяти секунд. Поэтому hook должен лишь инициировать drain или дать маршрутизации короткое время обновиться.
Доставка lifecycle hook задумана как at-least-once: обработчик обязан быть идемпотентным. Не помещайте в него единственную копию критического checkpoint — процесс может завершиться по другим причинам, при которых hook не спасёт.
Cooperative cancellation через весь стек
Cancellation работает только там, где код её наблюдает. Передавайте token или signal в LLM client, HTTP adapter, retriever, sandbox и дочерние процессы. Проверяйте его между шагами и перед каждым новым write-tool.
В Node.js для этого подходит AbortSignal: timeout и внешний сигнал можно объединить через AbortSignal.any(). Listener регистрируйте с { once: true }, чтобы длительно живущий worker не копил обработчики.
Отмена в Python asyncio
Task.cancel() не уничтожает coroutine мгновенно: CancelledError возникает на следующей возможности переключения. Cleanup размещайте в try/finally. Если исключение перехвачено, после очистки его обычно нужно снова выбросить.
Проглатывание CancelledError ломает ожидания structured concurrency, включая TaskGroup и timeout-контексты. Cleanup также обязан иметь собственный короткий deadline, иначе graceful shutdown никогда не закончится.
Определите safe cancellation points
Безопасная точка находится между атомарными бизнес-операциями: после сохранённого результата tool и до следующего вызова. Внутри пары «создать платёж — записать его ID» отмену лучше временно shield-ить, закончить минимальный критический участок и затем подтвердить cancellation.
Критический участок должен быть коротким и не включать генерацию модели. Его задача — привести состояние в однозначный вид, а не обязательно завершить весь пользовательский сценарий.
Что делать с уже начатым tool call
Read-only запрос можно прервать и забыть. Для write-call возможны три исхода: точно не отправлен, точно завершён, результат неизвестен. Последний нельзя автоматически трактовать как failure.
Сохраните состояние effect_unknown, operation ID и idempotency key. После перезапуска reconciliation-задание спрашивает статус у внешней системы. Только подтверждённое отсутствие эффекта разрешает повторный вызов.
Checkpoint должен переживать worker
Память процесса не является checkpoint. Сохраняйте номер шага, нормализованный state, завершённые effects и версию workflow в устойчивом хранилище. Запись должна быть атомарной или защищённой optimistic concurrency.
Lease отделяйте от статуса задачи. После истечения heartbeat другой worker получает право продолжить run с последнего подтверждённого шага, не создавая вторую параллельную копию.
Drain очереди без потери сообщений
При shutdown consumer перестаёт брать новые сообщения, но не подтверждает текущее до durable checkpoint. Если grace period закончился, broker переотдаст сообщение. Это нормальная at-least-once семантика, если обработчик идемпотентен.
Не увеличивайте visibility timeout бесконечно: умерший worker тогда надолго заблокирует задачу. Heartbeat должен продлевать lease лишь пока наблюдается реальный прогресс.
Компенсация — отдельный workflow, а не finally
Отмена бронирования, возврат платежа или удаление созданного ресурса сами могут упасть. Их нельзя прятать в короткий process cleanup. Создайте durable compensation job с собственным idempotency key, retries и аудитом.
Не каждый эффект следует автоматически откатывать: отправленное письмо не вернуть, а отмена оплаченного заказа может требовать решения человека. Политика компенсации должна зависеть от причины остановки и стадии процесса.
Как выбрать grace period
Измерьте p99 времени до ближайшей safe point, запись checkpoint, закрытие соединений и запас на routing delay. Grace period должен покрывать эту сумму, а не среднюю длительность полного run.
Если один tool может работать двадцать минут, увеличивать termination budget до получаса необязательно. Лучше сделать вызов асинхронным, хранить operation ID и позволить новому worker продолжить polling.
Наблюдаемость shutdown
Собирайте draining_instances, активные runs, время до safe point, forced termination rate, abandoned leases и задачи в effect_unknown. В trace добавляйте shutdown reason и последний подтверждённый шаг.
Алерт нужен не на сам SIGTERM при deploy, а на превышение grace budget, рост принудительных остановок и отсутствие прогресса reconciliation.
Как тестировать безопасную остановку
Посылайте SIGTERM в каждую значимую фазу: до tool call, во время read, после принятия write, перед checkpoint и во время compensation. После рестарта проверяйте конечное состояние и точное число внешних эффектов.
Отдельно тестируйте повторный PreStop, потерю сети во время cleanup и hard kill по истечении grace period. Chaos-тест считается успешным не тогда, когда нет ошибок, а когда нет дублей и потерянных задач.
Чек-лист перед rolling deploy
- SIGTERM переводит instance в draining.
- Readiness снимается до cancellation активных задач.
- Consumer прекращает получать новые сообщения.
- Cancellation signal проходит во все I/O операции.
- Есть safe points и durable checkpoints.
- Неизвестный write-effect получает отдельный статус.
- Cleanup ограничен собственным timeout.
- PreStop идемпотентен и короток.
- Grace period рассчитан по наблюдаемому p99.
- Hard-kill сценарий проходит тест без дублей.