Skip to Content
ДизайнDaemon Acp HTTPДемон ACP-over-HTTP → Стандартный ACP Streamable HTTP транспорт

Демон 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 провод мост южный: настоящий ACP

1.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/eventstext/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:729 new 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. initialize200 + тело 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 child

3.1 Структура нового модуля (packages/cli/src/serve/acp-http/)

ФайлОбязанности
index.tsmountAcpHttp(app, bridge, opts) — регистрирует маршруты /acp на существующем Express-приложении.
connection-registry.tsAcp-Connection-IdAcpConnection (писатель 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 Жизненный цикл соединения и сессии

  1. POST /acp {initialize} → создаётся connectionId, создаётся AcpConnection, отправляется ответ 200 с {protocolVersion, agentCapabilities, _meta:{qwen:{…}}} + заголовок Acp-Connection-Id.
  2. Клиент открывает GET /acp (на уровне соединения) с заголовком Acp-Connection-Id.
  3. POST /acp {session/new}202; демон вызывает bridge.createSession(...); отправляет JSON-RPC ответ (с sessionId) через поток соединения.
  4. Клиент открывает GET /acp (на уровне сессии) с Acp-Connection-Id+Acp-Session-Id; демон вызывает bridge.subscribeEvents(sessionId) и передаёт преобразованные фреймы.
  5. POST /acp {session/prompt}202; bridge.sendPrompt(...); уведомления session/update передаются в реальном времени через поток сессии; итоговый ответ на prompt ({id, result:{stopReason}}) отправляется через поток сессии после завершения.
  6. Запрос агента к клиенту (например, session/request_permission) отправляется как JSON-RPC запрос через поток сессии с идентификатором, выделенным демоном; клиент отвечает через POST /acp {id, result}; dispatch обрабатывает его через API разрешений моста.
  7. 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. План локальной проверки

  1. npm run build (или сборка workspace для cli + acp-bridge).
  2. Запуск демона: qwen serve --listen 127.0.0.1:0 --token <t> (или токен из переменной окружения).
  3. Запуск node scripts/acp-http-smoke.mjs:
    • POST /acp {initialize} → проверить 200 + Acp-Connection-Id.
    • Открыть SSE подключения; POST {session/new} → проверить ответ в потоке.
    • Открыть SSE сессии; POST {session/prompt:"say hi"} → проверить ≥1 session/update, затем финальный {result:{stopReason}}.
    • Запустить инструмент, требующий разрешения → проверить запрос session/request_permission, отправить ответ с разрешением → проверить завершение запроса.
    • POST {_qwen/session/set_model} → проверить смену модели + session/update.
  4. 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/updatestopReason=end_turn).

#СерьёзностьПроблемаИсправление
R1P0Поток сессии переподключение было постоянно мёртво: SessionBinding.abort создавался один раз и переиспользовался; при закрытии потока он прерывался навсегда, поэтому при переподключении subscribeEvents(signal) получал уже прерванный сигнал и не получал ни одного события.attachSessionStream теперь устанавливает новый AbortController для каждого потока (и закрывает предыдущий поток); index.ts продолжает работу по этому новому сигналу.
R2P0await dispatcher.handle() выполнялся после res.end(202); вызов моста, выбрасывающий исключение (особенно путь isResponse, не обёрнутый в try/catch), приводил к отклонению и необработанной ошибке → возможному краху демона.Обернул путь isResponse в try/catch; добавил .catch() для ожидаемых вызовов handle(...) и pumpSessionEvents(...).
R3P1Отсутствие владения соединение→сессия: любое аутентифицированное соединение могло открыть SSE-поток сессии или отправить запрос к любому sessionId в рабочем пространстве (подслушивание чтения; отправка запросов блокировалась лишь случайно из-за ошибки незарегистрированного clientId).AcpConnection.ownedSessions заполняется при session/new/load/resume; поток сессии возвращает 403, а POST-запросы к сессии возвращают INVALID_PARAMS для идентификаторов, не принадлежащих соединению (requireOwned).
R4P1Обработчик mountAcpHttp не сохранялся → таймер очистки TTL и живые SSE-потоки утекали при завершении.Обработчик помещается в app.locals; хук закрытия runQwenServe вызывает dispose() перед bridge.shutdown() (зеркалирует реестр device-flow).
R5P1Утечка ожидающего разрешения: закрытие сессии/соединения с ожидающим разрешением оставляло мост заблокированным в ожидании голосования.closeSessionStream/destroy отменяют соответствующие ожидающие запросы через внедрённый onAbandonPendingcancelAbandonedPermission.
R6P1Буферы фреймов перед присоединением (connBuffer/binding.buffer) были неограниченны.Ограничены 256 фреймами (отбрасывание старейших), как в EventBus maxQueued.
R7P2initialize игнорировал запрошенный клиентом protocolVersion.Согласовывает min(requested, 1).
R8P2Отсутствует перекрёстная проверка Acp-Session-Idparams.sessionId (RFD §2.3).POST требует их совпадения; несовпадение → INVALID_PARAMS.
R9P2Форма запроса 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/updateend_turn).

