Канал інструментів
Контракт дроту між мозком і пісочницею: хто кому дзвонить, чотири кадри життєвого циклу, що вважається відповіддю на кожен із них, що відбувається, коли відповіді немає взагалі, і що роблять сторони, коли з'ясовується, що вони говорять різними версіями.
Ця сторінка написана один раз — навмисно
Тут зустрічаються дві фічі. 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 і 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, який отримують обидві половини.
- Ухвалені рішення — пункт про канал інструментів, ухвалений до цієї сторінки.