Skip to content

Канал инструментов

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

Эта страница написана один раз — намеренно

Здесь встречаются две фичи. specs/006-runtime-pipeline (T000) провижинит песочницу; specs/007-worker-sandbox (T001, T002) — то, что внутри неё исполняется. Оба списка задач указывают на этот файл и ни один его не пересказывает. Контракт, написанный дважды, разъезжается — и разъезжается молча: сторона, которая ждёт кадр, зависает вместо того чтобы отказать, а зависание диагностируют часами там, где отказ диагностируют за секунды.

Жизненный цикл: DeployUseэтот каналDestroy.

Что здесь — и что намеренно не здесь

Страница описывает только канал 3 (канал инструментов mind↔worker), и внутри него — только жизненный цикл: соединение, рукопожатие, живость, гашение, версии.

ЗдесьНе здесь
Направление соединения, фрейминг и аутентификацияПользовательский чат (канал 1) и очередь провижининга (канал 2) — Use → протокол
initialize · ready · heartbeat · releasetools/list · tools/call · notifications/progress · resource_linkUse → на проводе
Соответствие идентификаторов и правило одного соединенияКакой тул где исполняется — Use → разделение тулов
sampling и остальные обратные capabilitiesПрофили, манифест Job, чеклист безопасности — Deploy
Таймауты, коды закрытия, отказ по версииСостояния сессии и порядок гашения — Destroy

Форма соединения

Worker звонит наружу. Внутрь не звонит никто и никогда.

worker pod                                        api worker-gateway
    │  1. HTTPS upgrade → CONTROL_URL
    │     Authorization: Bearer WORKER_TOKEN
    │     Sec-WebSocket-Protocol: agentfy.worker.v1
    │ ─────────────────────────────────────────────▶
    │                                    101 Switching Protocols
    │ ◀─────────────────────────────────────────────
    │  2. initialize                       (host → worker, MCP client → server)
    │ ◀─────────────────────────────────────────────
    │     initialize result
    │ ─────────────────────────────────────────────▶
    │  3. notifications/initialized
    │ ◀─────────────────────────────────────────────
    │  4. agentfy/ready                    (worker → host)
    │ ─────────────────────────────────────────────▶
    │  5. tools/list                       (host → worker)   ← сессия становится `running`
    │ ◀─────────────────────────────────────────────
    │  …  tools/call · progress · agentfy/heartbeat  …
    │  n. agentfy/release                  (host → worker)
    │ ◀─────────────────────────────────────────────
    │     release result, затем close 4005, exit 0
    │ ─────────────────────────────────────────────▶

Направление — не деталь. Наружу из песочницы дозвониться можно, внутрь — нет, и это сделано специально: NetworkPolicy по умолчанию всё запрещает, у пода нет ни Service, ни Ingress, ни постоянного адреса, и его перепланируют без предупреждения. Поэтому api никогда не держит IP пода. Всё остальное на этой странице следует из одного этого факта: хост не может восстановить потерянное соединение, значит переподключается только worker, и обеим сторонам нужны таймеры, а не возможность подёргать друг друга.

Роли — это не то же самое, что «кто соединился»

TCP / WebSocketMCP
workerклиент — он инициируетсервер — он владеет тулами
api worker-gatewayсервер — он принимаетhost / клиент — он открывает и вызывает

Worker одновременно и инициатор TCP, и MCP-сервер. Именно из-за этой инверсии стоковый MCP-транспорт не подходит и приходится записывать свой: у Streamable HTTP хост должен дотянуться до сервера — ровно та сетевая поза, от которой мы отказались.

Фрейминг

  • Одно сообщение JSON-RPC 2.0 на один текстовый WebSocket-кадр, UTF-8, без батчей и без сборки фрагментов поверх WebSocket-слоя.
  • Кадр, который не является валидным JSON-RPC, получает -32700 / -32600 и считается; десять битых кадров на одном соединении закрывают его с 4006. Парсер, молча выбрасывающий мусор, — это и есть способ превратить рассинхрон версий в зависание.
  • WebSocket ping/pong каждые 30 с в обе стороны. Он не даёт промежуточным узлам закрыть сокет по таймауту и не доказывает ничего про процесс — см. heartbeat.

Идентичность — один идентификатор, проверенный тремя путями

AgentRuntimeSession.id — единственный идентификатор сессии в этом канале. MCP session id — это буквально то же значение, а не второй id с таблицей соответствия. Соответствие — это вторая вещь, которую можно перепутать, а аудиту надо по чему-то джойнить.

Это значение везут три маршрута, и на рукопожатии проверяемы только два из них:

ПутьКто ставитЕдет по проводу?Доверяем?
sub в WORKER_TOKEN (Bearer, на upgrade)api в момент выпускадада — это источник истины
SESSION_ID, возвращённый в результате initializeсборщик манифестаданет — сверяем с токеном, но не верим
Лейбл session на k8s Jobсборщик манифестанетздесь непроверяем вовсе

Лейбл до этого канала не доходит. Это ручка на стороне кластера — по ней reaper удаляет нужный Job, по ней оператор потом сводит под с записью сессии. Контракт, утверждающий, что рукопожатие «требует совпадения всех трёх», утверждал бы то, чего рукопожатие сделать не может. Оно сверяет два маршрута, которые едут, и этого достаточно: токен — источник истины, а переменная окружения — единственное из трёх, что криво собранный манифест может испортить так, что канал это вообще увидит.

Хост берёт sessionId из токена и отказывает, если sessionId, который worker вернул в результате initialize, не совпал: -32002 SESSION_MISMATCH, close 4002, сессия → failed. Под, который спорит с собственным токеном, — это криво собранный манифест, и он обязан упасть громко на нулевой секунде, а не обслужить хоть один вызов тула от имени чужой сессии.