#СерьёзностьНаходкаИсправление
B1P0AbortController в handlePrompt никогда не прерывался — отключающийся/отменяющий клиент оставлял агента работающим (сжигалась квота модели, блокировался FIFO сессии). Отмечено обоими ботами + 5 саб-агентами.promptAbort прикреплён к SessionBinding; прерывается по session/cancel и при разрыве сессии/соединения (closeSessionStream/destroy).
B2P0В sessionCtx отсутствовал fromLoopback → каждый голос разрешения ACP рассматривался как удалённый; политика local-only отклоняла бы loopback-клиентов.Захват loopback при initialize (ядро remoteAddress, не поддельные заголовки) → AcpConnection.fromLoopback → передаётся через sessionCtx.
B3P0Ошибки записи SSE молча проглатывались → зомби-потоки (heartbeat срабатывают, ни одного события не доставлено, нет логов).Первая ошибка записи логируется и закрывает поток.
B4P0Сборщик бездействующих соединений уничтожал их без логирования и без ограничения на количество соединений (наводнение запросами initialize).Сборщик логирует каждое удаление; pumpSessionEvents вызывает touch() (длительные неактивные промпты не удаляются); ограничение maxConnections (64) → 503.
B5P1sessionCtx молча откатывался к незарегистрированному clientId соединения, если в привязке его не было (не тестировалось, всегда срабатывало в FakeBridge).Выбрасывать исключение при отсутствующем clientId (нарушение инварианта); FakeBridge теперь его проставляет.
B6P1session/new / load / resume принимали cwd без проверки (REST проверяет строку/длину/абсолютный путь — DoS-усиление).Общая parseOptionalWorkspaceCwd (строка, ≤4096, абсолютный путь).
B7P1session/prompt передавал непроверенный prompt мосту.validatePrompt (непустой массив объектов), зеркально REST.
B8P1Необработанные сообщения об ошибках моста передавались клиенту.toRpcError преобразует известные ошибки моста в кодированные, безопасные для клиента формы; неизвестные → общее Internal error (полная детализация по-прежнему в stderr).
B9P1nextId использовал последовательные отрицательные числа — клиент, легально использующий отрицательные id, мог столкнуться с коллизией в pending.Id, созданные демоном, теперь строки (_qwen_perm_N), не пересекающиеся с любым клиентским id.
B10P2Тип параметра 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/updateend_turn).

