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 і storage-scope. Єдине місце, де показано справжній маніфест — Deploy, — називає перше, друге і четверте, не має storage-scope взагалі і дістається токена через безіменний 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-prompt, який отримують обидві половини.
  • Ухвалені рішення — пункт про канал інструментів, ухвалений до цієї сторінки.