Одно живое соединение — и как сквозь него проходит переподключение

У сессии один живой канал за раз. Записанное плоским правилом «второй дозвон для живой сессии отклоняется», оно полностью обнуляло бы окно переподключения — причём ровно в том случае, ради которого оно написано: worker бросает полуоткрытый сокет на heartbeatGraceSeconds, а хост сдаётся на один интервал позже, поэтому первый же ретрай worker'а попадает в окно, где хост ещё считает старый канал живым. Его бы отклонили, и отклонили окончательно. Путь восстановления не отработал бы ни разу.

Поэтому дозвон везёт ещё два значения, и они отделяют «тот же процесс вернулся» от «второй процесс заслоняет живого»:

ПолеГдеЧто это
instanceId_meta.agentfy в результате initializeвыпускается один раз при старте процесса worker'а; постоянен на всех его переподключениях
attempt_meta.agentfy в результате initialize0 на первом дозвоне, +1 на каждом повторном у этого процесса
  • Тот же instanceId, attempt больше → принято. Хост закрывает старый сокет с 4009 superseded и привязывает сессию к новому. Ничего не теряется: липкое состояние живёт в процессе, а процесс не умирал. Хост заново прогоняет tools/list и заменяет зарегистрированный набор тулов, а не добавляет второй.
  • Тот же instanceId, attempt не больше → отказ 4002. Опоздавший протухший ретрай не должен вытеснять более новое соединение.
  • Другой instanceId → отказ 4002. Это второй процесс с токеном этой сессии — Job, породивший два пода, или переигранный токен, — и ради этого случая правило и существует.

Если worker всё-таки обнаружит у себя два открытых сокета, авторитетен его собственный последний успешный initialize, и он сам закрывает старый, а не обслуживает вызовы в оба.

Четыре кадра

Всё, что есть в канале кроме этих четырёх, — это работа, и работа описана в Use. А эти четыре — то, благодаря чему канал возникает, остаётся честным и заканчивается.

КадрНаправлениеТипОтветЕсли ответа нет
initializehost → workerrequestрезультат initializeworker ретраит, затем выходит с 75; хост валит сессию
agentfy/readyworker → hostnotificationtools/list от хостаworker ретраит, затем выходит с 75; хост валит сессию
agentfy/heartbeatworker → hostrequestрезультат heartbeatworker ретраит, затем выходит с 75; хост жнёт сессию
agentfy/releasehost → workerrequestрезультат releaseхост удаляет песочницу по истечении грейса

Свои методы лежат в неймспейсе agentfy/, чтобы никогда не столкнуться с методом, который MCP добавит позже. initialize — стоковый MCP и используется ровно так, как MCP его определяет.

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

1. initialize — host → worker

Шлёт хост, сразу после успешного upgrade. В MCP initialize — это запрос клиент → сервер, а клиент здесь хост; отвечает на него worker.

Аутентификации в этом кадре нет. WORKER_TOKEN едет в заголовке Authorization при WebSocket-upgrade, поэтому неаутентифицированный дозвон отклоняется с HTTP 401 до того, как проедет хоть один байт JSON-RPC, и MCP-сессией не становится вовсе. Это расходится с буквальной формулировкой в Use → handshake; см. расхождения.

jsonc
// host → worker
{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
  "protocolVersion": "2025-06-18",
  "clientInfo": { "name": "agentfy-worker-gateway", "version": "<тег образа api>" },
  "capabilities": { },                       // ← пусто. ни sampling, ни elicitation, ни roots
  "_meta": { "agentfy": {
    "sessionId":  "<AgentRuntimeSession.id>",
    "channelVersion": "1.3.0",
    "profile":    "light",
    "budgets": { "handshakeSeconds": 10, "readySeconds": 30, "readyAckSeconds": 15,
                 "heartbeatIntervalSeconds": 10, "heartbeatGraceSeconds": 30,
                 "idleSeconds": 90, "maxExecSeconds": 900,
                 "releaseGraceSeconds": 30, "reconnectWindowSeconds": 60 } } } } }
// dialSeconds здесь намеренно нет — он правит ожиданием ДО того, как этот сокет
// появился, поэтому worker его не наблюдает. Это значение профиля. См. #deadlines.
jsonc
// worker → host — ответ
{ "jsonrpc": "2.0", "id": 1, "result": {
  "protocolVersion": "2025-06-18",
  "serverInfo": { "name": "agentfy-worker", "version": "<тег образа worker>" },
  "capabilities": { "tools": { "listChanged": true }, "logging": { } },
  "_meta": { "agentfy": { "sessionId": "<эхо>", "channelVersion": "1.1.0",
                          "instanceId": "<выпускается один раз при старте>", "attempt": 0 } } } }

За результатом worker шлёт стоковое notifications/initialized; только после этого хост имеет право послать tools/list.

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

При молчании — и это два РАЗНЫХ ожидания, а не одно. Обе стороны заводят таймер до того, как начинают ждать; значения — в Дедлайнах.

  • До того, как этот сокет появился. Хост заводит dialSeconds в тот момент, когда создаёт песочницу. Это окно покрывает планирование пода, вытягивание образа и старт процесса — холодный старт, который Deploy считает отдельной статьёй, и профилю Browser с Chromium внутри его нужно куда больше, чем Light. Это не handshakeSeconds. Один бюджет на оба ожидания приравнивает терпение хоста к целому холодному старту к терпению на один круговой обмен — и тогда каждая сессия умирает раньше, чем поднимется её под.
  • Worker — нет initialize в течение handshakeSeconds с момента открытия сокета: закрыть, ретраить внутри окна переподключения, затем выйти с 75. Хост принял соединение и замолчал; это отказ на его стороне, и он вполне может пройти.
  • Хостnotifications/initialized не пришёл за 2 × handshakeSeconds с момента открытия сокета — вдвое больше бюджета worker'а, чтобы worker всегда сдавался первым и успевал ретраить в сессию, которая ещё существует. (Ожидание до этого — dialSeconds, выше; ожидание после — readySeconds, следующий раздел.) По истечении: close 4003, сессия → failed (failureReason: handshake_timeout), запускается гашение, и никакого ретрая (backoffLimit: 0).