#СерьёзностьНаходкаИсправление
C1P0Обработка ошибок записи SSE “SSE write-failure handling” из раунда 3 была задокументирована, но НЕ реализована — SseStream по-прежнему оставлял это на усмотрение отбрасывающих вызовителей (потоки-зомби).writeRaw теперь управляет этим: первый сбой записи один раз логируется + вызывает close(); doWrite также слушает 'error' (отклоняет немедленно вместо ожидания 'close'); onClose обёрнут в try/catch.
C2P1fromLoopback захватывалось только при initialize + вспомогательная функция уже, чем REST → голоса local-only от последующего POST неверно оценивались.Loopback на запрос передаётся через handlesessionCtx/resolveClientResponse; isLoopbackReq расширен до 127.0.0.0/8 + ::ffff:127.* + ::1 (соответствует REST).
C3P1Маршрутизация ошибок выводила поток из params.sessionId → сбои методов уровня соединения (session/load/resume/close/heartbeat) перенаправлялись в несуществующий поток сессии (тихая потеря).Набор CONN_ROUTED_METHODS; ошибки маршрутизируются так же, как и успешный путь.
C4P1bridge.detachClient никогда не вызывается при завершении → устаревшие идентификаторы клиентов, проставленные bridge, остаются в knownClientIds()/наборах голосования.Реестр принимает DetachSessionFn; closeSessionStream/destroy открепляют каждую принадлежащую сессию (best-effort).
C5P1session/close пропускало локальную очистку, если bridge.closeSession выбрасывал исключение.closeSessionStream перемещён в finally.
C6P2Windows cwd (C:\…) отвергался из-за startsWith('/').path.isAbsolute (с учётом платформы), соответствует REST.
C7P2protocolVersion мог согласовать 0/отрицательное значение.Фиксация Math.max(1, Math.min(requested, 1)); тесты для 0/отриц/огромное/недопустимое.
C8P2session/load/resume принимали пустой sessionId.Отклонять пустой с INVALID_PARAMS.
C9P2Ошибки session/prompt в форме уведомления исчезали бесшумно.Логировать на пути без id.
C10P2Session SSE сбрасывал буферизованные фреймы до заголовков/retry:.open() перед attachSessionStream.
C11P2Дублирующийся локальный logStderr.Общий writeStderrLine из utils/stdioHelpers.
C12P2В документации рекламировались флаг --no-acp-http, тег возможности acp_http и форвардинг fs/*, отсутствующие в v1.Документация приведена в соответствие с поставляемой поверхностью (только переключатель через переменную окружения; fs/*+terminal/* + флаг + тег отмечены как отложенные).
Всё ещё отложено (без изменений): WebSocket + HTTP/2; секрет на соединение для DELETE/владения (токен + рабочее пространство остаётся границей); строгий барьер упорядочивания результатов запросов; приведения as never на границе моста (целевые, отмечены для последующего обсуждения типов адаптера).

14. Пятый цикл рецензирования — доработки PR

Ещё один проход рецензента (qwen3.7-max). Набор тестов 26, проверены вживую.

#СерьёзностьНаходкаИсправление
D1P0resolveClientResponse удалил ожидающую запись ДО вызова respondToSessionPermission. Некорректный голос (result: {}) вызывает ошибку в медиаторе моста — и, так как ожидающая запись уже удалена, abandonPendingForSession в процессе завершения не может её отменить, и запрос агента зависает в ожидании голоса, который никогда не обработается (владелец токена может заблокировать сессию одним некорректным POST).Обернуть голос в try/catch; при любой ошибке использовать cancelAbandonedPermission, чтобы медиатор всегда освобождался. Добавлен новый тест для сценария некорректного голоса.
D2P1onClose потока сессии прерывал только цикл событий, но не binding.promptAbort — отключение клиента (закрытие вкладки / потеря сети) оставляло выполняющийся запрос активным (квота + FIFO) до истечения TTL простоя.Теперь onClose также прерывает promptAbort сессии.
D3P1Когда pumpSessionEvents отклонялся, .catch только логировал — поток SSE оставался открытым, отправляя пульс, но не доставляя данные (зомби, без сигнала переподключения).Теперь .catch также вызывает closeSessionStream(sessionId).

15. Шестой цикл рецензирования — доработки PR

Ещё один проход рецензента (qwen3.7-max). Набор тестов 28, проверены вживую.

#СерьёзностьНаходкаИсправление
E1P0handlePrompt перезаписывал binding.promptAbort без прерывания предыдущего контроллера — два параллельных session/prompt для одной сессии оставляли первый сиротой (он выполнялся до конца в FIFO моста, не прерываемый session/cancel).Прерывать предыдущий promptAbort перед установкой нового. Добавлен тест.
E2P0Путь, где subscribeEvents бросает исключение, отправлял уведомление stream_error, а затем делал return (разрешался) — .catch вызывающего кода никогда не срабатывал, оставляя поток SSE зомби (пульс, нет событий, нет сигнала переподключения).Повторно бросать исключение после уведомления, чтобы .catch вызывающего кода закрыл поток. Тест проверяет закрытие запроса.
E3P1Пульс SSE не отмечал соединение как активное — длинный запрос без промежуточных событий дольше 30 минут попадал под сборку при простое (потоки и запросы уничтожались).SseStream принимает хук onHeartbeat; оба GET-обработчика передают () => conn.touch().
E4P2.catch в pumpSessionEvents закрывал по sessionId — переподключение между броском исключения и микротаской могло убить НОВЫЙ поток.Защита по идентификатору: закрывать только если binding.stream всё ещё этот поток.
E6P2sendSession автоматически создавал привязку — поздний кадр pump/reply после closeSessionStream воскрешал призрачную привязку, которая буферизировала до 256 кадров навсегда.Теперь sendSession только ищет: отбрасывает кадры, если у сессии нет активной привязки.
E5принятоsession/load/resume не отклоняют, когда сессия уже принадлежит другому активному соединению («угон»).Принято, не изменено: граница доверия демона — это токен + привязка к одному рабочему пространству, и подключение нескольких клиентов является намеренным (мост по дизайну многоклиентский; REST имеет то же свойство). Владелец токена не получает дополнительных возможностей, которых у него нет через REST. Отслеживается вместе с другими пунктами границы токена (владение DELETE, §13).

16. Review round 7 — PR fold-ins

Ещё один обзорный проход (qwen3.7-max). Набор 30 тестов, проверено вживую.

#СерьёзностьНаходкаИсправление
F1P0Гонка TOCTOU в session/close: ownedSessions.delete выполнялся только в finally (после await), так что два параллельных закрытия оба проходили requireOwned → второй получал вводящее в заблуждение сообщение об ошибке + лишнее закрытие моста.Удалить владельческую привязку СИНХРОННО перед await; закрытие моста выполняется один раз. Добавлен тест.
F2P1Жизненный цикл pump: чистое завершение итератора (подпроцесс завершился, done разрешено) не вызывало .catch → зомби-поток; а ошибка итератора в середине потока не отправляла stream_error.pumpSessionEvents оборачивает весь цикл (синхронные и средне-потоковые ошибки отправляют stream_error, затем пробрасывают ошибку); потребитель .then(onDone, onErr) закрывает поток на ОБОИХ путях (с защитой по identity). Добавлены тесты.
F3P2Отклонение подключения по 503 (исчерпание емкости) не логировалось в stderr.writeStderrLine со значением емкости.
F4P2Разворот _qwen/notify stream_error позволял event.data.kind перекрывать дискриминатор.Сначала разворот, затем kind: 'stream_error'.
F5P2MAX_WORKSPACE_PATH_LENGTH объявлен повторно (= 4096) в отличие от канонического значения в fs/paths.js.Импортировать из ../fs/paths.js (без расхождений).
F6P2isObjectParams дублирует json-rpc.isObject.Импортировать isObject.
F7P2Прямой 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}categorymodel/mode/thought_level;而 set_model 仍走 unstable_setSessionModel
  • 规范保留 _ 前缀给扩展,示例为域风格 _zed.dev/…;厂商数据放 _meta 按域名分键。

落地:

  • 命名空间 _qwen/ → 反向域名 _qwen/_meta 统一 _meta:{ "qwen": … }(含 initialize 能力广告与 session/request_permission 的 requestId)。
  • 模型 + 审批模式 → 标准 session/set_config_optionconfigId:"model"|"mode"),路由到现有 bridge.setSessionModel/setSessionApprovalModesession/new 结果广告 configOptions(取自子进程会话状态 getSessionContextStatus().state.configOptions,已是 ACP 形状)。删除厂商 _qwen/session/set_model
  • REST(http+sse) 无需同步修改:两 transport 共用同一 bridge,状态天然一致。

17.2 Новые методы /acp, добавленные в этом выпуске (bridge уже поддерживает, 1:1 соответствует REST)

REST/acpbridge
POST /session/:id/model / approval-modeстандартный session/set_config_option (model/mode)setSessionModel / setSessionApprovalMode
GET /session/:id/context_qwen/session/contextgetSessionContextStatus
GET /session/:id/supported-commands_qwen/session/supported_commandsgetSessionSupportedCommandsStatus
PATCH /session/:id/metadata_qwen/session/update_metadataupdateSessionMetadata
GET /workspace/{mcp,skills,providers,env,preflight}_qwen/workspace/{…}getWorkspace*Status
POST /workspace/init_qwen/workspace/initinitWorkspace
POST /workspace/tools/:name/enable_qwen/workspace/set_tool_enabledsetWorkspaceToolEnabled
POST /workspace/mcp/:server/restart_qwen/workspace/restart_mcp_serverrestartMcpServer

(Уже выровнено: 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

#SeverityFindingFix
G1P1 (regression)Переподключение сессионного потока прервало выполняемый prompt: attachSessionStream закрывал СТАРЫЙ поток перед установкой нового, и старый поток безусловно прерывал promptAbort — так что при переподключении клиента (сбой сети/роуминг) терялся выполняемый prompt.Устанавливать новый поток ДО закрытия старого; защищать по идентичности прерывание prompt в onClose (прерывать только если ЭТО всё ещё активный поток сессии). Добавлен тест (prompt переживает переподключение).
G2P2session/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» ещё необходимо:

  1. Follow-up PR 1 — дополнение возможностей acp-bridge (предварительно / bridge-first): Добавление методов файлового I/O, потока устройств, CRUD агентов, CRUD памяти в HttpAcpBridge; маршруты REST переключаются на bridge (устранение дрейфа прямого подключения к сервисам уровня маршрутов).
  2. 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/…'), взаимодействуют с нашими обработчиками (идентичная форма на проводе).
Last updated on