Daemon ACP-over-HTTP → Официальный транспорт ACP Streamable HTTP
Целевая ветка:
daemon_mode_b_main. Ветка:feat/daemon-acp-http-streamable. Автор: arnoo.gao. Дата: 2026-05-24. Статус: Design v1 → implementation. Работа по принципу «сначала дизайн»: этот документ появляется до или вместе с PR реализации, чтобы контракт по протоколу был проверяем.
0. TL;DR
В настоящее время демон (qwen serve) общается с веб/SDK-клиентами на собственном диалекте REST + SSE, а с порождённым процессом qwen --acp — на настоящем ACP JSON-RPC через stdio. Это предложение добавляет второй северный транспорт, реализующий официальный транспорт ACP Streamable HTTP (RFD #721) на единой конечной точке /acp, так что любой ACP-нативный клиент (Zed, Goose, будущие SDK) может управлять демоном напрямую по стандартному протоколу — без знания qwen-специфичного REST.
Решение: два транспорта одновременно, аддитивно. Новая конечная точка /acp монтируется рядом с существующим REST-интерфейсом, используя под капотом тот же HttpAcpBridge + EventBus. REST API не удаляется. Обоснование в §6.
Решение: пространство имён расширений = _qwen/… (префикс с одним подчёркиванием, зарезервированная форма для кастомных методов по спецификации ACP) для функций демона, у которых нет стандартного метода ACP (переключение модели, интроспекция рабочей области, heartbeat, политика разрешений для нескольких клиентов, настройка противодавления SSE). Обоснование в §5.
В этот PR входит полная, локально запускаемая эталонная реализация (packages/cli/src/serve/acp-http/) и проверочный харнесс (scripts/acp-http-smoke.mjs).
1. Предыстория — что значит «ACP через HTTP» сегодня
Три уровня (проверено на коммите 0c0430939):
┌──────────────┐ собственный REST + SSE (HTTP/1.1) ┌────────────┐ ACP JSON-RPC ┌──────────────┐
│ веб / SDK │ ───────────────────────────────► │ qwen │ (stdio NDJSON) │ qwen --acp │
│ клиент │ ◄─── GET /session/:id/events ──── │ serve │ ◄─────────────► │ дочерний │
│ (ACP-клиент) │ (text/event-stream) │ (демон) │ ndJsonStream │ (Agent) │
└──────────────┘ └────────────┘ └──────────────┘
северный: НЕ протокол ACP мост южный: настоящий ACP1.1 Северный (клиент ↔ демон) — собственный, сегодня
- Express 5 приложение в
packages/cli/src/serve/server.ts(~30 маршрутов). - Отдельные REST-глаголы, не JSON-RPC:
POST /session(создать),POST /session/:id/prompt,POST /session/:id/cancel,POST /session/:id/load|resume,POST /session/:id/model,POST /session/:id/permission/:requestId,POST /session/:id/heartbeat,DELETE /session/:id, а также/workspace/*,/capabilities,/health.
- Потоковая передача сервер→клиент:
GET /session/:id/events→text/event-stream.- Фреймы:
id: <n>\nevent: <type>\ndata: <json>\n\n(server.ts:formatSseFrame, ~2626). - Монотонный
idна сессию + возобновление черезLast-Event-IDс поддержкой кольцевого буфераEventBus(acp-bridge/src/eventBus.ts). - Типы событий:
session_update,client_evicted,slow_client_warning,state_resync_required,stream_error, …
- Фреймы:
- Аутентификация:
Authorization: Bearer <token>(serve/auth.ts), CORS deny + белый список хостов. - Противодавление: последовательная цепочка записи на соединение + комментарии о heartbeat каждые 15 с.
1.2 Южный (демон ↔ дочерний) — уже ACP
acp-bridge/src/spawnChannel.tsзапускаетqwen --acp, оборачивает stdin/stdout с помощьюndJsonStreamиз@agentclientprotocol/sdk(^0.14.1).acp-bridge/src/bridge.ts:729new ClientSideConnection(() => client, channel.stream)— демон является ACP клиентом, дочерний — ACP агентом.- На этом плече уже используются методы расширения:
unstable_setSessionModel,unstable_resumeSession,unstable_listSessions(acp-integration/acpAgent.ts).
1.3 Зачем мигрировать северный
- Каждый клиент (webui, TS SDK, Java SDK, Python SDK, VSCode companion) перереализует собственное REST-отображение. Стандартная конечная точка ACP позволит ACP-нативным редакторам подключаться без qwen-специфичной склейки.
- Приводит внешнюю поверхность демона в соответствие с протоколом, который он уже использует внутри.
2. Цель: ACP Streamable HTTP (RFD #721)
Слитый черновик RFD (agentclientprotocol/agent-client-protocol#721, слит 2026-04-22).
Ещё не нормативный; ещё не включён ни в один SDK. Мы реализуем по дизайну протокола из RFD.
2.1 Конечная точка и глаголы (единый /acp)
| Глагол | Поведение |
|---|---|
POST /acp | Отправить JSON-RPC. initialize → 200 + тело JSON (возможности) и устанавливает Acp-Connection-Id. Все остальные запросы/уведомления → 202 Accepted, пустое тело; ответ (если есть) доставляется по соответствующему долгоживущему SSE-потоку. |
GET /acp | Открыть долгоживущий SSE-поток. (Upgrade: websocket → WebSocket; отложено, см. §7.) |
DELETE /acp | Завершить соединение → 202. |
2.2 Двухуровневые долгоживущие потоки
- Поток уровня соединения:
GET /acpс заголовкомAcp-Connection-Id, без заголовка сессии. Передаёт ответы уровня соединения (session/new,session/load,authenticate) и уведомления уровня соединения. - Поток уровня сессии:
GET /acpсAcp-Connection-IdиAcp-Session-Id. Передаёт уведомленияsession/update, запросы от агента к клиенту (session/request_permission,fs/read_text_file, …) и ответы на POST сессии (session/prompt,session/cancel).
2.3 Идентичность (3 уровня)
Acp-Connection-Id(HTTP-заголовок) — привязка к транспорту, создаётся приinitialize.Acp-Session-Id(HTTP-заголовок) — обязателен для GET уровня сессии и POST сессии.sessionId(параметр JSON-RPC) — внутри параметров метода (должен совпадать с заголовком).
2.4 Отличия от MCP StreamableHTTP
ACP использует долгоживущие потоки (не SSE на запрос), два ID-заголовка (соединение
vs сессия), 202-для-не-initialize, обязательный HTTP/2, обязательный WebSocket-со стороны клиента. Мы заимствуем скелет с единой конечной точкой + POST/GET-SSE + заголовок сессии, но адаптируем под долгоживущую двух-ID модель. Мы не используем @modelcontextprotocol/sdk’s
StreamableHTTPServerTransport (его модель потока на запрос и единый Mcp-Session-Id не подходят).
2.5 Стандартные методы (подтверждены текущей схемой)
- Запросы клиент→агент:
initialize,authenticate,session/new,session/load,session/prompt,session/resume,session/close,session/list,session/set_mode,session/set_config_option,logout. - Уведомление клиент→агент:
session/cancel. - Запросы агент→клиент:
fs/read_text_file,fs/write_text_file,session/request_permission,terminal/create|output|wait_for_exit|kill|release. - Уведомление агент→клиент:
session/update.
3. Архитектура нового транспорта
Демон должен предоставлять поверхность ACP-агента через HTTP на север, оставаясь ACP
клиентом по отношению к дочернему процессу на юге. Слой /acp поэтому является
маршрутизатором JSON-RPC, который завершает HTTP-транспорт и мостится в существующий
HttpAcpBridge.
POST /acp (JSON-RPC запросы/ответы/уведомления)
клиент ──────────────────────────────────────────────► ┌───────────────────────────┐
(редактор) │ AcpHttpTransport │
◄── GET /acp (SSE уровня соединения) ───────── │ - реестр соединений │
◄── GET /acp (SSE уровня сессии) ───────────── │ - корреляция ID JSON-RPC│
│ - диспетчеризация методов│
└────────────┬──────────────┘
│ использует
┌────────────▼──────────────┐
│ HttpAcpBridge + EventBus │ (без изменений)
└────────────┬──────────────┘
│ ACP stdio (без изменений)
qwen --acp дочерний процесс3.1 Новая структура модуля (packages/cli/src/serve/acp-http/)
| Файл | Ответственность |
|---|---|
index.ts | mountAcpHttp(app, bridge, opts) — регистрирует маршруты /acp на существующем Express-приложении. |
connection-registry.ts | Acp-Connection-Id → AcpConnection (писатель SSE соединения, Map<sessionId, SessionStream>, ожидающие запросы агент→клиент по JSON-RPC id, распределитель монотонных id). TTL + очистка при DELETE. |
json-rpc.ts | Хелперы для разбора/проверки/сериализации JSON-RPC 2.0; коды ошибок (-32600 и т.д.); защита пространства имён _qwen/. |
dispatch.ts | Сопоставляет входящие методы JSON-RPC → вызовы HttpAcpBridge. Сопоставляет BridgeEvent’ы → исходящие JSON-RPC фреймы. Таблица трансляции (§4). |
sse-stream.ts | Писатель долгоживущего SSE (переиспользует паттерн противодавления/heartbeat из server.ts). Отличается от REST /events (другая структура фреймов: полные JSON-RPC объекты, а не конверты событий qwen). |
Без изменений в bridge.ts / eventBus.ts (только аддитивный потребитель).
3.2 Жизненный цикл соединения и сессии
POST /acp {initialize}→ создатьconnectionId, создатьAcpConnection, ответить200с{protocolVersion, agentCapabilities, _meta:{qwen:{…}}}+ заголовокAcp-Connection-Id.- Клиент открывает
GET /acp(уровня соединения) сAcp-Connection-Id. POST /acp {session/new}→202; демон вызываетbridge.createSession(...); отправляет JSON-RPC ответ (сsessionId) по потоку соединения.- Клиент открывает
GET /acp(уровня сессии) сAcp-Connection-Id+Acp-Session-Id; демон вызываетbridge.subscribeEvents(sessionId)и передаёт трансформированные фреймы. POST /acp {session/prompt}→202;bridge.sendPrompt(...); уведомленияsession/updateпередаются в реальном времени по потоку сессии; финальный ответ на prompt ({id, result:{stopReason}}) отправляется по потоку сессии, когда он завершится.- Запрос агент→клиент (например,
session/request_permission) выдаётся как JSON-RPC запрос по потоку сессии с id, выделенным демоном; клиент отвечает черезPOST /acp {id, result};dispatchразрешает его через API разрешений моста. DELETE /acp(или закрытие потока соединения + TTL) уничтожает сессии/подписки.
4. Таблица трансляции (мост ⇄ ACP/HTTP)
4.1 Входящие (клиент POST → мост)
| ACP метод | Вызов моста | Ответ направляется на |
|---|---|---|
initialize | (нет; возможности из capabilities.ts) | inline 200 |
authenticate | существующий провайдер аутентификации (serve/auth/*) | поток соединения |
session/new | bridge.createSession | поток соединения |
session/load / session/resume | `bridge.restoreSession(‘load' | 'resume’)` |
session/prompt | bridge.sendPrompt | поток сессии (отложен до завершения) |
session/cancel (уведомление) | bridge.cancel | — |
session/list | bridge.listSessions (unstable_listSessions) | поток соединения |
session/set_mode | логика маршрута режима утверждения | поток сессии |
| JSON-RPC ответ (на запрос агент→клиент) | разрешить ожидающий (§4.3) | — |
_qwen/session/set_model | bridge.setSessionModel (unstable_setSessionModel) | поток сессии |
_qwen/workspace/list и т.д. | маршруты интроспекции рабочей области | поток соединения |
_qwen/session/heartbeat | bridge.heartbeat | поток соединения |
4.2 Исходящие (BridgeEvent → JSON-RPC по потоку сессии)
| BridgeEvent.type | Отправляется как |
|---|---|
session_update | уведомление {method:"session/update", params:<data>} |
| запрос разрешения | запрос {id:<n>, method:"session/request_permission", params} |
client_evicted / slow_client_warning / state_resync_required | уведомление {method:"_qwen/notify", params:{kind,…}} |
stream_error | JSON-RPC ответ об ошибке на активный id prompt (или _qwen/notify) |
| завершение prompt | {id:<promptId>, result:{stopReason}} |
4.3 Ожидающие запросы агент→клиент
AcpConnection хранит Map<jsonRpcId, {sessionId, kind, bridgeRequestId, resolve}>.
Когда клиент отправляет POST с JSON-RPC объектом ответа, dispatch сопоставляет id, затем вызывает
путь разрешения моста (например, внутренний эквивалент POST /session/:id/permission/:requestId).
Статус v1: реализован только цикл запрос-ответ
session/request_permissionагент→клиент. Пересылкаfs/*иterminal/*от агента к клиенту отложена (§7) — демон ещё не объявляет согласование клиентских возможностейfs/terminalна/acp, поэтому в v1 ACP-клиенты не должны рассчитывать на семантику файловой системы/терминала через этот транспорт. Целевое конечное состояние (пересылатьfs/*клиенту; при отсутствии возможностиfsу клиента — использовать файловую систему рабочей области демона) — это последующее улучшение, описанное в §7.
5. Стратегия расширений (требование #2)
ACP резервирует любые методы, начинающиеся с _, для кастомных расширений, и предоставляет _meta
для каждого типа. На южном плече кодовой базы уже используются имена методов unstable_*.
Выбор для северного: имена методов с вендорным пространством имён _qwen/<область>/<глагол>
(соответствующий спецификации префикс _). Возможности объявляются в
agentCapabilities._meta.qwen при initialize, чтобы клиенты могли определить функции до использования.
| Потребность | Стандартный метод ACP отсутствует? | Расширение |
|---|---|---|
| Переключение модели | да | _qwen/session/set_model |
| Интроспекция рабочей области (MCP/скиллы/провайдеры/окружение) | да | _qwen/workspace/list, _qwen/workspace/<область> |
| Heartbeat / время последней активности | да | _qwen/session/heartbeat |
| Политика разрешений для нескольких клиентов (консенсус/назначение) | частично | session/request_permission + _meta.qwen.policy |
Настройка противодавления SSE (maxQueued) | да | Заголовок Acp-Qwen-Max-Queued на GET сессии |
Курсор возобновления (кольцевой Last-Event-ID) | RFD Фаза 4 | Заголовок Last-Event-ID + _meta.qwen.eventId на фреймах |
Стандартные методы никогда не переименовываются; расширения строго аддитивны и игнорируемы.
6. Два транспорта или замена (требование #4)
Решение: два транспорта (аддитивно).
- Официальный транспорт — это черновик RFD, ещё не нормативный и отсутствующий во всех SDK — полная замена привязала бы нас к неутверждённому дизайну и сломала бы webui + 3 SDK + VSCode companion одновременно.
- REST-поверхность содержит функции, для которых пока нет чистого отображения на ACP (интроспекция
рабочей области, посредничество разрешений для нескольких клиентов, возобновление с кольцевым буфером,
реестр возможностей). Они деградируют до расширений
_qwen/*на/acp, но REST-поверхность остаётся авторитетной до ратификации RFD. - Оба транспорта используют один экземпляр
HttpAcpBridge+EventBus, поэтому нет дублирования состояния —/acpи/session/*могут даже управлять одной и той же активной сессией одновременно (множественные клиенты уже поддерживаются мостом). - Переключатель (v1, поставляется): включён по умолчанию;
QWEN_SERVE_ACP_HTTP=0отключает монтирование. Флаг CLI--no-acp-httpи тегacp_httpв/capabilitiesдля определения клиентом отложены до последующего обновления (не в v1) — до тех пор клиенты определяют транспорт, пробуяPOST /acp {initialize}.
Путь миграции: после ратификации RFD и выхода SDK REST-маршруты могут быть переосмыслены как
тонкая прослойка совместимости поверх /acp (отдельный, более поздний PR).
7. Объём PR реализации
Входит в объём (работоспособно + проверено локально):
- Диспетчеризация
POST /acpдляinitialize,session/new,session/prompt,session/cancel,session/load, обработка JSON-RPC ответов. - SSE потоки уровня соединения и уровня сессии через
GET /acpс фреймингом JSON-RPC. - Потоковая передача
session/update+ корреляция финального ответа на prompt. - Цикл запрос-ответ
session/request_permissionагент→клиент. - Расширение
_qwen/session/set_modelкак рабочий пример требования #2. - Переиспользование Bearer-аутентификации + белого списка хостов (те же middleware, что и REST).
- Модульные тесты (
acp-http/*.test.ts) + скрипт чёрного ящика, управляющий реальным демоном.
Отложено (задокументировано, не реализовано сейчас):
- Путь обновления до WebSocket (требование клиента по RFD; SSE достаточно для локальной проверки).
- Мультиплексирование HTTP/2 (мы работаем на HTTP/1.1; POST и долгоживущие GET используют отдельные сокеты, что работает для CLI/Node клиентов и браузеров с ≤6 соединениями). Задокументированное отличие.
- Полная пересылка
fs/*+terminal/*от агента к клиенту (путь разрешения доказывает механизм; остальное — механическое последующее улучшение). - Доведение устойчивости возобновления SSE до паритета с кольцевым буфером (Фаза 4 в RFD).
8. План локальной проверки
npm run build(или сборка воркспейсаcli+acp-bridge).- Запустите демон:
qwen serve --listen 127.0.0.1:0 --token <t>(или токен из переменной окружения). - Запустите
node scripts/acp-http-smoke.mjs:POST /acp {initialize}→ проверить200+Acp-Connection-Id.- Открыть SSE-соединение;
POST {session/new}→ проверить ответ в потоке. - Открыть SSE сессии;
POST {session/prompt:"say hi"}→ проверить ≥1session/update, затем финальный{result:{stopReason}}. - Вызвать инструмент, требующий разрешения → проверить запрос
session/request_permission, отправить POST с разрешением → проверить завершение промпта. POST {_qwen/session/set_model}→ проверить смену модели +session/update.
- Vitest:
acp-http/*.test.tsзелёные.
9. Риски
| Риск | Смягчение |
|---|---|
| Изменения RFD до ратификации | За флагом возможности + пространством имён _qwen; изолированный модуль; легко пересмотреть. |
| HTTP/1.1 vs. требуемый HTTP/2 | Локальные/CLI-клиенты не затронуты; задокументировано; h2 можно заменить транспортом позже. |
| Гонка двух транспортов на одном мосту | Мост уже поддерживает нескольких клиентов; используем его блокировку повторно. |
Проброс fs/* vs. локальная ФС демона | Ограничено возможностью: пробрасывать, когда клиент объявляет fs, иначе локально. |
10. Журнал реализации и проверки (v1)
Реализовано в packages/cli/src/serve/acp-http/ (json-rpc.ts, sse-stream.ts,
connection-registry.ts, dispatch.ts, index.ts), смонтировано из server.ts
через mountAcpHttp(app, bridge, { boundWorkspace }).
Автоматизированные тесты (packages/cli/src/serve/acp-http/*.test.ts)
transport.test.ts запускает реальный Express-сервер + реальный mountAcpHttp над
контролируемым фейковым мостом и управляет им через fetch + ручной разбор SSE.
15 тестов зелёные, покрывают: initialize 200 + Acp-Connection-Id; неизвестное соединение 400;
ответ session/new в потоке соединения; промпт → поток session/update + корреляция финального результата;
круговорот session/request_permission агент→клиент→агент; _qwen/session/set_model;
метод не найден; DELETE для завершения.
Живой демон (реальная модель)
Запущен qwen serve --port 8767 --token … --workspace … (точка входа в бандл, чтобы
запущенный дочерний процесс qwen --acp был самодостаточным) и выполнен scripts/acp-http-smoke.mjs:
✓ initialize: connectionId=… protocolVersion=1
✓ session/new: sessionId=…
→ prompt: "Reply with the single word: pong"
pong
✓ prompt complete: 10 session/update frames, stopReason=end_turn
✓ DELETE /acp — connection closed
ALL CHECKS PASSED ✅Путь ошибки также подтверждён вживую: когда дочерний процесс не запускался,
тайм-аут моста отображался для клиента как JSON-RPC-кадр ошибки в потоке соединения
({"id":2,"error":{"code":-32603,…}}), что доказывает корреляцию id + разделение 202/SSE при сбое.
Исправление по результатам ревью — clientId, выданный мостом (обнаружено при живой проверке)
Первый живой запуск упал на session/prompt с “client id … is not registered for
session”. Коренная причина: spawnOrAttach/loadSession игнорируют clientId,
предоставленный вызывающей стороной, который мост никогда не выдавал, и ставят новый
(возвращается в BridgeSession.clientId); диспетчер эхом возвращал собственный
(незарегистрированный) id соединения на sendPrompt. Исправление: сохранять выданный мостом
id в SessionBinding и возвращать его при каждом вызове для данной сессии (sessionCtx).
Повторно проверено — зелёный.
11. Ревью раунд 2 — исправления
Два независимых ревью (корректность/конкурентность + соответствие протоколу/безопасность) плюс самопроверка.
Все исправления проверены расширенным набором vitest (18 тестов) + свежий живой прогон
(21 кадр session/update → stopReason=end_turn).
| # | Важность | Находка | Исправление |
|---|---|---|---|
| R1 | P0 | Переподключение потока сессии было навсегда мёртвым: SessionBinding.abort создавался один раз и переиспользовался; при закрытии потока он навсегда прерывался, поэтому при повторном подключении subscribeEvents(signal) получал уже прерванный сигнал и не получал никаких событий. | attachSessionStream теперь устанавливает новый AbortController на каждый поток (и закрывает предыдущий поток); index.ts работает с этим новым сигналом. |
| R2 | P0 | await dispatcher.handle() выполнялся после res.end(202); выбрасывающий вызов моста (в частности, не обработанный try/catch путь isResponse) приводил к отклонению промиса и мог вызвать падение демона. | Обернул путь isResponse в try/catch; добавил .catch() на ожидаемый handle(...) и на pumpSessionEvents(...). |
| R3 | P1 | Нет привязки соединения к сессии: любой аутентифицированный клиент мог открыть SSE сессии или отправить промпт для любого sessionId в воркспейсе (чтение/подслушивание; промпт блокировался лишь случайно из-за ошибки незарегистрированного clientId). | AcpConnection.ownedSessions заполняется при session/new/load/resume; поток сессии возвращает 403, а POST для сессии возвращают INVALID_PARAMS для не принадлежащих id (requireOwned). |
| R4 | P1 | mountAcpHttp handle не сохранялся → утечка таймера TTL-очистки и живых SSE-потоков при завершении. | Handle сохранён в app.locals; хук закрытия runQwenServe вызывает dispose() перед bridge.shutdown() (аналогично регистру device-flow). |
| R5 | P1 | Утечка ожидающих разрешений: закрытие сессии/соединения с ожидающим разрешением оставляло мост заблокированным в ожидании голоса. | closeSessionStream/destroy отменяют соответствующие ожидающие запросы через внедрённый onAbandonPending → cancelAbandonedPermission. |
| R6 | P1 | Буферы кадров перед присоединением (connBuffer/binding.buffer) были неограниченны. | Ограничены 256 кадрами (удаление самых старых), аналогично maxQueued в EventBus. |
| R7 | P2 | initialize игнорировал запрошенный клиентом protocolVersion. | Выполняется согласование min(requested, 1). |
| R8 | P2 | Нет перекрёстной проверки Acp-Session-Id ↔ params.sessionId (RFD §2.3). | POST проверяет их совпадение; несовпадение → INVALID_PARAMS. |
| R9 | P2 | session/cancel в форме запроса (с id) никогда не получал ответа; дублирующийся _meta.qwen на верхнем уровне. | Ответ отправляется, когда id присутствует; один agentCapabilities._meta.qwen. |
Принято / задокументировано (не исправлено в v1)
- Порядок результат-промпт vs. завершающий
session/update(P2):handlePromptожидаетsendPrompt, затем записывает кадр результата, в то время как обновления приходят параллельно. На практике мост публикует всеsession/updateв шину до того, какsendPromptзавершится, и оба используют одну упорядоченную цепочку записи SSE, поэтому результат оказывается последним (подтверждено: 21 обновление, затем результат). Строгий барьер — возможное ужесточение позже, если редьюсер клиента окажется чувствительным. - Браузерный
EventSourceне может установитьAuthorization— GET-потоки/acpтребуют bearer-заголовок, поэтому браузерам нужен отложенный путь WebSocket (§7); CLI/Node-клиенты не затронуты. - Реальная граница доверия демона остаётся bearer-токен + привязка к одному воркспейсу (как и у REST-поверхности); проверка владения из R3 — защита в глубину + корректность контракта, а не граница арендатора.
12. Ревью раунд 3 — исправления от PR-ботов (#4472)
Два автоматических ревьюера PR плюс бот-резюме.
Все исправления проверены набором тестов (теперь 22 теста) + свежий живой прогон (16 session/update → end_turn).
| # | Важность | Находка | Исправление |
|---|---|---|---|
| B1 | P0 | AbortController в handlePrompt никогда не прерывался — отключающийся/отменяющий клиент оставлял агента работающим (сжигалась квота модели, блокировалась FIFO сессий). Обнаружено обоими ботами + 5 под-агентами. | promptAbort сохранён в SessionBinding; прерывается по session/cancel и при разрушении сессии/соединения (closeSessionStream/destroy). |
| B2 | P0 | В sessionCtx отсутствовал fromLoopback → каждый голос разрешения ACP считался удалённым; политика local-only отклоняла бы loopback-клиентов. | Захват loopback при initialize (ядровый remoteAddress, не подделываемые заголовки) → AcpConnection.fromLoopback → пробрасывается через sessionCtx. |
| B3 | P0 | Ошибки записи в SSE молча проглатывались → потоки-зомби (heartbeat срабатывают, события не доставляются, нет логов). | Первая ошибка записи логируется + закрывает поток. |
| B4 | P0 | Очистка по таймеру уничтожала соединения без логирования и без ограничения на количество соединений (флуд инициализациями). | Очистка логирует каждое удаление; pumpSessionEvents вызывает touch() (долгие молчащие промпты не удаляются); ограничение maxConnections (64) → 503. |
| B5 | P1 | sessionCtx молча возвращался к незарегистрированному clientId соединения, когда у привязки его не было (не тестировалось, в FakeBridge всегда срабатывало). | Бросок при отсутствии выданного clientId (нарушение инварианта); FakeBridge теперь выдаёт один. |
| B6 | P1 | `session/new | load |
| B7 | P1 | session/prompt передавал непроверенный prompt мосту. | validatePrompt (непустой массив объектов), аналогично REST. |
| B8 | P1 | Сырые сообщения об ошибках моста передавались клиенту. | toRpcError сопоставляет известные ошибки моста кодированным, безопасным для клиента формам; неизвестные → общий Internal error (полные данные всё равно в stderr). |
| B9 | P1 | nextId использовал последовательные отрицательные числа — клиент, легально использующий отрицательные id, мог вызвать коллизию в pending. | Id, исходящие от демона, теперь строки (_qwen_perm_N), не пересекающиеся с любыми клиентскими id. |
| B10 | P2 | Тип параметра resolveClientResponse исключал JsonRpcError; SSE-поток, ограниченный соединением, не имел onClose; DELETE без заголовка был молчаливым 202; SseStream.close вызывал onClose вне try/catch; session/load·resume·close не тестировались. | Расширен тип до JsonRpcResponse; поток соединения логирует закрытие; DELETE без заголовка → 400; onClose обёрнут в try/catch; добавлены тесты load/resume/close + DELETE-400. |
Вне области видимости (базовая ветка daemon_mode_b_main, не этот дифф) — второй ревьюер отметил
ошибки типов в acpAgent.ts (entryCount/entrySummary/sessionClose) и другие ранее существовавшие
пункты, которые он явно отнёс к базовой ветке (введены #4353). Отслеживаются отдельно; здесь не затронуты.
Всё ещё отложено (задокументировано): секрет для каждого соединения для DELETE/владение соединением (токен остаётся границей); WebSocket + HTTP/2 (§7); строгий барьер результат-промпт vs. завершающий update (§11).
13. Ревью раунд 4 — исправления PR (перебазировано на #4469)
Ветка перебазирована на daemon_mode_b_main (#4353 + #4469) — чисто, без конфликтов. Два
ревьюера PR (GPT-5 + qwen3.7-max). Набор теперь 25 тестов; живая перепроверка (125 session/update
→ end_turn).
| # | Важность | Находка | Исправление |
|---|---|---|---|
| C1 | P0 | Обработка ошибок записи SSE из раунда 3 была задокументирована, но НЕ реализована — SseStream всё ещё оставлял это игнорирующим вызывающим (потоки-зомби). | writeRaw теперь берёт это на себя: первое отклонение записи логируется один раз + вызывает close(); doWrite также слушает 'error' (отклоняет сразу, а не ждёт 'close'); onClose обёрнут в try/catch. |
| C2 | P1 | fromLoopback захватывался только при initialize + хелпер уже REST → голоса local-only от последующего POST неправильно оценивались. | Loopback на каждый запрос пробрасывается через handle→sessionCtx/resolveClientResponse; isLoopbackReq расширен до 127.0.0.0/8 + ::ffff:127.* + ::1 (совпадает с REST). |
| C3 | P1 | Маршрутизация ошибок определяла поток из params.sessionId → ошибки методов, ограниченных соединением (session/load/resume/close/heartbeat), направлялись в несуществующий поток сессии (молчаливая потеря). | Введён набор CONN_ROUTED_METHODS; ошибки направляются так же, как и путь успеха. |
| C4 | P1 | bridge.detachClient никогда не вызывался при разрушении → устаревшие clientId, выданные мостом, оставались в knownClientIds()/наборах голосующих. | Реестр принимает DetachSessionFn; closeSessionStream/destroy открепляют каждую принадлежащую сессию (best-effort). |
| C5 | P1 | session/close пропускал локальную очистку, если bridge.closeSession выбрасывал исключение. | closeSessionStream перенесён в finally. |
| C6 | P2 | Windows-путь cwd (C:\…) отвергался startsWith('/'). | path.isAbsolute (учитывает платформу), совпадает с REST. |
| C7 | P2 | protocolVersion мог согласовать 0/отрицательное значение. | Ограничение Math.max(1, Math.min(requested, 1)); тесты для 0/отр/огромное/невалидное. |
| C8 | P2 | session/load/resume принимали пустой sessionId. | Отклонять пустой с INVALID_PARAMS. |
| C9 | P2 | Ошибки session/prompt в форме уведомления исчезали молча. | Логирование на пути без id. |
| C10 | P2 | SSE сессии сбрасывал буферизованные кадры до заголовков/retry:. | open() до attachSessionStream. |
| C11 | P2 | Дублирование локального logStderr. | Общий writeStderrLine из utils/stdioHelpers. |
| C12 | P2 | Документация рекламировала флаги --no-acp-http, тег возможности acp_http и проброс fs/*, отсутствующие в v1. | Документация приведена в соответствие с поставляемой поверхностью (только переключатель env-var; fs/*+terminal/* + флаг + тег помечены как отложенные). |
Всё ещё отложено (без изменений): WebSocket + HTTP/2; секрет на соединение для DELETE/владения (токен + одна рабочая область остаётся границей); строгий барьер упорядочения подсказка→результат; приведения на границе моста as never (целенаправленные, отмечены для последующего дополнения по типам адаптера).
14. Обзорный раунд 5 — слияния PR
Ещё один проход рецензента (qwen3.7-max). Набор 26 тестов, повторно проверены вживую.
| # | Серьёзность | Находка | Исправление |
|---|---|---|---|
| D1 | P0 | resolveClientResponse удалял ожидающую запись ДО вызова respondToSessionPermission. Некорректный голос (result: {}) вызывает ошибку в медиаторе моста — а поскольку ожидающая запись уже удалена, abandonPendingForSession в teardown не может её отменить, и подсказка агента зависает на голосе, который никогда не разрешится (владелец токена может заблокировать сессию одним неправильным POST). | Обернуть голос в try/catch; при любой ошибке откатываться к cancelAbandonedPermission, чтобы медиатор всегда освобождался. Добавлен новый тест для пути с некорректным голосом. |
| D2 | P1 | onClose потока сессии прерывал только насос событий, а не binding.promptAbort — отключение клиента (закрытие вкладки / потеря сети) оставляло выполняющуюся подсказку (квота + FIFO) до окончания idle TTL. | onClose теперь также прерывает promptAbort сессии. |
| D3 | P1 | Когда pumpSessionEvents отклонялся, .catch только логировал — SSE-поток оставался открытым, отправляя heartbeat’ы, но не доставляя ничего (зомби, без сигнала переподключения). | .catch теперь также вызывает closeSessionStream(sessionId). |
15. Обзорный раунд 6 — слияния PR
Ещё один проход рецензента (qwen3.7-max). Набор 28 тестов, повторно проверены вживую.
| # | Серьёзность | Находка | Исправление |
|---|---|---|---|
| E1 | P0 | handlePrompt перезаписывал binding.promptAbort без прерывания предыдущего контроллера — два одновременных session/prompt для одной сессии приводили к сиротству первого (выполняется до конца в FIFO моста, не может быть прерван через session/cancel). | Прервать предыдущий promptAbort перед установкой нового. Добавлен тест. |
| E2 | P0 | Путь, где subscribeEvents выбрасывает исключение, отправлял уведомление stream_error, затем return (разрешался) — .catch вызывающего никогда не срабатывал, оставляя зомби-поток SSE (heartbeat’ы, без событий, без сигнала переподключения). | Повторно выбросить исключение после уведомления, чтобы .catch вызывающего закрыл поток. Тест проверяет закрытие подсказки. |
| E3 | P1 | Heartbeat SSE не отмечал соединение как активное — длинная подсказка без промежуточных событий >30 мин приводила к сборке по idle (потоки и подсказки уничтожались). | SseStream принимает хук onHeartbeat; оба GET-обработчика передают () => conn.touch(). |
| E4 | P2 | .catch в pumpSessionEvents закрывал по sessionId — переподключение между выбросом и микрозадачей могло убить НОВЫЙ поток. | Защита по идентичности: закрывать только если binding.stream всё ещё этот поток. |
| E6 | P2 | sendSession автоматически создавал привязку — поздний фрейм pump/reply после closeSessionStream воскрешал призрачную привязку, которая бесконечно буферизировала до 256 фреймов. | sendSession теперь работает только на поиск: отбрасывает фреймы, когда у сессии нет активной привязки. |
| E5 | принято | session/load/resume не отклоняются, когда другое активное соединение владеет сессией (“перехват”). | Принято, не изменено: граница доверия демона — токен-носитель + привязка к одной рабочей области, а мультиклиентское подключение является намеренным (мост спроектирован как мультиклиентский; REST обладает тем же свойством). Владелец токена не получает возможностей, которых у него нет через REST. Отслеживается вместе с остальными элементами границы токена (владение DELETE, §13). |
16. Обзорный раунд 7 — слияния PR
Ещё один проход рецензента (qwen3.7-max). Набор 30 тестов, повторно проверены вживую.
| # | Серьёзность | Находка | Исправление |
|---|---|---|---|
| F1 | P0 | Гонка параллельных session/close TOCTOU: ownedSessions.delete выполнялся только в finally (после await), поэтому два одновременных закрытия оба проходили requireOwned → ошибочное сообщение для второго + избыточное закрытие моста. | Удалить шлюз владения СИНХРОННО перед await; закрытие моста выполняется один раз. Добавлен тест. |
| F2 | P1 | Жизненный цикл насоса: чистое завершение итератора (подпроцесс завершён, done) разрешалось → .catch никогда не срабатывал → зомби-поток; а ошибка итератора В СЕРЕДИНЕ ПОТОКА не отправляла stream_error. | pumpSessionEvents оборачивает весь цикл (синхронные ошибки + ошибки в середине потока отправляют stream_error, затем повторный выброс); потребитель .then(onDone, onErr) закрывает поток на ОБОИХ путях (с защитой по идентичности). Добавлены тесты. |
| F3 | P2 | Отклонение соединения при достижении лимита (503) не имело лога в stderr. | writeStderrLine со значением лимита. |
| F4 | P2 | Spread в _qwen/notify stream_error позволял event.data.kind затенять дискриминатор. | Сначала spread, затем kind: 'stream_error'. |
| F5 | P2 | MAX_WORKSPACE_PATH_LENGTH повторно объявлен (= 4096) вместо канонического из fs/paths.js. | Импорт из ../fs/paths.js (без расхождений). |
| F6 | P2 | isObjectParams дублирует json-rpc.isObject. | Импортировать isObject. |
| F7 | P2 | Использование сырого process.stderr.write в index.ts/sse-stream.ts вместо writeStderrLine в других местах. | Унифицировано на writeStderrLine во всём модуле. |
17. REST эквивалентность + внедрение аудита расширений (раунд 8)
Цель: сделать /acp эквивалентной альтернативой REST+SSE. В этой серии на основе выводов аудита переработаны схемы расширений и дополнены все возможности, уже предоставляемые мостом; возможности, которых у моста пока нет (файловый ввод/вывод, потоки устройств, agents/memory CRUD), согласно архитектурным требованиям сначала дополняются в acp-bridge (см. §17.3).
17.1 Аудит схем расширения → внедрение (замена старой схемы из §5)
Сверка по реализованному SDK @agentclientprotocol/sdk@0.14.1 (не только по сайту):
session/set_config_option— основной (неunstable_) метод, запрос{sessionId, configId, value},categoryвключаетmodel/mode/thought_level; при этомset_modelпо-прежнему идёт черезunstable_setSessionModel.- Спецификация оставляет префикс
_для расширений, пример — доменный стиль_zed.dev/…; данные производителя помещаются в_metaс разбивкой по доменам.
Внедрение:
- Пространство имён
_qwen/→ обратный домен_qwen/;_metaунифицирован как_meta:{ "qwen": … }(содержит анонс возможностейinitializeиrequestIdдляsession/request_permission). - Модель + режим утверждения → стандартный
session/set_config_option(configId:"model"|"mode"), маршрутизируется в существующиеbridge.setSessionModel/setSessionApprovalMode; результатsession/newрекламируетconfigOptions(берётся из состояния сессии подпроцессаgetSessionContextStatus().state.configOptions, уже в форме ACP). Удалён производительский_qwen/session/set_model. - REST(http+sse) не требует синхронных изменений: оба транспорта используют один и тот же мост, состояние согласовано автоматически.
17.2 Новые методы /acp, добавленные в этой серии (мост уже поддерживает, 1:1 соответствие REST)
| REST | /acp | bridge |
|---|---|---|
POST /session/:id/model / approval-mode | стандартный session/set_config_option (model/mode) | setSessionModel / setSessionApprovalMode |
GET /session/:id/context | _qwen/session/context | getSessionContextStatus |
GET /session/:id/supported-commands | _qwen/session/supported_commands | getSessionSupportedCommandsStatus |
PATCH /session/:id/metadata | _qwen/session/update_metadata | updateSessionMetadata |
GET /workspace/{mcp,skills,providers,env,preflight} | _qwen/workspace/{…} | getWorkspace*Status |
POST /workspace/init | _qwen/workspace/init | initWorkspace |
POST /workspace/tools/:name/enable | _qwen/workspace/set_tool_enabled | setWorkspaceToolEnabled |
POST /workspace/mcp/:server/restart | _qwen/workspace/restart_mcp_server | restartMcpServer |
(Уже выровнены: session/new·load·resume·close·list·prompt·cancel, heartbeat, permission, events.)
17.3 Оставшиеся пробелы → требуется сначала дополнить acp-bridge (архитектурная корректность)
Файловый ввод/вывод REST (/file /glob /list /stat /file/write /file/edit), потоковая аутентификация устройств (/workspace/auth/*), agents CRUD (/workspace/agents), memory CRUD (/workspace/memory) в настоящее время не находятся на HttpAcpBridge — маршруты REST напрямую вызывают сервисы уровня маршрутов (WorkspaceFileSystemFactory, DeviceFlowRegistry, SubagentManager, writeWorkspaceContextFile), минуя мост.
Решение (принято по результатам рецензирования / мнения владельца): не заставлять транспорт /acp подключаться напрямую к этим сервисам уровня маршрутов (это воспроизвело бы архитектурный дрейф REST и удвоило бы связанность транспорта). Правильный подход — сначала дополнить HttpAcpBridge в @qwen-code/acp-bridge этими возможностями (например, readWorkspaceFile/writeWorkspaceFile/globWorkspace, startDeviceFlow/pollDeviceFlow, listAgents/upsertAgent/deleteAgent, readMemory/writeMemory), чтобы и REST, и /acp проходили через мост. После этого в /acp будут добавлены _qwen/fs/*, _qwen/auth/*, _qwen/workspace/agent*, _qwen/workspace/memory* (чтение файлов, так как нет стандартного метода ACP клиент→агент, является легальным расширением производителя).
Полная эквивалентность = текущая серия (возможности, уже имеющиеся в мосту) + последующая серия после восполнения пробелов acp-bridge.
18. Обзорный раунд 9 — слияния PR
| # | Серьёзность | Находка | Исправление |
|---|---|---|---|
| G1 | P1 (регрессия) | Переподключение потока сессии прерывало выполняющуюся подсказку: attachSessionStream закрывал СТАРЫЙ поток перед установкой нового, а onClose старого потока безусловно прерывал promptAbort — таким образом, переподключающийся клиент (сбои сети / роуминг) терял свою запущенную подсказку. | Установить новый поток ДО закрытия старого; защитить прерывание promptAbort в onClose проверкой идентичности (прерывать только если ЭТО всё ещё активный поток сессии). Добавлен тест (подсказка переживает переподключение). |
| G2 | P2 | session/cancel передавал undefined в качестве тела CancelNotification, отбрасывая поля отмены (причина/контекст), предоставленные клиентом, которые REST передаёт. | Передавать { ...params, sessionId } (аналог REST). |
Перебазировано на последнюю версию daemon_mode_b_main (#4473/#4483/#4484/#4500), конфликтов нет. Набор 33 теста, повторно проверены вживую.
19. Дорожная карта / последующие PR (чтобы не забыть)
Текущий PR (#4472) = ACP Streamable HTTP transport + полное выравнивание возможностей, поддерживаемых мостом + официальная схема расширений. Переведён в готов. Чтобы достичь «/acp полностью эквивалентен REST+SSE», необходимы:
- Follow-up PR 1 — дополнение возможностей acp-bridge (предварительно / bridge-first):
HttpAcpBridgeполучает новые методы: файловый ввод/вывод, потоки устройств, agents CRUD, memory CRUD; маршруты REST переходят на мост (устранение дрейфа прямых вызовов сервисов уровня маршрутов). - Follow-up PR 2 — оставшееся выравнивание
/acp(зависит от PR 1):_qwen/fs/*,_qwen/auth/*,_qwen/workspace/agent*,_qwen/workspace/memory*→ полная эквивалентность REST.
Отслеживание: #3803 (открытые решения), #4175 (дорожная карта Mode B) — в обоих оставлены комментарии. Отложенные пункты по hardening указаны в описании PR («Известные отложенные»).
20. Переименование пространства имён расширений + анализ транспорта SDK (раунд 11)
- Пространство имён
_qwen.ai/→_qwen/: Единственное жёсткое правило ACP — ведущий символ_; сегмент домена_zed.dev/— это соглашение по примеру, а не обязательное требование. Посколькуqwenявляется различимым, мы используем более короткую базовую форму. Ключ_metaтакже"qwen". (Обзор реальных агентов: Zed/gemini-cli в основном используют_metaв стандартных методах + собственныеunstable_*ACP; пользовательские методы с_редки — наши_qwen/*представляют собой действительно новые операции с рабочей областью/сессией, не имеющие стандартного эквивалента, поэтому метод с_— правильный инструмент.) - Почему самодельный транспорт (не на основе SDK): TS SDK поставляется только с
ndJsonStream(stdio); RFD #721 HTTP находится в фазе 3 SDK (не реализован).Connectionв SDK — это одно-дуплексный поток; наш транспорт — многопоточный (POSTs + соединение-SSE + SSE на сессию) и требует демультиплексирования исходящих сообщений по sessionId — что наш диспетчер уже знает на этапе маршрутизации. Полная переработка SDK противоречит этой модели и не устранит основную часть (трансляция моста, жизненный цикл SSE, владение, EventBus→JSON-RPC). Прагматичное улучшение (кандидат на follow-up): использовать валидаторы схем Zod из SDK + типы для проверки параметров, сохраняя самодельный транспорт. Клиенты SDK, использующиеextMethod('_qwen/…'), будут взаимодействовать с нашими обработчиками (идентичная форма сообщения на проводе).