Два слова, два события, два бюджета. Рукопожатие завершено на notifications/initialized — там, где инициализацию заканчивает сам MCP, и это то, что меряет handshakeSeconds. Сессия становится running, когда вернулся tools/list, и это то, что меряет readySeconds, начиная ровно там, где рукопожатие кончилось. Схлопывание этих двух в один дедлайн и делает второй недостижимым — таблица дедлайнов объясняет почему подробно. Между созданием песочницы и running хост поэтому нигде не безоружен и нигде не вооружён дважды: три последовательных ожидания, без промежутка и без перекрытия.

Каждое ожидание таким образом ограничено с обеих сторон независимо, и ни одной стороне не разрешено ждать, полагаясь на хорошее поведение другой.

2. agentfy/ready — worker → host

Нотификация, которую шлёт worker, и означает она ровно: мои исполнители построены, рабочая директория примонтирована, я приму tools/call. Она несёт тот allowlist, который worker реально разрезолвил, — так расхождение о наборе тулов всплывает до первого вызова, а не в виде пропавшего тула позже.

jsonc
// worker → host
{ "jsonrpc": "2.0", "method": "notifications/agentfy/ready", "params": {
  "sessionId": "<id>", "tools": ["exec", "file"], "workspace": "/workspace",
  "bootMillis": 812 } }

ready и running — два события, а не одно слово. Worker объявляет готовность; хост объявляет сессию running только после того, как tools/list вернулся и набор тулов зарегистрирован в живой сессии. Между ними лежит настоящее окно, в котором песочница уже согласна, а мозг ещё не может её позвать.

Ответ — это tools/list от хоста. У нотификации нет JSON-RPC-ответа, поэтому подтверждением служит вызов discovery. Без этого правила самый вероятный отказ worker'а — вечно сидеть готовым, когда никто не слушает.

При молчании — в обе стороны, потому что песочница, которая так и не объявилась, — такой же настоящий отказ, как хост, который не ответил объявившейся:

Кто замечаетПорогЧто происходит
Workerнет tools/list после agentfy/ready в течение readyAckSecondsсчитает хост отсутствующим → окно переподключения → выход с 75
Хостсессия не дошла до running за readySeconds после notifications/initialized — либо agentfy/ready так и не пришёл, либо tools/list остался без ответаclose 4003, сессия → failed (failureReason: ready_timeout), гашение, без ретрая

readySeconds покрывает всё между концом рукопожатия и моментом, когда сессией можно пользоваться: построение исполнителей, монтирование рабочей директории и ответ на tools/list. Поэтому он такое же значение профиля, как остальные — профилю Browser, поднимающему Chromium, его нужно больше, чем Light, которому надо лишь открыть шелл, — и это единственный дедлайн хоста, взведённый на этом отрезке. Ничто более короткое не имеет права его перекрывать, иначе профиль, ради которого он заведён, — это профиль, который он убивает.

3. agentfy/heartbeat — worker → host

Запрос JSON-RPC, а не WebSocket-ping, который worker шлёт каждые heartbeatIntervalSeconds.

jsonc
// worker → host
{ "jsonrpc": "2.0", "id": 42, "method": "agentfy/heartbeat", "params": {
  "sessionId": "<id>",
  "idleSeconds": 37,          // с момента, когда ЗАВЕРШИЛСЯ последний tools/call — часы простоя
  "execSeconds": 512,         // с момента старта процесса — часы потолка
  "activeToolCalls": 0,
  "workspaceBytes": 18452113,
  "channelVersion": "1.1.0" } }   // ДЕЙСТВУЮЩАЯ версия, как её посчитал worker

// host → worker — ответ
{ "jsonrpc": "2.0", "id": 42, "result": {
  "continue": true, "idleBudgetSeconds": 90, "execBudgetSeconds": 900 } }

Почему запрос, а не ping/pong. На ping/pong отвечает WebSocket-библиотека под приложением: worker с заклинившим event loop'ом, с мёртвыми исполнителями или с исчезнувшей рабочей директорией продолжает бодро понговать. А это ровно тот зомби, ради которого heartbeat и существует. На запрос отвечает тот же код, которому пришлось бы отвечать и на вызов тула, и запрос может нести полезную нагрузку — что нужно следующему правилу.

Heartbeat — сигнал живости, но никогда не сигнал активности. Он говорит я жив; он не говорит я работаю. Часы простоя сбрасывает завершившийся tools/call и больше ничто. Обе стороны читают одно и то же число — idleSeconds, измеренное один раз, внутри worker'а, — поэтому reaper и песочница не могут разойтись во мнении о том, кто простаивает. Это исправляет настоящее противоречие в документах worker'а: страницы, где heartbeat сбрасывает таймер простоя, описывают сессию, которую не сожнут никогда, потому что здоровый простаивающий worker бьётся сердцем вечно.

Хост может в ответе урезать бюджет и может выставить continue: false — это значит сворачивайся сейчас, и worker запускает своё гашение так, как если бы его отпустили. Хост никогда не может поднять execBudgetSeconds выше maxExecSeconds; песочница держит свой собственный потолок независимо от того, что ей сказали, потому что потолок, который можно поднять по проводу, — не потолок.

При молчании.

