Канал инструментов
Контракт провода между мозгом и песочницей: кто кому дозванивается, четыре кадра жизненного цикла, что считается ответом на каждый из них, что происходит, когда ответа нет вовсе, и что делают стороны, когда выясняется, что они говорят на разных версиях.
Эта страница написана один раз — намеренно
Здесь встречаются две фичи. specs/006-runtime-pipeline (T000) провижинит песочницу; specs/007-worker-sandbox (T001, T002) — то, что внутри неё исполняется. Оба списка задач указывают на этот файл и ни один его не пересказывает. Контракт, написанный дважды, разъезжается — и разъезжается молча: сторона, которая ждёт кадр, зависает вместо того чтобы отказать, а зависание диагностируют часами там, где отказ диагностируют за секунды.
Жизненный цикл: Deploy → Use → этот канал → Destroy.
Что здесь — и что намеренно не здесь
Страница описывает только канал 3 (канал инструментов mind↔worker), и внутри него — только жизненный цикл: соединение, рукопожатие, живость, гашение, версии.
| Здесь | Не здесь |
|---|---|
| Направление соединения, фрейминг и аутентификация | Пользовательский чат (канал 1) и очередь провижининга (канал 2) — Use → протокол |
initialize · ready · heartbeat · release | tools/list · tools/call · notifications/progress · resource_link — Use → на проводе |
| Соответствие идентификаторов и правило одного соединения | Какой тул где исполняется — 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 / WebSocket | MCP | |
|---|---|---|
| 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 в результате initialize | 0 на первом дозвоне, +1 на каждом повторном у этого процесса |
- Тот же
instanceId,attemptбольше → принято. Хост закрывает старый сокет с4009superseded и привязывает сессию к новому. Ничего не теряется: липкое состояние живёт в процессе, а процесс не умирал. Хост заново прогоняетtools/listи заменяет зарегистрированный набор тулов, а не добавляет второй. - Тот же
instanceId,attemptне больше → отказ4002. Опоздавший протухший ретрай не должен вытеснять более новое соединение. - Другой
instanceId→ отказ4002. Это второй процесс с токеном этой сессии — Job, породивший два пода, или переигранный токен, — и ради этого случая правило и существует.
Если worker всё-таки обнаружит у себя два открытых сокета, авторитетен его собственный последний успешный initialize, и он сам закрывает старый, а не обслуживает вызовы в оба.
Четыре кадра
Всё, что есть в канале кроме этих четырёх, — это работа, и работа описана в Use. А эти четыре — то, благодаря чему канал возникает, остаётся честным и заканчивается.
| Кадр | Направление | Тип | Ответ | Если ответа нет |
|---|---|---|---|---|
initialize | host → worker | request | результат initialize | worker ретраит, затем выходит с 75; хост валит сессию |
agentfy/ready | worker → host | notification | tools/list от хоста | worker ретраит, затем выходит с 75; хост валит сессию |
agentfy/heartbeat | worker → host | request | результат heartbeat | worker ретраит, затем выходит с 75; хост жнёт сессию |
agentfy/release | host → worker | request | результат 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; см. расхождения.
// 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.// 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, следующий раздел.) По истечении: close4003, сессия →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 реально разрезолвил, — так расхождение о наборе тулов всплывает до первого вызова, а не в виде пропавшего тула позже.
// 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.
// 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'а, мозг решил, что работа сделана, упёрлись в жёсткий потолок или сессия свалилась.
// 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 |
| Worker | release не пришёл вовсе, потому что хост исчез | его гасят собственные таймеры 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), гашение, без ретрая |
приходит initialize | worker | handshakeSeconds · 10 с | открытия сокета | close → окно переподключения → выход 75 |
| MCP-рукопожатие завершено | хост | 2 × handshakeSeconds · 20 с | открытия сокета | close 4003, сессия → failed (handshake_timeout), без ретрая |
сессия дошла до running | хост | readySeconds · 30 с | notifications/initialized | close 4003, сессия → failed (ready_timeout), без ретрая |
приходит tools/list | worker | readyAckSeconds · 15 с | отправки agentfy/ready | close → окно переподключения → выход 75 |
| на heartbeat ответили | worker | heartbeatGraceSeconds · 30 с | отправки heartbeat | окно переподключения → выход 75 |
| приходит heartbeat | хост | heartbeatGraceSeconds + heartbeatIntervalSeconds · 40 с | предыдущего heartbeat | close 4004, сессия → failed, гашение |
на agentfy/release ответили | хост | releaseGraceSeconds · 30 с | отправки release | удалить песочницу всё равно, записать outputsFlushed: false |
| потерянный канал вернулся | worker | reconnectWindowSeconds · 60 с | момента, когда канал признан потерянным | выгрузить что можно, выход 75 |
| песочница перестала работать | обе | idleSeconds · 90 с | момента, когда завершился последний tools/call | reaper отпускает сессию |
| песочница работает слишком долго | обе | 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, эта граница израсходована, и дальше правит окно переподключения.
Три ограничения, которые обязан утверждать каталог профилей, потому что эта страница не может:
idleSeconds < maxExecSeconds— иначе k8s убивает тёплую сессию посреди обещанного ей окна простоя. См. расхождение 6.dialSeconds > 0, со значением, взятым из образа самого профиля, а не из умолчания Light.activeDeadlineSeconds ≥ dialSeconds + maxExecSeconds. Kubernetes считаетactiveDeadlineSecondsот старта Job, то есть включая весь холодный старт;maxExecSecondsсчитается от старта процесса. Иллюстративный манифест в Deploy приравнивает их — это было верно, пока бюджет был один, и неверно теперь, когда у холодного старта свой. Оставить их равными — значит убивать Browser-сессию примерно на время pull'а и запуска раньше срока, причём убивать необъяснённым обрывом соединения, то есть ровно тем отказом, который эта страница старательнее всего вычищает.
Коды выхода
Код выхода worker'а говорит, какого рода был отказ, а значит — стоит ли кому-то ретраить.
| Выход | Имя | Значит | Откуда попадают |
|---|---|---|---|
0 | — | отпущен штатно | принятый agentfy/release, close 4005 |
75 | EX_TEMPFAIL | control-плоскость замолчала и не вернулась | любой дедлайн молчания выше, после истечения окна переподключения; close 4004 |
77 | EX_NOPERM | токен или сессия отклонены | close 4002 |
78 | EX_CONFIG | ретрай ничего не изменит: стороны не могут договориться о версии или формате кадров, либо песочница не успела подняться | HTTP 426, close 4001, 4003, 4006 |
Молчание — это 75, несогласие — это 78. Различие и есть весь смысл: 75 говорит попробуй ещё раз, и вполне может получиться, 78 говорит ничего не изменится, пока кто-нибудь не выкатит что-то другое. Страница, отвечающая на «хост замолчал» кодом EX_CONFIG, отправила бы оператора искать рассинхрон версий, которого нет.
Потеря соединения
Хост не может дозвониться в песочницу, поэтому переподключается только worker — и только в пределах ограничения.
- Сокет отваливается, либо срабатывает собственный grace-таймер worker'а.
- Worker переподключается к
CONTROL_URLс экспоненциальным backoff'ом (1 с, 2 с, 4 с… с потолком 10 с) не дольшеreconnectWindowSeconds. Исполнители продолжают работать, липкое состояние не трогают — процесс не умирал, поэтому cwd, окружение, фоновые процессы и контекст браузера живы. - Каждый ретрай везёт
instanceIdпроцесса и увеличенныйattempt. Успешное переподключение — это свежийinitialize, привязанный к тому жеsessionId, и хост даёт ему вытеснить старый канал по правилу одного соединения, закрывая старый сокет с4009, а не отклоняя новый. Именно этот шаг делает окно настоящим: worker бросает полуоткрытый сокет на один интервал раньше хоста, поэтому его первый ретрай неизбежно приходит, пока хост ещё считает старый канал живым. Плоское «одно соединение на сессию» отклонило бы ровно этот ретрай, окончательно, и этот раздел не отработал бы никогда. - Окно истекло → worker выгружает что успел и выходит с
75(EX_TEMPFAIL).
Код закрытия решает, разрешено ли вообще переподключаться.
| Close | Что значит | Worker переподключается? |
|---|---|---|
4001 | версия канала или MCP не поддержана | нет — exit 78 |
4002 | несовпадение сессии, невалидный или истёкший токен, дубль соединения | нет — exit 77 |
4003 | пропущен дедлайн рукопожатия | нет — exit 78 |
4004 | потерян heartbeat (со стороны хоста) | нет — exit 75 |
4005 | released, штатно | нет — exit 0 |
4006 | слишком много битых кадров | нет — exit 78 |
4008 | хост уходит в drain (катится реплика api) | да, в пределах окна |
4009 | superseded — этот сокет заменён более новым дозвоном того же процесса | нет, и ничего не упало: новый канал уже обслуживает |
| любой обрыв на уровне транспорта | неизвестно | да, в пределах окна |
Катящийся деплой 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. |
| Версия протокола MCP | protocolVersion в initialize и в результате | Worker отвечает ближайшей версией, которую поддерживает. Если хост на ней говорить не умеет, он закрывает соединение с 4001, неся оба значения. Никакого молчаливого даунгрейда — сторона, тихо притворившаяся, что говорит на версии, которой не знает, и есть способ потерять поле и не дождаться кадра. |
| Минор канала | _meta.agentfy.channelVersion, semver, в обе стороны | Разрешён внутри мажора. Действующая версия — меньшая из двух, и её считают обе стороны — см. ниже. Дальше каждая сторона пользуется только фичами не выше неё. |
Действующий минор никто не объявляет, потому что и не может
Очевидная формулировка — «хост объявляет действующую версию в результате initialize» — в этом канале неверна, и неверна поучительно. Результат initialize шлёт worker, а не хост; кадра, в котором хост мог бы что-то объявить между результатом и tools/list, не существует. Поэтому правило — арифметика, а не объявление:
- Запрос
initializeот хоста несётchannelVersionхоста. Результат worker'а несёт его собственный. - После результата обе стороны держат оба числа, и обе считают
min(host, worker). Никому ничего сообщать не надо. - Каждый
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 — часть этого контракта: отказ, который не может сматчить машина, не сматчит и человек под давлением.
| Код | Имя | Кто шлёт | Когда |
|---|---|---|---|
-32001 | CHANNEL_VERSION_MISMATCH | любая сторона | кадр или поле принадлежит версии, которую получатель не согласовывал |
-32002 | SESSION_MISMATCH | хост | sessionId worker'а не равен sub токена |
-32003 | SAMPLING_DISABLED | хост | любой запрос sampling/* |
-32004 | CAPABILITY_NOT_NEGOTIATED | хост | elicitation/*, roots/* или что угодно необъявленное |
-32005 | SESSION_NOT_RUNNING | worker | tools/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-промпт, который получают обе половины.
- Принятые решения — пункт про канал инструментов, принятый до этой страницы.