Демон ACP-over-HTTP → Стандартный ACP Streamable HTTP транспорт
Цель:
daemon_mode_b_main. Ветка:feat/daemon-acp-http-streamable. Автор: arnoo.gao. Дата: 2026-05-24. Статус: Проект v1 → реализация. Разработка на основе проектов по рабочему процессу репозитория: этот документ появляется до или вместе с 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 API.
Решение: двойной транспорт, добавление. Новая конечная точка /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 over 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 │ (агент) │
└──────────────┘ └────────────┘ └──────────────┘
северный: НЕ 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). - Типы событий
type:session_update,client_evicted,slow_client_warning,state_resync_required,stream_error, …
- Фреймы:
- Аутентификация:
Authorization: Bearer <token>(serve/auth.ts), запрет CORS + белый список хостов. - Обратное давление: последовательная цепочка записи на соединение + комментарии к 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-заголовка (соединение
против сессии), 202 для не-initialize, обязательный HTTP/2, обязательный WebSocket для клиента. Мы
заимствуем схему с одной конечной точкой + POST/GET-SSE + заголовок сессии, но адаптируем под
долгоживущую двух-ID модель. Мы не используем StreamableHTTPServerTransport из
@modelcontextprotocol/sdk (его потоковая модель на каждый запрос и единственный
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 Agent через HTTP на северной стороне, оставаясь ACP
клиентом для дочернего процесса на южной. Слой /acp, таким образом, является
JSON-RPC маршрутизатором, который завершает HTTP-транспорт и мостит в существующий
HttpAcpBridge.
POST /acp (JSON-RPC запросы/ответы/уведомления)
клиент ──────────────────────────────────────────────► ┌───────────────────────────┐
(редактор) │ AcpHttpTransport │
◄── GET /acp (SSE уровня соединения) ────────── │ - реестр соединений │
◄── GET /acp (SSE уровня сессии) ───────────── │ - корреляция JSON-RPC id│
│ - диспетчеризация методов │
└────────────┬──────────────┘
│ использует
┌────────────▼──────────────┐
│ HttpAcpBridge + EventBus │ (без изменений)
└────────────┬──────────────┘
│ ACP stdio (без изменений)
qwen --acp child3.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 (использует тот же паттерн управления противодавлением/сердцебиением, что и 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 запрос через поток сессии с идентификатором, выделенным демоном; клиент отвечает черезPOST /acp {id, result};dispatchобрабатывает его через API разрешений моста. DELETE /acp(или закрытие потока соединения + TTL) завершает сессии/подписки.
4. Таблица трансляции (bridge ⇄ ACP/HTTP)
4.1 Входящие (клиент POST → bridge)
| Метод ACP | Вызов bridge | Куда направляется ответ |
| ------------------------------------------- | ----------------------------------------------------- | -------------------------------------- | ----------------- |
| initialize | (нет; возможности из capabilities.ts) | встроенный 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 | Отправляется как |
|---|---|
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 подсказки (или _qwen/notify) |
| завершение подсказки | {id:<promptId>, result:{stopReason}} |
4.3 Ожидающие запросы агент→клиент
AcpConnection хранит Map<jsonRpcId, {sessionId, kind, bridgeRequestId, resolve}>.
Когда клиент отправляет POST с объектом ответа JSON-RPC, dispatch находит совпадение по id, затем вызывает путь разрешения bridge (например, внутренний эквивалент POST /session/:id/permission/:requestId).
Статус v1: реализован только двусторонний обмен
session/request_permissionагент→клиент. Перенаправлениеfs/*иterminal/*от агента к клиенту отложено (§7) — демон еще не объявляет согласование клиентских возможностейfs/terminalчерез/acp, поэтому клиенты ACP не должны рассчитывать на семантику файловой системы/терминала через этот транспорт в v1. Целевое конечное состояние (перенаправлятьfs/*клиенту; при отсутствии у клиента возможностиfsиспользовать файловую систему рабочей области демона) является последующим этапом, описанным в §7.
5. Стратегия расширения (требование #2)
ACP резервирует любой метод, начинающийся с _, для пользовательских расширений и предоставляет _meta для каждого типа. Южный сегмент кодовой базы уже использует имена методов unstable_*.
Выбор северного направления: имена методов с вендорским пространством имен _qwen/<область>/<глагол> (соответствующий спецификации префикс _). Возможности объявляются в agentCapabilities._meta.qwen при initialize, чтобы клиенты могли обнаруживать функциональность перед использованием.
| Потребность | Нет стандартного метода ACP? | Расширение |
|---|---|---|
| Переключение модели | да | _qwen/session/set_model |
| Интроспекция MCP/навыков/провайдеров/окружения рабочей области | да | _qwen/workspace/list, _qwen/workspace/<область> |
| Сердцебиение / время последнего появления | да | _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. Двойной транспорт vs. замена (требование #4)
Решение: двойной транспорт (аддитивный).
- Официальный транспорт — это черновик RFD, не нормативный и отсутствует во всех SDK — жёсткая замена привязала бы нас к неутверждённой спецификации и сломала бы webui + 3 SDK + VSCode companion одновременно.
- REST-поверхность содержит функции, для которых пока нет чистого ACP-отображения
(интроспекция рабочего пространства, посредничество разрешений для нескольких клиентов,
возобновление ring-buffer, реестр возможностей). Они деградируют до расширений
_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потоковая передача + корреляция финального ответа на запрос.session/request_permissionдвусторонний обмен агент→клиент.- Расширение
_qwen/session/set_modelкак работающий пример #2. - Повторное использование Bearer-аутентификации + белого списка хостов (те же middleware, что и для REST).
- Модульные тесты (
acp-http/*.test.ts) + сквозной smoke-скрипт, запускающий реальный демон.
Отложено (документировано, не реализовано сейчас):
- Путь обновления WebSocket (возможность клиента, требуемая RFD; SSE достаточно для локальной проверки).
- Мультиплексирование HTTP/2 (мы используем HTTP/1.1; POST и долгоживущие GET используют отдельные сокеты, что работает для CLI/Node-клиентов и браузеров до 6 соединений). Документировано как отклонение.
- Полное перенаправление агент→клиент для
fs/*+terminal/*(механизм доказан через разрешения; остальное — механическое дополнение). - Улучшение устойчивости SSE к возобновлению на уровне ring-buffer (Фаза 4 в RFD).
8. План локальной проверки
npm run build(или сборка workspace для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 {_qwen/session/set_model}→ проверить смену модели +session/update.
- Vitest:
acp-http/*.test.tsзелёные.
9. Риски
| Риск | Смягчение |
|---|---|
| Изменения RFD до утверждения | За тегом возможности + пространство имён _qwen; изолированный модуль; легко пересмотреть. |
| HTTP/1.1 против требуемого HTTP/2 | Клиенты localhost/CLI не затронуты; документировано; h2 — замена транспорта позже. |
| Два транспорта на одном бридже — состояние гонки | Бридж уже поддерживает многоклиентность; повторно используем его блокировки. |
Перенаправление fs/* против локальной ФС демона | Ограничено возможностью: пересылать, если клиент объявил 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,…}}), что подтвердило корреляцию по идентификатору + разделение HTTP 202/SSE в случае сбоя.
Обзорное внесение (fold-in) — выданный мостом clientId (обнаружено при живой проверке)
Первый живой запуск завершился ошибкой session/prompt с сообщением “client id … is not registered for session”. Коренная причина: spawnOrAttach/loadSession игнорируют предоставленный вызывающим clientId, который мост никогда не выдавал, и ставят новый (возвращается в BridgeSession.clientId); диспетчер возвращал собственный (незарегистрированный) идентификатор соединения в sendPrompt. Исправление: сохранять выданный мостом идентификатор в SessionBinding и возвращать его при каждом вызове, привязанном к сессии (sessionCtx). Повторно проверено — зелёный, как указано выше.
11. Второй раунд ревью — внесения (fold-ins)
Два независимых ревью (корректность/параллельность + соответствие протоколу/безопасность) плюс самостоятельное прочтение. Все исправления проверены расширенным набором vitest (18 тестов) + новым живым smoke-тестом (21 фрейм session/update → stopReason=end_turn).
| # | Серьёзность | Проблема | Исправление |
|---|---|---|---|
| R1 | P0 | Поток сессии переподключение было постоянно мёртво: SessionBinding.abort создавался один раз и переиспользовался; при закрытии потока он прерывался навсегда, поэтому при переподключении subscribeEvents(signal) получал уже прерванный сигнал и не получал ни одного события. | attachSessionStream теперь устанавливает новый AbortController для каждого потока (и закрывает предыдущий поток); index.ts продолжает работу по этому новому сигналу. |
| R2 | P0 | await dispatcher.handle() выполнялся после res.end(202); вызов моста, выбрасывающий исключение (особенно путь isResponse, не обёрнутый в try/catch), приводил к отклонению и необработанной ошибке → возможному краху демона. | Обернул путь isResponse в try/catch; добавил .catch() для ожидаемых вызовов handle(...) и pumpSessionEvents(...). |
| R3 | P1 | Отсутствие владения соединение→сессия: любое аутентифицированное соединение могло открыть SSE-поток сессии или отправить запрос к любому sessionId в рабочем пространстве (подслушивание чтения; отправка запросов блокировалась лишь случайно из-за ошибки незарегистрированного clientId). | AcpConnection.ownedSessions заполняется при session/new/load/resume; поток сессии возвращает 403, а POST-запросы к сессии возвращают INVALID_PARAMS для идентификаторов, не принадлежащих соединению (requireOwned). |
| R4 | P1 | Обработчик mountAcpHttp не сохранялся → таймер очистки TTL и живые SSE-потоки утекали при завершении. | Обработчик помещается в app.locals; хук закрытия runQwenServe вызывает dispose() перед bridge.shutdown() (зеркалирует реестр device-flow). |
| R5 | P1 | Утечка ожидающего разрешения: закрытие сессии/соединения с ожидающим разрешением оставляло мост заблокированным в ожидании голосования. | closeSessionStream/destroy отменяют соответствующие ожидающие запросы через внедрённый onAbandonPending → cancelAbandonedPermission. |
| R6 | P1 | Буферы фреймов перед присоединением (connBuffer/binding.buffer) были неограниченны. | Ограничены 256 фреймами (отбрасывание старейших), как в EventBus maxQueued. |
| 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)
- Порядок
prompt-resultи последующего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 | Сборщик бездействующих соединений уничтожал их без логирования и без ограничения на количество соединений (наводнение запросами initialize). | Сборщик логирует каждое удаление; pumpSessionEvents вызывает touch() (длительные неактивные промпты не удаляются); ограничение maxConnections (64) → 503. |
| B5 | P1 | sessionCtx молча откатывался к незарегистрированному clientId соединения, если в привязке его не было (не тестировалось, всегда срабатывало в FakeBridge). | Выбрасывать исключение при отсутствующем clientId (нарушение инварианта); FakeBridge теперь его проставляет. |
| B6 | P1 | session/new / load / resume принимали cwd без проверки (REST проверяет строку/длину/абсолютный путь — DoS-усиление). | Общая parseOptionalWorkspaceCwd (строка, ≤4096, абсолютный путь). |
| 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, не этот diff) — второй рецензент отметил ошибки типизации в acpAgent.ts (entryCount/entrySummary/sessionClose) и другие существующие ранее проблемы, которые он явно приписал базовой ветке (введённые в #4353). Отслеживаются отдельно; здесь не затрагиваются. |
Всё ещё отложено (задокументировано): секрет на соединение для DELETE/владения соединением (токен остаётся границей); WebSocket + HTTP/2 (§7); строгий барьер между prompt-result и trailing-update (§11).
13. Раунд ревью 4 — PR fold-ins (перебазировано на #4469)
Ветка перебазирована на daemon_mode_b_main (#4353 + #4469) — чисто, без конфликтов. Два PR-ревьюера (GPT-5 + qwen3.7-max). Набор тестов теперь 25 тестов; проверено вживую (125 session/update → end_turn).
| # | Серьёзность | Находка | Исправление |
|---|---|---|---|
| C1 | P0 | Обработка ошибок записи SSE “SSE write-failure handling” из раунда 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 никогда не вызывается при завершении → устаревшие идентификаторы клиентов, проставленные bridge, остаются в 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 | Session SSE сбрасывал буферизованные фреймы до заголовков/retry:. | open() перед attachSessionStream. |
| C11 | P2 | Дублирующийся локальный logStderr. | Общий writeStderrLine из utils/stdioHelpers. |
| C12 | P2 | В документации рекламировались флаг --no-acp-http, тег возможности acp_http и форвардинг fs/*, отсутствующие в v1. | Документация приведена в соответствие с поставляемой поверхностью (только переключатель через переменную окружения; fs/*+terminal/* + флаг + тег отмечены как отложенные). |
Всё ещё отложено (без изменений): WebSocket + HTTP/2; секрет на соединение для DELETE/владения (токен + рабочее пространство остаётся границей); строгий барьер упорядочивания результатов запросов; приведения as never на границе моста (целевые, отмечены для последующего обсуждения типов адаптера). |
14. Пятый цикл рецензирования — доработки PR
Ещё один проход рецензента (qwen3.7-max). Набор тестов 26, проверены вживую.
| # | Серьёзность | Находка | Исправление |
|---|---|---|---|
| D1 | P0 | resolveClientResponse удалил ожидающую запись ДО вызова respondToSessionPermission. Некорректный голос (result: {}) вызывает ошибку в медиаторе моста — и, так как ожидающая запись уже удалена, abandonPendingForSession в процессе завершения не может её отменить, и запрос агента зависает в ожидании голоса, который никогда не обработается (владелец токена может заблокировать сессию одним некорректным POST). | Обернуть голос в try/catch; при любой ошибке использовать cancelAbandonedPermission, чтобы медиатор всегда освобождался. Добавлен новый тест для сценария некорректного голоса. |
| D2 | P1 | onClose потока сессии прерывал только цикл событий, но не binding.promptAbort — отключение клиента (закрытие вкладки / потеря сети) оставляло выполняющийся запрос активным (квота + FIFO) до истечения TTL простоя. | Теперь onClose также прерывает promptAbort сессии. |
| D3 | P1 | Когда pumpSessionEvents отклонялся, .catch только логировал — поток SSE оставался открытым, отправляя пульс, но не доставляя данные (зомби, без сигнала переподключения). | Теперь .catch также вызывает closeSessionStream(sessionId). |
15. Шестой цикл рецензирования — доработки PR
Ещё один проход рецензента (qwen3.7-max). Набор тестов 28, проверены вживую.
| # | Серьёзность | Находка | Исправление |
|---|---|---|---|
| E1 | P0 | handlePrompt перезаписывал binding.promptAbort без прерывания предыдущего контроллера — два параллельных session/prompt для одной сессии оставляли первый сиротой (он выполнялся до конца в FIFO моста, не прерываемый session/cancel). | Прерывать предыдущий promptAbort перед установкой нового. Добавлен тест. |
| E2 | P0 | Путь, где subscribeEvents бросает исключение, отправлял уведомление stream_error, а затем делал return (разрешался) — .catch вызывающего кода никогда не срабатывал, оставляя поток SSE зомби (пульс, нет событий, нет сигнала переподключения). | Повторно бросать исключение после уведомления, чтобы .catch вызывающего кода закрыл поток. Тест проверяет закрытие запроса. |
| E3 | P1 | Пульс SSE не отмечал соединение как активное — длинный запрос без промежуточных событий дольше 30 минут попадал под сборку при простое (потоки и запросы уничтожались). | 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. Review round 7 — PR fold-ins
Ещё один обзорный проход (qwen3.7-max). Набор 30 тестов, проверено вживую.
| # | Серьёзность | Находка | Исправление |
|---|---|---|---|
| F1 | P0 | Гонка TOCTOU в session/close: ownedSessions.delete выполнялся только в finally (после await), так что два параллельных закрытия оба проходили requireOwned → второй получал вводящее в заблуждение сообщение об ошибке + лишнее закрытие моста. | Удалить владельческую привязку СИНХРОННО перед await; закрытие моста выполняется один раз. Добавлен тест. |
| F2 | P1 | Жизненный цикл pump: чистое завершение итератора (подпроцесс завершился, done разрешено) не вызывало .catch → зомби-поток; а ошибка итератора в середине потока не отправляла stream_error. | pumpSessionEvents оборачивает весь цикл (синхронные и средне-потоковые ошибки отправляют stream_error, затем пробрасывают ошибку); потребитель .then(onDone, onErr) закрывает поток на ОБОИХ путях (с защитой по identity). Добавлены тесты. |
| F3 | P2 | Отклонение подключения по 503 (исчерпание емкости) не логировалось в stderr. | writeStderrLine со значением емкости. |
| F4 | P2 | Разворот _qwen/notify stream_error позволял event.data.kind перекрывать дискриминатор. | Сначала разворот, затем 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 等价对齐 + 扩展方案审计落地(round 8)
目标:让 /acp 成为 REST+SSE 的等价替代。本批基于审计结论重构扩展方案,并补齐所有 bridge 已暴露的能力;bridge 尚未拥有的能力(文件 I/O、设备流、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能力广告与session/request_permission的 requestId)。 - 模型 + 审批模式 → 标准
session/set_config_option(configId:"model"|"mode"),路由到现有bridge.setSessionModel/setSessionApprovalMode;session/new结果广告configOptions(取自子进程会话状态getSessionContextStatus().state.configOptions,已是 ACP 形状)。删除厂商_qwen/session/set_model。 - REST(http+sse) 无需同步修改:两 transport 共用同一 bridge,状态天然一致。
17.2 Новые методы /acp, добавленные в этом выпуске (bridge уже поддерживает, 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 (архитектурная корректность)
Файловый I/O REST (/file /glob /list /stat /file/write /file/edit), вход через поток устройств (/workspace/auth/*), CRUD агентов (/workspace/agents), CRUD памяти (/workspace/memory) в настоящее время не находятся на HttpAcpBridge — маршруты REST напрямую вызывают сервисы уровня маршрутов (WorkspaceFileSystemFactory, DeviceFlowRegistry, SubagentManager, writeWorkspaceContextFile), минуя bridge.
Решение (принято мнение ревью/владельца): Не позволять транспорту /acp напрямую подключаться к этим сервисам уровня маршрутов (это повторит дрейф архитектуры REST и удвоит связанность транспорта). Правильный подход — сначала дополнить HttpAcpBridge из @qwen-code/acp-bridge этими возможностями (например, readWorkspaceFile/writeWorkspaceFile/globWorkspace, startDeviceFlow/pollDeviceFlow, listAgents/upsertAgent/deleteAgent, readMemory/writeMemory), чтобы и REST, и /acp проходили через bridge. Тогда /acp добавит _qwen/fs/*, _qwen/auth/*, _qwen/workspace/agent*, _qwen/workspace/memory* (чтение файлов — легальное расширение вендора, так как нет стандартного метода ACP client→agent).
Полная эквивалентность = этот выпуск (уже имеющиеся возможности bridge) + последующий выпуск после заполнения пробелов acp-bridge.
18. Review round 9 — PR fold-ins
| # | Severity | Finding | Fix |
|---|---|---|---|
| G1 | P1 (regression) | Переподключение сессионного потока прервало выполняемый prompt: attachSessionStream закрывал СТАРЫЙ поток перед установкой нового, и старый поток безусловно прерывал promptAbort — так что при переподключении клиента (сбой сети/роуминг) терялся выполняемый prompt. | Устанавливать новый поток ДО закрытия старого; защищать по идентичности прерывание prompt в onClose (прерывать только если ЭТО всё ещё активный поток сессии). Добавлен тест (prompt переживает переподключение). |
| G2 | P2 | session/cancel передавал undefined как тело CancelNotification, теряя предоставленные клиентом поля отмены (reason/context), которые REST передаёт. | Передавать { ...params, sessionId } (зеркалирует REST). |
Перебазировано на последнюю версию daemon_mode_b_main (#4473/#4483/#4484/#4500), конфликтов нет. Набор 33 теста, повторно проверено вживую.
19. Дорожная карта / последующие PR (чтобы не забыть)
Данный PR (#4472) = ACP Streamable HTTP transport + полное выравнивание возможностей bridge + официальное расширение. Статус переведён в ready. Для достижения «полной эквивалентности /acp с REST+SSE» ещё необходимо:
- Follow-up PR 1 — дополнение возможностей acp-bridge (предварительно / bridge-first): Добавление методов файлового I/O, потока устройств, CRUD агентов, CRUD памяти в
HttpAcpBridge; маршруты REST переключаются на bridge (устранение дрейфа прямого подключения к сервисам уровня маршрутов). - Follow-up PR 2 — оставшееся выравнивание
/acp(зависит от PR 1):_qwen/fs/*,_qwen/auth/*,_qwen/workspace/agent*,_qwen/workspace/memory*→ полностью эквивалентно REST. Отслеживание: #3803 (открытые решения), #4175 (дорожная карта Mode B) — все прокомментированы.
Отложенные элементы ужесточения см. в описании 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); HTTP из RFD #721 находится в фазе 3 SDK (не реализован). Соединение SDK — это единый дуплексный поток; наш транспорт — мультипотоковый (POST-запросы + соединение-SSE + SSE на сессию) и требует исходящей демультиплексации по sessionId — которую наш диспетчер уже знает на этапе маршрутизации. Полный переезд на SDK борется с этой моделью и не устранил бы основную часть (трансляция моста, жизненный цикл SSE, владение, EventBus → JSON-RPC). Прагматичное улучшение (кандидат на дальнейшее развитие): использовать схемы Zod-валидаторов и типы из SDK для проверки параметров, оставив собственный транспорт. Клиенты SDK, использующиеextMethod('_qwen/…'), взаимодействуют с нашими обработчиками (идентичная форма на проводе).