Кто замечаетПорогЧто происходит
Workerнет ответа на heartbeat за heartbeatGraceSeconds (три пропущенных)control-соединение считается потерянным → окно переподключения → выход с 75
Хостнет запроса heartbeat за heartbeatGraceSeconds + heartbeatIntervalSecondsсессия → failed, запускается гашение, песочница удаляется — Destroy → защита от зомби

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

4. agentfy/release — host → worker

Запрос, который шлёт хост, когда сессия закончена: истекло окно простоя у reaper'а, мозг решил, что работа сделана, упёрлись в жёсткий потолок или сессия свалилась.

jsonc
// host → worker
{ "jsonrpc": "2.0", "id": 77, "method": "agentfy/release", "params": {
  "sessionId": "<id>", "reason": "idle", "graceSeconds": 30 } }   // releaseGraceSeconds
// reason ∈ idle | explicit | deadline | failed

// worker → host — после сброса выходов, до закрытия
{ "jsonrpc": "2.0", "id": 77, "result": {
  "accepted": true, "outputsFlushed": true,
  "outputs": [ { "type": "resource_link", "uri": "store://art_88" } ] } }

Ответ — это результат release, и это не подтверждение, а отчёт. Worker шлёт его только после того, как остановил исполнителей и выгрузил выходы, и несёт в нём, удалась ли выгрузка. Голое «ок» не сказало бы хосту ничего нужного, потому что единственное, что хост обязан записать, — потерялось ли что-нибудь. Дальше worker закрывается с 4005 и выходит с 0.

graceSeconds — это то, что делает порядок гашения обеспеченным.Destroy требует, чтобы выходы доехали до system/file до удаления песочницы. Этому шагу нужно ограниченное окно, и вот оно: хост ждёт максимум graceSeconds, затем удаляет песочницу всё равно и записывает на сессию outputsFlushed: false. Данные, которые не удалось выгрузить, — это факт, который стоит иметь; хост, вечно ждущий заклинивший worker, — это утечка.

При молчании — в обе стороны, в той же форме, что и у остальных трёх кадров:

Кто замечаетПорогЧто происходит
Хостнет результата release за releaseGraceSecondsудалить песочницу всё равно, записать на сессию outputsFlushed: false
Workerrelease не пришёл вовсе, потому что хост исчезего гасят собственные таймеры idleSeconds и maxExecSeconds — а если умер именно канал, то окно переподключения и выход с 75

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

В полёте. Хост не шлёт release, пока не завершён tools/call, — кроме случаев, когда reason это deadline или failed; в них worker отменяет исполнителей и всё равно пытается выгрузить выходы.

Кадра done не существует. Destroy упоминает подсказку done от LLM — это подсказка мозгу, а мозг превращает её в release с reason: "explicit". В этом канале ничто не называется done.

Дедлайны и коды выхода

Каждое ожидание в этом канале — в этой таблице, и каждое число в ней один раз. Раздел кадра выше называет бюджет, который правит ожиданием; значение живёт здесь. Две копии таймаута — это два таймаута, и вторая станет неверной в тот день, когда кто-нибудь подкрутит первую.

Умолчания даны для профиля Light, и каждое из них переопределяется в RuntimeProfile. Ни одно из них не измерение — ничего ещё не запускалось.

ОжиданиеЧей таймерБюджет · умолчаниеОтсчёт отПо истечении
под дозваниваетсяхостdialSeconds · 120 ссоздания песочницысессия → failed (dial_timeout), гашение, без ретрая
приходит initializeworkerhandshakeSeconds · 10 соткрытия сокетаclose → окно переподключения → выход 75
MCP-рукопожатие завершенохост2 × handshakeSeconds · 20 соткрытия сокетаclose 4003, сессия → failed (handshake_timeout), без ретрая
сессия дошла до runningхостreadySeconds · 30 сnotifications/initializedclose 4003, сессия → failed (ready_timeout), без ретрая
приходит tools/listworkerreadyAckSeconds · 15 сотправки agentfy/readyclose → окно переподключения → выход 75
на heartbeat ответилиworkerheartbeatGraceSeconds · 30 сотправки heartbeatокно переподключения → выход 75
приходит heartbeatхостheartbeatGraceSeconds + heartbeatIntervalSeconds · 40 спредыдущего heartbeatclose 4004, сессия → failed, гашение
на agentfy/release ответилихостreleaseGraceSeconds · 30 сотправки releaseудалить песочницу всё равно, записать outputsFlushed: false
потерянный канал вернулсяworkerreconnectWindowSeconds · 60 смомента, когда канал признан потеряннымвыгрузить что можно, выход 75
песочница перестала работатьобеidleSeconds · 90 смомента, когда завершился последний tools/callreaper отпускает сессию
песочница работает слишком долгообеmaxExecSeconds · 900 сстарта процессажёсткая остановка; activeDeadlineSeconds — страховка кластера, и это не то же число — см. ниже

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

Три стартовых ожидания хоста идут ПОДРЯД, и каждое начинается там, где кончилось предыдущее.dialSeconds — от создания песочницы до сокета; handshakeSeconds — от сокета до notifications/initialized; readySeconds — оттуда и до момента, когда сессия стала running. Они не перекрываются, и это правило, а не наблюдение. В более раннем черновике ожидание рукопожатия кончалось на «пришёл agentfy/ready и вернулся tools/list» — то есть десятисекундный и тридцатисекундный таймеры стояли на отрезках с общим концом. Всегда срабатывает более тугой, поэтому любая песочница, которой на построение исполнителей нужно больше десяти секунд, умирала на десятой с handshake_timeout, а бюджет, заведённый ради медленных исполнителей, был недостижим ровно на том профиле, ради которого его заводили. Перекрывающиеся дедлайны не складывают терпение; существует только самый маленький из них.

И на единственном шве, за которым следят обе стороны, хост намеренно медленнее.handshakeSeconds у worker'а и 2 × handshakeSeconds у хоста отсчитываются от одного и того же сокета, и удвоение — это и есть смысл: заметить молчащий хост первым обязан worker, потому что он единственная сторона, которая может позвонить снова. Сработают одновременно — и хост пометит сессию failed и погасит её в тот же миг, когда worker решит ретраить, так что ретрай придёт к сессии, которой уже нет. Это дефект правила одного соединения слоем выше, и ответ тот же: первой замечает та сторона, которая ещё может действовать. По той же причине асимметрична и пара heartbeat'ов.

Что делает с этими ожиданиями повторный дозвон — а асимметрия теперь гарантирует, что он бывает. Worker, сдавшийся на молчащем хосте, ретраит в сессию, которую хост ещё держит. Этот дозвон привязывается к тому же sessionId по правилу одного соединения, и хост взводит handshakeSeconds и readySeconds заново, против нового канала — иначе переподключённая сессия умерла бы по часам, запущенным на сокете, которого больше нет. Что НЕ взводится заново — это внешняя граница: хост держит dialSeconds + 2 × handshakeSeconds + readySeconds от создания песочницы как единственный ответ на «эта сессия ни разу не стала пригодной», и повторный дозвон её не продлевает. Как только сессия хоть раз дошла до running, эта граница израсходована, и дальше правит окно переподключения.

Три ограничения, которые обязан утверждать каталог профилей, потому что эта страница не может:

  1. idleSeconds < maxExecSeconds — иначе k8s убивает тёплую сессию посреди обещанного ей окна простоя. См. расхождение 6.
  2. dialSeconds > 0, со значением, взятым из образа самого профиля, а не из умолчания Light.
  3. activeDeadlineSeconds ≥ dialSeconds + maxExecSeconds. Kubernetes считает activeDeadlineSeconds от старта Job, то есть включая весь холодный старт; maxExecSeconds считается от старта процесса. Иллюстративный манифест в Deploy приравнивает их — это было верно, пока бюджет был один, и неверно теперь, когда у холодного старта свой. Оставить их равными — значит убивать Browser-сессию примерно на время pull'а и запуска раньше срока, причём убивать необъяснённым обрывом соединения, то есть ровно тем отказом, который эта страница старательнее всего вычищает.

Коды выхода

Код выхода worker'а говорит, какого рода был отказ, а значит — стоит ли кому-то ретраить.

ВыходИмяЗначитОткуда попадают
0отпущен штатнопринятый agentfy/release, close 4005
75EX_TEMPFAILcontrol-плоскость замолчала и не вернуласьлюбой дедлайн молчания выше, после истечения окна переподключения; close 4004
77EX_NOPERMтокен или сессия отклоненыclose 4002
78EX_CONFIGретрай ничего не изменит: стороны не могут договориться о версии или формате кадров, либо песочница не успела поднятьсяHTTP 426, close 4001, 4003, 4006

Молчание — это 75, несогласие — это 78. Различие и есть весь смысл: 75 говорит попробуй ещё раз, и вполне может получиться, 78 говорит ничего не изменится, пока кто-нибудь не выкатит что-то другое. Страница, отвечающая на «хост замолчал» кодом EX_CONFIG, отправила бы оператора искать рассинхрон версий, которого нет.

Потеря соединения

Хост не может дозвониться в песочницу, поэтому переподключается только worker — и только в пределах ограничения.

  1. Сокет отваливается, либо срабатывает собственный grace-таймер worker'а.
  2. Worker переподключается к CONTROL_URL с экспоненциальным backoff'ом (1 с, 2 с, 4 с… с потолком 10 с) не дольше reconnectWindowSeconds. Исполнители продолжают работать, липкое состояние не трогают — процесс не умирал, поэтому cwd, окружение, фоновые процессы и контекст браузера живы.
  3. Каждый ретрай везёт instanceId процесса и увеличенный attempt. Успешное переподключение — это свежий initialize, привязанный к тому же sessionId, и хост даёт ему вытеснить старый канал по правилу одного соединения, закрывая старый сокет с 4009, а не отклоняя новый. Именно этот шаг делает окно настоящим: worker бросает полуоткрытый сокет на один интервал раньше хоста, поэтому его первый ретрай неизбежно приходит, пока хост ещё считает старый канал живым. Плоское «одно соединение на сессию» отклонило бы ровно этот ретрай, окончательно, и этот раздел не отработал бы никогда.
  4. Окно истекло → worker выгружает что успел и выходит с 75 (EX_TEMPFAIL).

Код закрытия решает, разрешено ли вообще переподключаться.

CloseЧто значитWorker переподключается?
4001версия канала или MCP не поддержананет — exit 78
4002несовпадение сессии, невалидный или истёкший токен, дубль соединениянет — exit 77
4003пропущен дедлайн рукопожатиянет — exit 78
4004потерян heartbeat (со стороны хоста)нет — exit 75
4005released, штатнонет — exit 0
4006слишком много битых кадровнет — exit 78
4008хост уходит в drain (катится реплика api)да, в пределах окна
4009superseded — этот сокет заменён более новым дозвоном того же процессанет, и ничего не упало: новый канал уже обслуживает
любой обрыв на уровне транспортанеизвестнода, в пределах окна

Катящийся деплой api закрывает соединение с 4008, и песочница это пережидает. Любой другой отказ окончателен: ретраить отказ, вызванный версией или токеном, — значит жечь кластер и прятать причину.

sampling выключен — и вот где именно

В песочнице нет ни одного ключа к модели. Песочница, которая может попросить хост запустить модель, занимала бы у мозга его ключ прямо по проводу — а это ровно та граница, ради которой песочница и существует. Вместе с ним выключаются ещё две обратные capabilities: elicitation (сервер задаёт вопрос человеку — песочница не имеет права дотягиваться до человека) и roots (клиент открывает серверу свою файловую систему).

Три места, и они не дублируют друг друга.

ГдеЧем обеспеченоЧто останавливает
1запрос initialize от хостаcapabilities: {}sampling, elicitation и roots отсутствуюткорректный worker вообще никогда не сформирует такой запрос; MCP запрещает серверу пользоваться capability, которую клиент не объявил
2транспорт хоста, до диспетчеризациилюбой входящий запрос, у которого method начинается на sampling/, получает -32003 SAMPLING_DISABLED и попадает в аудит сессии. elicitation/ и roots/-32004 CAPABILITY_NOT_NEGOTIATEDнекорректный или скомпрометированный worker. Это несущее место: оно не зависит ни от чего, что делает песочница, и живёт в одной точке, а не по одной на каждый хендлер
3транспорт worker'а, на выходета же проверка на исходящих кадрахбаг в исполнителе не сможет даже сформировать такой запрос, а отказ виден в собственных логах песочницы

Слой 2 — это гарантия; 1 и 3 — то, благодаря чему корректная система до неё не доходит. Обратите внимание, чего в списке нет: проверки внутри каждого исполнителя. Это форма, которая гниёт: её можно забыть в одном исполнителе, и никто не заметит, потому что тул продолжит работать.

Правило версий

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

Три уровня, у каждого свой отказ

УровеньГде записанЧто делает несовпадение
Мажор каналаWebSocket-субпротокол на upgrade: agentfy.worker.v1Хост отвечает HTTP 426 Upgrade Required, перечисляя мажоры, на которых говорит, в Sec-WebSocket-Protocol. Сокет не открывается, JSON-RPC не существует. Worker логирует оба списка и выходит с 78.
Версия протокола MCPprotocolVersion в initialize и в результатеWorker отвечает ближайшей версией, которую поддерживает. Если хост на ней говорить не умеет, он закрывает соединение с 4001, неся оба значения. Никакого молчаливого даунгрейда — сторона, тихо притворившаяся, что говорит на версии, которой не знает, и есть способ потерять поле и не дождаться кадра.
Минор канала_meta.agentfy.channelVersion, semver, в обе стороныРазрешён внутри мажора. Действующая версия — меньшая из двух, и её считают обе стороны — см. ниже. Дальше каждая сторона пользуется только фичами не выше неё.

Действующий минор никто не объявляет, потому что и не может

Очевидная формулировка — «хост объявляет действующую версию в результате initialize» — в этом канале неверна, и неверна поучительно. Результат initialize шлёт worker, а не хост; кадра, в котором хост мог бы что-то объявить между результатом и tools/list, не существует. Поэтому правило — арифметика, а не объявление:

  1. Запрос initialize от хоста несёт channelVersion хоста. Результат worker'а несёт его собственный.
  2. После результата обе стороны держат оба числа, и обе считают min(host, worker). Никому ничего сообщать не надо.
  3. Каждый agentfy/heartbeat везёт действующую версию такой, какой её посчитал worker. Если она разошлась с собственной арифметикой хоста, хост отказывает -32001 CHANNEL_VERSION_MISMATCH и закрывает 4001. Согласование, о котором договорились только два приватных вычисления, — это согласование, которое никто не проверил; это же перепроверяется каждые десять секунд и падает отказом, а не полем, которое тихо пропало.

И один кадр неизбежно выведен из-под правила. Хост шлёт initialize до того, как может узнать минор worker'а, поэтому «не слать поле, которого другая сторона не обещала» там невыполнимо. Этот кадр поэтому ограничен тем, что гарантирует любой минор мажора канала — а это и есть смысл слова «мажор», — и контракт совместимости для него работает в обратную сторону:

  • Запрос initialize от хоста несёт только поля базового уровня мажора. Всё, что добавил более поздний минор, едет в кадре после результата, но никогда в первом.
  • Worker на более старом миноре игнорирует незнакомые ключи _meta.agentfy, а не отказывает. _meta — это точка расширения MCP, ровно для этого она и есть. Отказ там сделал бы каждое повышение минора ломающим и лишил бы миноры смысла.
  • Та же терпимость — у хоста, читающего результат worker'а. Незнакомый ключ: игнорировать. Незнакомый метод или знакомое поле не той формы: отказывать.

С первого же кадра после результата действует строгое правило: ни одна сторона не шлёт поля, которого другая не обещала.

Правило, которое превращает несовпадение в отказ

Любое решение по версии заканчивается кадром, и у любого ожидания есть дедлайн.

Отказ — это HTTP-статус, код закрытия WebSocket или ошибка JSON-RPC, и в нём стоят оба значения версии. Оборвать соединение — не отказ. Принять соединение и замолчать — не отказ.

И поскольку сам отказ может потеряться, ни одной стороне не разрешено рассчитывать на его получение. Обе заводят таймер от открытия сокетаhandshakeSeconds у worker'а и вдвое больше у хоста, чтобы первой сдавалась та сторона, которая может позвонить снова, — а хост заводит dialSeconds от создания песочницы на холодный старт, который происходит до появления этого сокета. Worker, упёршийся в свой дедлайн, закрывается, ретраит внутри окна переподключения и выходит с 75; хост, упёршийся в любой из своих, помечает сессию failed и гасит её. Все значения — в Дедлайнах и больше нигде на этой странице.

Несовпадение таким образом ограничено дважды — один раз отказом, второй раз дедлайном, — и ни один путь не оставляет сессию вечно висеть в starting. А два числа внутри отказа — это то, что делает диагностику пятисекундной: оператор видит worker говорит agentfy.worker.v2, api говорит v1 и сразу знает, который из двух выкатился.

Что каждая сторона делает потом

  • Хост. Сессия → failed, failureReason: channel_version, обе версии и оба тега образов записаны, запускается гашение, ретрая нет. Несовпадение версий — проблема деплоя; ретраить её значит жечь ёмкость нод и хоронить причину под пачкой одинаковых падений.
  • Worker. Выходит с 78 (EX_CONFIG), обе версии в stderr. Он не ретраит и не откатывается: песочница, которая даунгрейдит себя ради сохранения соединения, хуже той, которая умирает.

Окно совместимости — то, благодаря чему всё это вообще выживает

Хост принимает текущий мажор канала и предыдущий; образ worker'а говорит ровно на одном. Если бы хост поддерживал один мажор, у каждого деплоя было бы окно, в котором одна из сторон отказывает во всём.

Отсюда и порядок: сначала катим шлюз api — он умеет и N−1, и N; потом катим образ worker'а до N; и только когда ни одна сессия уже не может быть запланирована на образ N−1, выкидываем N−1 из хоста. Мажор, выброшенный раньше, чем ушёл последний старый образ, превращает рядовой деплой в отказ рук.

Коды ошибок

Прикладные коды JSON-RPC (диапазон -32000…-32099) и коды закрытия WebSocket — часть этого контракта: отказ, который не может сматчить машина, не сматчит и человек под давлением.

КодИмяКто шлётКогда
-32001CHANNEL_VERSION_MISMATCHлюбая сторонакадр или поле принадлежит версии, которую получатель не согласовывал
-32002SESSION_MISMATCHхостsessionId worker'а не равен sub токена
-32003SAMPLING_DISABLEDхостлюбой запрос sampling/*
-32004CAPABILITY_NOT_NEGOTIATEDхостelicitation/*, roots/* или что угодно необъявленное
-32005SESSION_NOT_RUNNINGworkertools/call до ready либо после принятого release

Расхождения в документах worker'а — названы, а не сглажены

Шесть мест, где Deploy, Use, Destroy и Implementation расходятся друг с другом или оставляют дыру. Они записаны здесь, а не тихо залатаны в тех страницах, потому что за каждым стоит чьё-то решение и рассуждение стоит сохранить.

1 — Кто шлёт initialize. Use → handshake читается как «worker стартует → дозванивается до CONTROL_URL по WebSocket → MCP initialize (предъявляет WORKER_TOKEN)», то есть сажает worker'а в кресло отправителя. Та же страница четырьмя абзацами ниже говорит: «worker — инициатор TCP, но при этом MCP-сервер; шлюз — MCP-клиент», а в MCP initialize — запрос клиент→сервер. Реализованное буквально с обеих сторон, это даёт обе стороны ждут друг друга и канал зависает: ровно тот отказ, ради которого этот контракт существует, и причина, по которой его надо было написать раньше любой из половин. Решено: initialize шлёт хост, как в MCP. Токен переезжает на WebSocket-upgrade, где проверяется до того, как появится хоть какой-то JSON-RPC, — а это строго лучше, чем везти его в кадре.

2 — Что такое heartbeat. Use говорит «Heartbeats (ws ping/pong)». Implementation перечисляет heartbeat как кадр рядом с initialize, ready и release. Это разные слои с разными гарантиями: на ping/pong отвечают ниже приложения, и он не может нести полезную нагрузку. Решено: запрос agentfy/heartbeat с ответом. Ping/pong остаётся тупым keepalive'ом для промежуточных узлов, и на этой странице прямо сказано, что он не доказывает ничего про процесс.

3 — Что heartbeat сбрасывает (несущее расхождение).Deploy — «heartbeats reset the idle timer» — и Destroy — reaper «пересоздаётся на каждом heartbeat» — заставляют живость сбрасывать часы простоя. Взятое буквально, это значит, что здоровую простаивающую сессию не сожнут никогда, потому что здоровый простаивающий worker бьётся сердцем вечно: ровно та утечка, ради которой страница Destroy и написана. Та же страница говорит и «последующий турн внутри окна переиспользует сессию и сбрасывает таймер» — это вторые, правильные часы. Оба прочтения живут на одной странице. Решено: двое часов, одно измерение. idleSeconds сбрасывает только завершившийся tools/call; измеряется в worker'е, приезжает в каждом heartbeat, читается и reaper'ом, и песочницей.

4 — Кто объявляет ready. Deploy → boot & регистрация пишет, что под «дозванивается до Core по WebSocket и сигнализирует ready». Диаграмма в Use приводит к «ready» шлюз, уже прогнавший tools/list. Решено: оба — настоящие события, которым досталось одно слово. agentfy/ready от worker'а объявляет песочницу согласной; состояние running у хоста наступает после tools/list. Окно между ними — это место, где живёт сбой регистрации, и теперь у него есть имя.

5 — Bootstrap-окружение перечислено трижды и по-разному.Implementation и Use перечисляют SESSION_ID, CONTROL_URL, WORKER_TOKEN, TOOL_ALLOWLIST и скоуп хранилища. Единственное место, где показан настоящий манифест — Deploy, — называет первое, второе и четвёртое, не имеет скоупа хранилища вовсе и добирается до токена через безымянный secretRef. Манифест помечен как иллюстративный, так что это дыра, а не противоречие, — но WORKER_TOKEN несущий для старта песочницы, и его нет ровно на той странице, откуда реализующий будет копировать.

Решено в 006 T104 (AGNT2-197): пропуск — это файл. WORKER_TOKEN_FILE называет путь, по которому смонтирован projected Secret, манифест на Deploy теперь показывает том, а выставить одновременно WORKER_TOKEN_FILE и WORKER_TOKEN нельзя — иначе наполовину переведённый манифест тихо оставил бы значение в окружении.

6 — Ограничение, которого не пишет ни одна страница: idleSeconds должен быть меньше maxExecSeconds. Профиль Warm простаивает 10–30 минут (Deploy → режимы рантайма), тогда как иллюстративный манифест ставит Job activeDeadlineSeconds: 900, а Destroy утверждает, что потолок max-idle никогда не отключается. Если maxExecSeconds профиля не превышает его же окно простоя, k8s убивает тёплую сессию посреди обещанного ей окна, и песочница видит необъяснённый обрыв соединения — она не может отличить «хост исчез» от «меня сейчас убьют». Это меняет смысл молчания, поэтому место ему здесь: каждый профиль обязан удовлетворять idleSeconds < maxExecSeconds, и утверждается это в каталоге профилей.

Решения, которые принимает эта страница и которых раньше записано не было

Всё, что выше и чего нет в таблице ниже, уже решено в Deploy / Use / Destroy / Implementation или в Принятых решениях, и повторено здесь только потому, что контракт должен читаться за один присест. А вот это — новое:

РешениеЗачем
Токен аутентифицирует на WebSocket-upgrade, а не внутри initializeСохраняет собственное направление initialize в MCP и отклоняет неаутентифицированный дозвон до того, как выделено хоть какое-то состояние сессии
heartbeatзапрос, несущий idleSecondsТолько на запрос можно не получить ответа, а это то, что нужно таймеру самогашения worker'а; и только полезная нагрузка может нести то единственное измерение простоя, которое читают обе стороны
elicitation и roots не объявляются наравне с samplingДокументы выключают sampling. Тот же довод покрывает и песочницу, задающую вопрос человеку, и хост, открывающий ей свою файловую систему: все три — обратные capabilities, которых у песочницы быть не должно
Ограниченное окно переподключения (по умолчанию 60 с) перед самогашениемКатящийся деплой api не должен убивать все живые песочницы; безграничный ретрай — та самая утечка, ради которой существуют потолки. Ограничение — то, что делает верным и то и другое одновременно
Коды закрытия и коды выхода решают, разрешён ли ретрай«Ретраить при обрыве» ретраит несовпадение версий вечно, и причина хоронится под пачкой одинаковых падений
Хост принимает мажоры канала N и N−1При одном поддержанном мажоре у каждой раздельной выкатки двух образов есть окно, в котором рук просто нет
-32001…-32005 как именованные кодыОтказ, который не может сматчить машина, не сматчит и человек в три часа ночи
dialSeconds — самостоятельный бюджет, отдельный от handshakeSecondsЭто разные ожидания: холодный старт против кругового обмена. Одно число на оба заставляет хост сдаваться раньше, чем появится под, — всегда, и хуже всего на профиле с браузером внутри
Одно живое соединение, вытесняемое по instanceId + attempt, а не отклоняемое наглухоПлоский отказ обнуляет окно переподключения ровно в том случае, ради которого оно написано. Эти два поля и отделяют «тот же процесс вернулся» от «второй процесс заслоняет живого»
Действующий минор считают обе стороны, и он перепроверяется в каждом heartbeatКадр результата принадлежит worker'у, поэтому хосту не в чем его объявить; а значение, о котором договорились только два приватных вычисления, никто не проверял
Запрос initialize выведен из-под «не слать поля, которого другая сторона не обещала», а незнакомые ключи _meta.agentfy игнорируютсяОн шлётся до того, как известен минор. Без этого исключения вместе с терпимостью любое повышение минора становится ломающим
Worker подчиняется меньшему из двух потолков — из манифеста и из кадраПотолок, который можно поднять по проводу, — не потолок, а тот, что существует в одном месте, никто не может проверить
Десять битых кадров закрывают соединение (4006), и парсер никогда не выбрасывает молчаМолчаливое выбрасывание мусора — это и есть способ показать рассинхрон версий зависанием вместо отказа
WebSocket ping/pong каждые 30 с поверх heartbeatОн не даёт промежуточным узлам закрыть сокет по простою. И он явно не сигнал живости — это heartbeat, и страница объясняет почему
Никакого release, пока не завершён tools/call, кроме причин deadline и failedИначе обычное гашение по простою гонится с работающим тулом и теряет его выход — ровно то, ради предотвращения чего существует порядок гашения
Выход 75 на молчание, 78 на несогласиеОни требуют противоположных действий — ретрай против «выкатите другое», — и один код на оба отправляет оператора искать рассинхрон версий, которого нет
Стартовые ожидания хоста идут подряд, а его бюджет рукопожатия вдвое больше, чем у worker'аДва таймера на отрезках с общим концом — это один таймер, тот, что короче, и тогда более длинный бюджет недостижим. А на шве, за которым следят обе стороны, первой обязана сдаваться та, что может позвонить снова, иначе хост погасит сессию, к которой ретрай и возвращался

Открыто — и намеренно не решается здесь

  • Сами числа. У каждого бюджета на этой странице есть значение по умолчанию, и каждое из них переопределяется в RuntimeProfile. Умолчания выбраны так, чтобы не противоречить таблице профилей в Deploy, и они не измерения — ничего ещё не запускалось. Их надо вывести заново из первых настоящих сессий.
  • Всё, что касается инфраструктуры. Кластер, нодпулы и реестр образов — решения владельца, и здесь их не трогают; см. Открытые вопросы. Эта страница — формат провода, и он одинаково держится и на локальном драйвере песочницы, и на Kubernetes.
  • Что канал несёт помимо жизненного цикла. Семантика tools/list, tools/call, прогресса и resource_link остаётся в Use. Разложить их по двум страницам — это способ сделать контракт нечитаемым за один присест.

См. также

  • Deploy — Job, профили, чеклист безопасности.
  • Use — три канала, рабочие кадры, разделение тулов.
  • Destroy — состояния сессии, порядок гашения, жёсткие потолки.
  • Implementation — build-промпт, который получают обе половины.
  • Принятые решения — пункт про канал инструментов, принятый до этой страницы.