RFC: “qwen tag” — персистентный мультиплеерный агент-резидент канала для qwen-code (с приоритетом на DingTalk)
Статус: Черновик (v2) Дата: 2026-06-25 Автор: (qwen-code)
История изменений (v1 → v2)
В этой редакции закрыты все Open Decision из v1 (теперь это Resolved Decisions, §9) и исправлены семь дефектов корректности/согласованности, выявленных при ревью. Два ключевых изменения:
- OD-1 больше не является блок-фактором — это зафиксированная архитектура. Phase 0 поставляется на текущем пути
AcpBridge; Phase 1+ мигрирует хостинг каналов в демонqwen serve(черезDaemonChannelBridge/ daemon channel runner) для повторного использования пер-сессионной FIFOpromptQueue,MultiClientPermissionMediator,eventBus,/workspace/memoryи rate-limit. Каждый раздел, где ранее было написано “OD-1 open / gates everything”, теперь отражает принятое решение, а обязательство по использованию демона распространено на §1, §4, §5, §6.1, §6.2, §6.3, §6.4 и §7. - Проактивный путь выполнения (fire-path) переработан под демон, на котором он будет фактически работать.
dispatchProactiveиз v1 был написан под семантикуAcpBridge(канал-sidesessionQueues). При миграции на демонDaemonChannelBridge.prompt()выбрасываетPrompt already in flightпри перекрытии (DaemonChannelBridge.ts:257-261) вместо постановки в очередь. v2 сериализует проактивные промпты черезChannelBase.sessionQueuesдля обоих вариантов, поэтому защита от выброса исключения никогда не срабатывает, а инвариант “никогда не отменяется” (never-cancellable) явно зафиксирован (§6.2).
Включенные решения и исправления:
- OD-2 решено: один процесс на workspace/channel.
- OD-3 решено: Phase 1
first-responder+ единыйclientIdна уровне канала; Phase 2consensus/designatedпосле появления ростераsenderId→clientId+ жизненного цикла; автоматический отказ (auto-deny) для инструментов высокого риска при проактивных ходах. - OD-4 решено: в общей (thread) группе
/clearтребует явногоconfirmи ограниченconfig.allowedUsers, если этот список задан;/statusтолько для чтения. (Дефисный/clear-channelне парсится грамматикой слэш-команд; настоящий owner-gate для каждого участника ждет модели идентификации — OD-3/OD-11.) - OD-5 решено: исправить устаревший JSDoc в
types.ts:42на'steer'; профиль группы tag явно устанавливаетdispatchMode: 'followup'. - OD-6 решено: префикс
[senderName]для каждого хода, без привязки кinstructedSessions; одно новое опциональное полеEnvelope—alreadyPrefixed, чтобы синтетический повторный вход в режимеcollectпропускал повторное добавление префикса. (Исправляет утверждение v1 “нет новых полей envelope” — Fix #2.) - OD-7 решено на основе проверенных фактов о DingTalk API (§6.2/§6.5), элементы с низкой уверенностью по-прежнему помечены.
- OD-8 решено: планировщик gateway/daemon является единственным владельцем cron; сессия tag не запускает свой внутри-сессионный
Sessioncron; два хранилища cron находятся на непересекающихся путях, поэтому коллизия возможна только в случае, если оба планировщика запущены для одних и тех же задач. - OD-9 решено: агрегация “org” для каждого процесса + окна для каждого канала, побеждает самый строгий, фиксированное дневное окно; v1 оценивает токены на стороне канала и читает путь использования демона после перехода на хостинг в демоне.
- OD-10 решено: добавить область
channel(+channelKey) вwriteContextFile.ts; channel-base получает запись/чтение через callback на уровне CLI, внедряемый черезChannelBaseOptions(без зависимостиchannel-base → core); пользовательское глобальное расположение~/.qwen/channels/memory/. - OD-11 решено:
senderNameтолько для справки;clientId— единственный принципал безопасности; кольцо аудита в памяти + файл follow-up с добавлением в конец~/.qwen. - OD-12 решено: требовать
--require-auth+ токен для любого развертывания с демоном, не являющегося loopback.
Исправления корректности помимо решений OD:
- Fix #1 — конкурентность проактивного fire-path переработана для пути демона (§6.2), инвариант “никогда не отменяется” применяется как для варианта
AcpBridgeиз Phase-0, так и для варианта демона из Phase-1+. - Fix #2 — внутреннее противоречие удалено: §6.1/G2 больше не утверждает “нет новых полей envelope”; он признает наличие одного поля
alreadyPrefixed. - Fix #3 — спроектирована проводка памяти (§6.3): точное изменение
ChannelBaseOptions(callback-иreadChannelMemory/writeChannelMemory) и то, кто их создает/внедряет вstart.ts, при этом первичное чтение при загрузке (bootstrap read) один раз за сессию повторно использует gateinstructedSessions. - Fix #4 — спроектирован флаг возможности
canColdSend(§6.2): где он объявлен, как его устанавливают DingTalk/Feishu и как планировщик выдает явную ошибку (fails loud). - Fix #5 — уточнение непересекающихся хранилищ OD-8 (§6.2): хранилище gateway и хранилище
Session— это разные пути; единственный риск коллизии — это сессия tag, которая также запускает внутри-сессионный cron, что закрыто gate OD-8. - Fix #6 — принудительное применение оценочного бюджета (§6.4): оценка может выдавать WARN/alert, но никогда не должна жестко отклонять (hard-decline) промпт пользователя; жесткое отклонение (HARD-decline) только по реальным цифрам использования демона.
- Fix #7 — атрибуция аудита при
followup(§6.4): передачаsenderIdвместе с промптом в очереди, чтобы вызов инструмента/разрешение атрибутировались ходу, который фактически выполняется, а не последнему поставленному в очередь отправителю.
Проверенные факты из v1 (топология AcpBridge, auto-approve в AcpBridge, абстрактный sendMessage, области, значения по умолчанию парсера) сохранены без изменений.
1. Обзор
“qwen tag” — это общий агент qwen-code, который живет внутри чат-канала (в первую очередь группы DingTalk, во вторую — Feishu), и которого любой участник этого канала вызывает с помощью @-упоминания. После вызова он запускает полный цикл агента qwen-code (инструменты, редактирование файлов, shell, MCP) для привязанного workspace, потоково передает свою работу обратно в канал по мере выполнения, запоминает канал между ходами и перезапусками и может действовать проактивно или по расписанию, не дожидаясь запроса. Это повторяет форм-фактор Claude Tag — единый персистентный мультиплеерный агент, который является резидентом комнаты, а не ботом для личных сообщений 1-на-1, — но он полностью построен на существующем стеке адаптеров каналов qwen-code (qwen channel start, packages/channels/*) и демоне qwen serve, а не на новом хостинговом сервисе.
Преднамеренная рамка этого RFC заключается в том, что реактивная половина форм-фактора по большей части уже поставлена, а проактивная/memory половина — нет. Части, которые делают ответного агента в стиле Claude Tag сложным — долгоживущий процесс, мультиплексирующий сессии, транспорт агента, сохраняющий инвариант “один промпт на сессию”, мультиплеерная маршрутизация сессий, контроль доступа для каждого канала, потоковый рендеринг карточек и долговременное сохранение сессий, — уже существуют и используются текущими адаптерами каналов. То, чего не хватает, — это четко ограниченный набор возможностей, которые превращают реактивного бота-ответчика в агента-резидента: атрибуция отправителя в общих сессиях, проактивный/планируемый путь вывода, память для каждой комнаты и мультиплеерное управление. Этот RFC сводит этот пробел к четырем областям разработки и специфицирует их в рамках Phase 0–2.
Примечание о “80%”: в ранних черновиках это формулировалось как “~80% поставлено”. Эта цифра непроверяема и преувеличивает ситуацию — весь проактивный движок (Build Area 2) и память для каждой комнаты (Build Area 3) совершенно новые, а в DingTalk в частности вообще нет пути для инициирования исходящих сообщений. Вместо этого мы формулируем это так: “реактивный путь построен; проактивный путь и пути памяти — нет”.
Факт топологии, ограничивающий весь RFC
Существует два различных способа подключения адаптера канала к агенту qwen, в двух разных процессах, и их смешение — самая распространенная ошибка в ранних черновиках:
qwen channel start <name>(поставляемый путь).start.tsсоздаетnew AcpBridge(bridgeOpts)(start.ts:213,268,356,435), иAcpBridge.start()порождает дочерний процессnode <cliEntryPath> --acp(AcpBridge.ts:53-70), который общается по ACP через NDJSON на stdio. Этот дочерний процесс — автономный агент, а не HTTP-демонqwen serve. В этой топологии нет HTTP-демона, нет маршрута/workspace/memory, нетMultiClientPermissionMediator, нет кольца воспроизведенияeventBusи нет демоническойpromptQueue— всё это находится вpackages/acp-bridge+packages/cli/src/serve, которыеqwen channel startникогда не инстанцирует. Сериализация промптов здесь выполняется полностью на стороне канала с помощьюChannelBase(мьютексactivePromptsвChannelBase.ts:356-391+ цепочкаsessionQueuesв:394-470) и собственным инвариантом ACP “один промпт на сессию” дочернего процесса.AcpBridge.requestPermissionавтоматически одобряет каждый вызов инструмента (AcpBridge.ts:108-118).qwen serve+DaemonChannelBridge(хостинг в демоне).DaemonChannelBridge(packages/channels/base/src/DaemonChannelBridge.ts) — это внутрипроцессный мост, чейsessionFactoryсоздает объектыSessionдемона. Этот путь запускает каналы внутри демона и, таким образом, наследует FIFOpromptQueueизacp-bridge(bridge.ts:232,2855,3082),MultiClientPermissionMediator,eventBusи HTTP-маршруты.qwen channel startсегодня его не инстанцирует (ноль ссылок вstart.ts). Один острый угол, формирующий проактивный дизайн:DaemonChannelBridge.prompt()не ставит в очередь — он выбрасываетPrompt already in flightпри перекрытии (DaemonChannelBridge.ts:257-261); FIFOpromptQueue, до которой он в итоге доходит, находится на стороне демона/acp-bridge, за этим внутрипроцессным защитным выбросом. Поэтому проактивный движок должен сериализоваться на уровне канала (§6.2).
Зафиксированная архитектура (было OD-1, теперь решено): механика мультиклиентского демона используется повторно путем миграции хостинга каналов в демон qwen serve начиная с Phase 1.
- Phase 0 поставляется на текущем пути
AcpBridge(внедрение идентификации не требует ни HTTP-маршрутов, ни медиатора). - Phase 1+ запускает каналы под демоном
qwen serve(черезDaemonChannelBridgeили daemon channel runner), поскольку проактивному движку, сохранению памяти для каждой комнаты и управлению требуются долговечность, маршруты,promptQueue, медиатор и шина событий демона.
Это больше не “открыто” и не “блокирует”: проводка Phase 0 добавляет путь подключения DaemonChannelBridge (или флаг --daemon <url>), чтобы миграция была доступна в тот момент, когда начинается Phase 1. Планировщик, принадлежащий gateway (§6.2), построен нейтральным к миграции, чтобы он работал одинаково до и после переключения.
Что такое “qwen tag” конкретно
Развертывание “qwen tag” — это единый процесс агента, привязанный к одному workspace, плюс адаптер qwen channel start dingtalk, настроенный так, что вся группа использует одну сессию агента. Должны совпадать две различные концепции области (scope):
- Область маршрутизации канала (
ChannelConfig.sessionScope, используетсяSessionRouter.routingKey()): определяет, как входящие сообщения сопоставляются с ключом маршрутизации. Для tag это должно быть'thread', чтобы вся группа использовала один ключ маршрутизации (channel:(threadId||chatId),SessionRouter.ts:53). Значение по умолчанию парсера —'user', а не'thread'(config-utils.ts:91-92), поэтому в рецепте tag его нужно задать явно. - Область сессии Bridge/ACP (
sessionScopeвDaemonChannelBridge/acp-bridge): определяет, как демон использует базовую сессию ACP.DaemonChannelBridge.newSession()по умолчанию устанавливает это в'thread'(DaemonChannelBridge.ts:229,240); внутрипроцессный путьacp-bridgeпо умолчанию устанавливает'single'(bridge.ts:709). Это отдельный регулятор, отличный от области маршрутизации канала, и его нет на путиqwen channel start(AcpBridge.newSession(cwd)принимает толькоcwd,AcpBridge.ts:131).
При их наличии:
- Один агент на комнату, вызывается по упоминанию.
GroupGateобеспечивает соблюдениеrequireMention(по умолчаниюtrue,GroupGate.ts:49), поэтому агент молчит, пока его не упомянут через@или пока это не будет ответ боту (GroupGate.ts:51). Мультиплеерный ключ — этоsessionScope: 'thread', сопоставляемый сchannel:(threadId||chatId)(SessionRouter.ts:50-53), поэтому каждый участник повторно использует один и тот жеsessionIdнезависимо от отправителя. - Настоящая многоэтапная работа с инструментами. Входящие сообщения становятся промптами через
ChannelBase.handleInbound(), который строитpromptTextиз текста сообщения, контекста цитаты ответа, путей к файлам вложений и (один раз за сессию)config.instructions(ChannelBase.ts:316-347), затем диспатчит черезbridge.prompt(sessionId, promptText, { imageBase64, imageMimeType })(ChannelBase.ts:425—promptTextэто позиционный аргумент; объект опций несет только поля изображения). - Потоково передает свою работу обратно в комнату. Адаптеры рендерят инкрементальный вывод как нативные карточки платформы (Feishu create/update/finalize,
markdown.ts; разбивка markdown на части в DingTalk,DingtalkAdapter.ts:144-169). - Запоминает канал.
SessionRouter.persist()/restoreSessions()долговременно хранятsessionId, target иcwdи восстанавливают их черезbridge.loadSession()при перезапусках (SessionRouter.ts:168-244); память workspace (QWEN.md/~/.qwen/QWEN.md) читается/пишется черезGET/POST /workspace/memory(workspace-memory.ts). Эта память имеет область workspace/global, а не для каждой комнаты — см. Build Area 3. - Может действовать проактивно / по расписанию. Это та половина, которой еще не существует сквозным образом (end-to-end) и которая является сердцем Phase 1.
2. Мотивация
Инфраструктура, обычно требуемая мультиплеерному ответному агенту-резиденту, уже реализована в этом репозитории. Действительно не хватает работы только над четырьмя областями.
| Возможность, необходимая для форм-фактора Tag | Уже присутствует (ссылка) |
|---|---|
| Долгоживущий мульти-сессионный процесс | AcpBridge порождает долгоживущий дочерний процесс --acp (AcpBridge.ts:53-70); путь демона добавляет пер-сессионную FIFO promptQueue (bridge.ts:232,2855,3082) |
| Мультиплеерная маршрутизация “одна комната, одна сессия” | Область 'thread' в SessionRouter (SessionRouter.ts:53), переопределение для каждого канала setChannelScope() (SessionRouter.ts:40) |
| Семантика вызова по упоминанию | requireMention в GroupGate по умолчанию true (GroupGate.ts:49-52) |
| Контроль доступа + онбординг | allowlist в SenderGate + флоу с кодом сопряжения; гейты применяются сначала к группе, затем к отправителю (ChannelBase.ts:240-252) |
| Долговременное сопоставление сессий при перезапусках | Сохранение в SessionRouter (SessionRouter.ts:168-244) |
| Чтение/запись памяти workspace | GET / POST /workspace/memory (workspace-memory.ts); только области workspace + global; только для демона |
| Контроль разрешений для нескольких участников + аудит (только для демона) | Четыре политики MultiClientPermissionMediator, включая кворум consensus (permissionMediator.ts:621-637); отдельное кольцо аудита разрешений (permission-audit.ts) |
| Аутентификация, rate limiting, безопасность loopback (только для демона) | Глобальный bearer-токен (auth.ts:259-266) + многоуровневый rate limit для каждого clientId/IP (rate-limit.ts) |
| Примитив push внутри сессии (фоновые задачи) | Очередь уведомлений Session + setNotificationCallback() передает вывод фоновых задач/монитора/shell в открытую сессию (Session.ts:688-689,2638-2668); isIdle() учитывает это (Session.ts:777) |
| Доставка на платформу (DingTalk + Feishu) | Рабочие адаптеры с потоковыми карточками, медиа, реакциями (DingtalkAdapter.ts, FeishuAdapter.ts) |
Поскольку Phase 1+ работает под демоном (зафиксированная архитектура, §1), строки “только для демона” выше становятся доступными возможностями для проактивного движка, сохранения памяти и управления, а не просто “целями, если мы мигрируем”.
Четыре области разработки, подробно раскрытые в §6:
- Конфигурация + идентификация для объявления tag (Phase 0). Задокументированный рецепт конфигурации —
sessionScope: 'thread',groupPolicy,requireMention,instructions,dispatchMode— плюс пробел атрибуции отправителя:handleInbound()намеренно не внедряетsenderNameвpromptText(ChannelBase.ts:316-347;senderNameиспользуется только для контроля доступа вChannelBase.ts:246). В общей сессии'thread'агент не может понять, кто говорит. Phase 0 внедряет маркер отправителя, точно так же, как уже внедряется контекст цитаты ответа (ChannelBase.ts:318). - Проактивный движок / движок инициирования исходящих (Phase 1). Сегодня нет проактивного пути на границе канала:
ChannelBase.sendMessage()абстрактен (ChannelBase.ts:81) и вызывается только из ответа. В DingTalksendMessage()может отвечать только через короткоживущийsessionWebhook, кэшируемый для каждогоconversationIdпри входящем сообщении (DingtalkAdapter.ts:134-142), поэтому “холодной” группе вообще нельзя написать (DingtalkAdapter.ts:137-141возвращает управление молча). Phase 1 добавляет планировщик-резидент демона и проактивный путь отправки для DingTalk. - Память-резидент канала + извлечение (Phase 2, половина памяти). Память workspace — это workspace/global, а не для каждой комнаты:
POST /workspace/memoryпринимает толькоscope: 'workspace' | 'global'(workspace-memory.ts:118-125) и является маршрутом мутации со строгой аутентификацией (deps.mutate({ strict: true }),workspace-memory.ts:114). Tag, который “помнит этот канал”, нуждается в пространстве имен памяти для каждой комнаты. - Мультиплеерное управление + безопасность (Phase 2, половина управления). Политика разрешений, подходящая для групп, ограничители (guardrails) для проактивных действий и судебный аудит, построенные на существующей механике уровня
clientId(а не уровня человеческой идентификации).
3. Цели и не-цели
Цели
- G1 — Задокументировать и поставить конфигурацию “tag” для DingTalk: копируемый рецепт
channels.dingtalk(явныйsessionScope: 'thread',groupPolicy: 'allowlist'с перечисленным ID группы,requireMention: true,instructionsи намеренно выбранныйdispatchMode), дающий работающего мультиплеерного агента-резидента, с повторным использованиемparseChannelConfig()и существующих гейтов. Рецепт должен подчеркивать различие между областью маршрутизации и областью ACP, а также то, что значение по умолчанию парсера'user'должно быть переопределено. - G2 — Атрибуция отправителя в общих сессиях. Внедрить маркер отправителя для каждого сообщения в
promptText, чтобы агент мог различать говорящих в группе с областью'thread', не нарушая внедрениеinstructionsодин раз за сессию, отслеживаемоеinstructedSessions(ChannelBase.ts:344-346). Маркер для каждого сообщения (говорящий меняется каждый ход) и НЕ должен зависеть отinstructedSessions. Это требует одного нового опционального поляEnvelope,alreadyPrefixed(types.ts), чтобы синтетический повторный вход в режимеcollectне добавлял префикс дважды — см. §6.1. (В v1 это ошибочно описывалось как “только формат, без новых полей”.) - G3 — Проактивный движок. Механизм для (a) инициирования вывода в канал, который только что не писал, и (b) срабатывания по расписанию независимо от любой открытой интерактивной сессии, с доставкой через существующий путь уведомлений для каждой сессии, где это возможно, — включая API проактивной отправки DingTalk и сохраняемое хранилище
openConversationIdс определенным владельцем обновления токена. Должен соблюдать инвариант ACP “один промпт на сессию” (NG6) путем сериализации черезChannelBase.sessionQueues(никогда не отменять ход человека черезsteer), при обеих топологиях. - G4 — Память-резидент канала. Пространство имен памяти для каждой комнаты и путь извлечения, надстроенные над существующей механикой
/workspace/memoryи механизмомinstructions. Дизайн добавляет новую областьchannel(+channelKey) вwriteContextFile.tsи достигает ее изchannel-baseчерез callback на уровне CLI, внедряемый черезChannelBaseOptions(без зависимостиchannel-base → core). - G5 — Мультиплеерное управление. Политика разрешений, подходящая для групп, ограничители для проактивных действий и аудит, построенные на
MultiClientPermissionMediatorи кольце аудита разрешений. Должен учитывать тот факт, что голоса атрибутируютсяclientId, а не человеческой идентификации, и что в одной общей сессии'thread'каждый участник группы является одним и тем же клиентом демона. - G6 — Паритет Feishu для всего в G1–G5, рассматривается как follow-up. Стабильный
tenant_access_tokenFeishu уже поддерживает проактивные отправки в любой чат просто поchatId(FeishuAdapter.ts:622-651), поэтому Feishu не нужен новый API отправки для G3 — только механизм пробуждения/планирования на уровне демона. Feishu объявляетcanColdSend = true. - G7 — Повторное использование вместо изобретения. Каждая область разработки расширяет существующий механизм (гейты, маршрутизатор, мост, медиатор, маршруты памяти, путь уведомлений внутри сессии, cron), а не вводит параллельную подсистему.
Нецели
- NG1 — Не является хостинговым мультитенантным SaaS. Процесс “qwen tag” — это один агентный процесс, привязанный к одному рабочему пространству (
serve.ts:165-171; несколько рабочих пространств = один демон на каждое рабочее пространство на отдельных портах). Нет централизованной плоскости управления. - NG2 — В этом RFC нет индивидуальной идентификации пользователей, биллинга или бюджетов затрат. Модель идентификации демона — это один глобальный bearer-токен (
auth.ts:259-266) и атрибуция на уровнеclientIdво всей шине событий и аудите разрешений. Мы добавляем маркеры отправителя в промптах (G2), но не вводим аутентифицированные пользовательские субъекты, пользовательские квоты или отслеживание затрат. Маркеры отправителя — это информационный текст в промпте, а не граница аутентификации: каждый участник группы использует единые учетные данные рабочего пространства демона, а в общей сессии'thread'это один и тот жеclientIdдемона. - NG3 — Шлюз с множественной идентификацией для Фазы 3 выходит за рамки данного документа и упоминается только как ориентир на будущее. Этот RFC охватывает Фазы 0–2.
- NG4 — Feishu является вторичным, а не равнозначным основному. DingTalk выступает в качестве эталонной реализации и источника всех рабочих примеров.
- NG5 — Slack и другие западные платформы выходят за рамки. Зарегистрированные типы каналов:
telegram,weixin,dingtalk,feishuиqq(channel-registry.ts:10-14); адаптер для Slack отсутствует. - NG6 — Инвариант ACP «один промпт на сессию» не изменяется. Запланированный/проактивный промпт — это просто еще одна запись в
sessionQueuesканала; он не может выполняться параллельно с пользовательским ходом в той же сессии и не может его отменить. - NG7 — Не создается новый движок хранилища памяти с привязкой к чату. Память, резидентная в канале (G4), добавляет пространство имен поверх существующих файлов
QWEN.md/AGENTS.mdс файловым хранением; никаких векторных БД или баз данных для каждой комнаты.
4. Оценка текущего состояния
Создано (B), частично (P), отсутствует (M). В колонке «Файл» указан авторитетный символ. «Топология» указывает, существует ли возможность на пути канала AcpBridge (A), пути демона qwen serve (D) или на обоих путях, а также, поскольку Фаза 1+ обязана работать под демоном, содержит пометку «→D», если именно миграция открывает данную возможность.
| Возможность | Текущий qwen-code (файл / символ) | Топология | Пробел | Размер |
|---|---|---|---|---|
| Маршрутизация “один чат — одна сессия” | SessionRouter.routingKey() 'thread' (SessionRouter.ts:44-60) | A+D | Область действия по умолчанию — 'user' (config-utils.ts:91-92); оператор должен установить 'thread' | Конфиг (S) |
| Вызов по упоминанию | GroupGate.requireMention по умолчанию true (GroupGate.ts:49-52) | A+D | Отсутствует — уже корректно | — |
| Контроль доступа / онбординг | SenderGate allowlist + pairing (ChannelBase.ts:240-252) | A+D | Отсутствует | — |
| Долговременное сопоставление сессий | SessionRouter.persist/restoreSessions (SessionRouter.ts:168-244) | A+D | Отсутствует | — |
| Атрибуция отправителя в промпте | handleInbound() собирает promptText без senderName (ChannelBase.ts:316-347) | A+D | senderName никогда не внедряется; агент не может определить, кто говорил; требуется новый Envelope.alreadyPrefixed | Код (S) |
| Сериализация промптов | ChannelBase.sessionQueues/activePrompts (:356-470); демон promptQueue (bridge.ts:2855) | A (канал) / D (демон) | DaemonChannelBridge.prompt() ВЫБРАСЫВАЕТ ИСКЛЮЧЕНИЕ при перекрытии (:257-261) — проактивный движок должен сериализовать на стороне канала; dispatchMode по умолчанию 'steer' отменяет параллельные (:354,371-379) | Конфиг + Код (S) |
| Инициирование исходящих / проактивная отправка | ChannelBase.sendMessage() абстрактный (:81); DingTalk только через webhook (DingtalkAdapter.ts:134-142) | A+D | Нет проактивного шва; в холодной группе DingTalk невозможна отправка сообщений; требуется флаг возможности canColdSend | Код (L) |
| Планировщик на уровне демона | Cron привязан к сессии (Session.ts:667-668), завершается при dispose() (:790-812) | A+D (шлюз) → D (аудит/повторное использование очереди) | Нет эндпоинта планировщика демона в serve/ или channels/; планировщик шлюза является единственным владельцем (OD-8) | Код (L) |
| Примитив push-уведомлений в сессии | setNotificationCallback (Session.ts:2638-2668) | A+D | Доставляется только в активную сессию; не может разбудить удаленную сессию | (повторное использование) |
| Память для каждого чата | /workspace/memory ограничивает области workspace|global (workspace-memory.ts:118-125) | Только D | Нет области действия чата/канала; требуется новая область действия channel + callback на уровне CLI (без зависимости от ядра) | Код (M) |
| Голосование за разрешения от нескольких участников | MultiClientPermissionMediator 4 политики (permissionMediator.ts:621-637) | D (унаследовано Фаза 1+) | AcpBridge автоматически одобряет (AcpBridge.ts:108-118); голоса учитываются для каждого clientId, один клиент на канал | Код (L) |
| Аудиторский след | PermissionAuditRing FIFO 512 (permission-audit.ts) | D + кольцо на стороне канала | Нет человеческого senderId; в памяти, теряется при перезапуске; дополнение с добавлением только в конец в ~/.qwen | Код (M) |
| Бюджет токенов / затрат | отсутствует (rate-limit только по количеству запросов, rate-limit.ts) | реестр на стороне канала + использование D | Нет счетчика расходов; оценки v1 (информационные), реальное списание только при хостинге на демоне | Код (M) |
| Область действия инструментов/MCP для каждого канала | coreTools/allowedTools/excludeTools (config.ts:727-729); MCP allow-filter (:3327-3333) | для каждого Config | Нет пути аргумента запуска от канала к дочернему процессу --acp (AcpBridge); Config для каждого демона после размещения на хостинге | Код (M) |
| Проактивная отправка DingTalk | не реализовано (только robot/emotion, messageFiles/download) | A+D | Новый эндпоинт + сохраненный openConversationId + обновление токена (проверенный контракт, §6.2) | Код (L) |
| Проактивная отправка Feishu | sendMessage() через tenant_access_token (FeishuAdapter.ts:622-676) | A+D | Отсутствует — canColdSend = true | — |
Ключ к размерам: S = конфигурация/небольшой код, M = изменение модуля + интерфейса, L = изменение в нескольких пакетах или новая подсистема.
5. Архитектура
qwen tag — это не новый рантайм. Это четыре тонких слоя, привитых к существующему стеку адаптеров. Базовый слой уже обеспечивает работу агента с поддержкой многопользовательского режима, выполнения инструментов и MCP, доступного через чат-канал. Четыре новых слоя компенсируют следующие пробелы: (1) кто говорит — идентификация отправителя никогда не попадает в промпт; (2) действия без запроса — нет пути инициации исходящих сообщений, cron внутри сессии завершается вместе с сессией; (3) запоминание канала — память является глобальной для рабочего пространства; (4) управление общим мозгом — аутентификация представляет собой один глобальный токен, нет бюджета для каждого канала.
Каждый слой ниже указывает, какую топологию он предполагает (см. §1). Зафиксированное разделение: Фаза 0 на AcpBridge; Фаза 1+ на демоне qwen serve через DaemonChannelBridge.
Базовый слой (существующий) — топология qwen channel start (Фаза 0)
one host, one workspace
┌──────────────────────────────────────────────────────────────────────────────┐
│ qwen channel start dingtalk │
│ │
│ ┌────────────────────┐ Envelope ┌───────────────────────────────────┐ │
│ │ DingtalkAdapter │ ──────────────▶ │ ChannelBase.handleInbound() │ │
│ │ (stream client, │ │ 1 GroupGate.check (mention/ │ │
│ │ webhooks map by │ ◀────────────── │ policy/allowlist) │ │
│ │ conversationId) │ text/markdown │ 2 SenderGate.check (pairing) │ │
│ │ sendMessage() │ │ 3 slash / "!" commands │ │
│ └────────────────────┘ │ 4 router.resolve(...) │ │
│ ▲ sessionWebhook (expires, │ 5 dispatchMode (steer default) │ │
│ │ per inbound msg only) └───────────────┬───────────────────┘ │
│ │ │ sessionId │
│ │ ┌────────────────▼──────────────────┐ │
│ │ │ SessionRouter │ │
│ │ │ routingKey(): user|thread|single │ │
│ │ │ persist() → JSON (crash recovery) │ │
│ │ └────────────────┬──────────────────┘ │
│ │ textChunk / toolCall events ┌────────────────▼──────────────────┐ │
│ └─────────────────────────────── │ AcpBridge (NOT the HTTP daemon) │ │
│ │ spawns child `node <cli> --acp` │ │
│ │ ClientSideConnection over stdio │ │
│ │ requestPermission AUTO-APPROVES │ │
│ └────────────────┬──────────────────┘ │
└──────────────────────────────────────────────────────────┼─────────────────────┘
│ ACP / NDJSON (stdio)
┌──────────────────▼─────────────────────┐
│ child agent process (`--acp`) │
│ one prompt-in-flight per ACP session │
│ in-session cron (Session.ts) — DISABLED│
│ for tag sessions (OD-8); MCP, tools. │
│ NO promptQueue/eventBus/mediator │
└─────────────────────────────────────────┘Топология с хостингом на демоне (Фаза 1+) — qwen serve + DaemonChannelBridge
one host, one workspace, ONE daemon
┌──────────────────────────────────────────────────────────────────────────────┐
│ qwen channel start dingtalk (channels hosted IN the daemon) │
│ ┌────────────────────┐ Envelope ┌────────────────────────────────────────┐│
│ │ DingtalkAdapter │ ──────────▶ │ ChannelBase.handleInbound() ││
│ │ pushProactive() │ ◀────────── │ gates → governor.admit → router ││
│ │ canColdSend = false*│ │ → sessionQueues (FIFO, serialization) ││
│ └────────────────────┘ └───────────────┬────────────────────────┘│
│ ▲ proactive group-send │ bridge.prompt() │
│ │ (openConversationId) ┌───────────────▼────────────────────────┐│
│ ┌──────┴────────────┐ │ DaemonChannelBridge ││
│ │ ChannelCronSched │──fire────────▶│ prompt() THROWS on overlap (:257-261) ││
│ │ (gateway-owned, │ dispatchProa- │ → so all prompts MUST arrive serialized││
│ │ sole cron owner) │ ctive via │ via sessionQueues ││
│ └────────────────────┘ sessionQueues └───────────────┬────────────────────────┘│
│ │ in-process Session │
│ ┌────────────────▼────────────────────────┐│
│ │ daemon: acp-bridge FIFO promptQueue, ││
│ │ MultiClientPermissionMediator, eventBus, ││
│ │ /workspace/memory + /channel routes, ││
│ │ rate-limit, bearer auth ││
│ └──────────────────────────────────────────┘│
└──────────────────────────────────────────────────────────────────────────────┘
* DingTalk canColdSend flips true once the proactive-send path ships (§6.2).Ключевые инварианты, на которые мы опираемся (проверено):
- Область действия треда — это ключ к многопользовательскому режиму.
routingKey()возвращает${channelName}:${threadId || chatId}в режиме'thread'(SessionRouter.ts:53);resolve()повторно использует ключ (:79-83). Область действия по умолчанию —'user'(:25);qwen channel startустанавливает область действия для каждого канала черезrouter.setChannelScope(name, config.sessionScope)(start.ts:361-362) в многоканальном пути, или через конструкторChannelBaseизconfig.sessionScope(ChannelBase.ts:62-64) в одноканальном пути. Для многопользовательского режима оператор должен установитьsessionScope: "thread". - Сериализация промптов. В
AcpBridgenewSession(cwd)принимает толькоcwd(AcpBridge.ts:131), аAcpBridge.prompt()не имеет защиты от параллелизма — сериализация обеспечиваетсяdispatchModeвChannelBase:collectбуферизует (:361-370,445-463),steerотменяет выполняющийся промпт (:371-379),followupвыстраивает в цепочкуsessionQueues(:381-383,394-470). Значение по умолчанию для рантайма —'steer'(:354); в JSDoctypes.ts:42указано'collect'— устарело; v2 исправляет на'steer'(OD-5). На пути демонаDaemonChannelBridge.prompt()выбрасывает исключение при перекрытии (:257-261); демон FIFOpromptQueue(bridge.ts:2855,3082) находится за этой защитой. Следствие (критично для §6.2): все промпты — человеческие и проактивные — должны поступать вbridge.prompt()уже сериализованными черезChannelBase.sessionQueues. sendMessageявляется абстрактным.ChannelBase.sendMessage()—abstract(:81);DingtalkAdapter.sendMessage()(:134-170) отправляет сообщения черезsessionWebhookдля каждогоconversationId, который кэшируется только при входящем сообщении (:516-517) и имеет срок действия — для холодной группы нет кэшированного webhook, и вызов возвращает управление молча (:137-141).- Инварианты демона, унаследованные в Фазе 1+.
MultiClientPermissionMediator(permissionMediator.ts:621-637), кольцо воспроизведенияeventBus(eventBus.ts:92), FIFOpromptQueueдля каждогоSessionEntry(bridge.ts:2855-3082) становятся доступными, когда каналы размещаются подqwen serve(зафиксировано, §1).
Четыре новых слоя
┌───────────── governance (Layer 4) ─────────────┐
│ per-channel turn/cost budget gate │
│ proactive allowlist, quiet hours, kill switch │
└───────────────────────┬─────────────────────────┘
│ wraps all inbound + outbound
inbound ┌──────────────────────────▼─────────────────────────┐ outbound
───────▶ │ identity injection (Layer 1) │ ────────▶
│ prefix promptText with speaker + channel context │
└──────────────────────────┬─────────────────────────┘
│
┌──────────────────────────▼─────────────────────────┐
│ channel memory (Layer 3) │
│ per-channel fragment, injected at session start; │
│ persisted via CLI-layer callback (core helper) │
└──────────────────────────┬─────────────────────────┘
│
┌──────────────────────────▼─────────────────────────┐
│ proactive engine (Layer 2) │
│ gateway scheduler → sessionQueues → bridge.prompt → │
│ channel.pushProactive() w/ cold-group fallback │
└─────────────────────────────────────────────────────┘Слой 1 — Инъекция идентификации. Топология: обе; не требует демона. handleInbound() никогда не помещает senderName в promptText (ChannelBase.ts:246 читает его только для SenderGate.check(); Envelope.senderName существует в types.ts:69). Дизайн: одна точка инъекции с конфигурационным затвором в handleInbound(), после префикса referencedText (:316-319), управляемая envelope.isGroup, плюс новый флаг Envelope.alreadyPrefixed для повторного входа collect. Подробно описано в §6.1.
Слой 2 — Проактивный движок. Топология: планировщик, принадлежащий шлюзу, нейтральный к миграции; работает под демоном в Фазе 1+. Cron внутри сессии завершается при dispose() (Session.ts:790-803); эндпоинт планировщика демона отсутствует. DingtalkAdapter.sendMessage() не может достичь холодной группы (:137-141). Дизайн: планировщик, резидентный в шлюзе, который инициирует срабатывание через ChannelBase.sessionQueues (никогда не steer) и направляет завершение в channel.pushProactive(). Подробно описано в §6.2.
Слой 3 — Память канала. Топология: путь сохранения через callback на уровне CLI; инъекция на стороне канала. Память является глобальной только для рабочего пространства (workspace-memory.ts:86-303). Дизайн: фрагмент памяти для каждого канала, внедряемый при запуске сессии (повторное использование одноразового затвора instructions для сессии), плюс новая область действия channel на пути записи, достигаемая из channel-base через внедренные callbacks (без зависимости channel-base → core). Подробно описано в §6.3.
Слой 4 — Governance. Топология: обертка-затвор на стороне канала; rate-limiter на стороне демона в Фазе 1+. Демон имеет один глобальный bearer-токен (auth.ts:259-266), ограничение скорости для каждого clientId/IP и не имеет бюджета для каждого канала. Дизайн: ChannelGovernor/BudgetLedger, оборачивающие handleInbound() и планировщик. Подробно описано в §6.4.
Data-flow 1 — входящий @qwen в групповом треде
Этот поток имеет одинаковую структуру в обеих топологиях; единственное отличие заключается в том, где реализованы сериализация и проверка прав. В AcpBridge (Phase 0) сериализация обеспечивается ChannelBase.sessionQueues, а права автоматически одобряются дочерним процессом; в демоне (Phase 1+) сериализация по-прежнему осуществляется через ChannelBase.sessionQueues (защитный механизм демона throw-guard никогда не срабатывает, так как канальный уровень уже выполнил сериализацию), а проверка прав проходит через MultiClientPermissionMediator.
- DingTalk → адаптер. Участник отправляет “@qwen summarize today’s incidents”. Стрим-клиент доставляет
DingTalkMessageDataсconversationId,sessionWebhook, отправителем иisInAtList.DingtalkAdapterкэшируетwebhooks.set(conversationId, sessionWebhook)(:516-517) и эмититEnvelopeсisGroup:true,isMentioned:true,chatId = conversationId. - Governor (L4).
ChannelGovernor/BudgetLedger.admit()проверяет бюджет ходов/затрат канала (рекомендательный режим, пока не появятся реальные данные об использовании, §6.4) и аварийный выключатель (kill switch). Жесткое ограничение / явный лимит с реальными числами → отклонение и ответ; превышение порога только по оценкам → WARN, никогда не жесткое отклонение (Fix #6). - Gates.
GroupGate.check()проходит (упоминание удовлетворяет значению по умолчаниюrequireMention:true);SenderGate.check()проходит (:246). - Routing.
router.resolve(...)вычисляетdingtalk:<conversationId>в скоупе'thread'(требуетсяsessionScope:"thread") и возвращает общийsessionIdгруппы.persist()записывает его. - Memory (L3) + identity (L1). При первом ходе память канала +
config.instructionsдобавляются в начало один раз (instructedSessions,:344-347). Инъекция идентичности добавляет[Alice]в начало каждого сообщения. - Attribution capture. Разрешенные
senderId/senderNameзаписываются в элемент очереди, передаваемый вsessionQueues(Fix #7), а не объединяются позже по временной метке. - Dispatch. Профиль тега устанавливает
followup(никогдаsteer); параллельное сообщение Боба выстраивается в цепочку вsessionQueues(:394-470). - Bridge.
bridge.prompt(sessionId, promptText, {imageBase64, imageMimeType})пересылается через stdio ACP (AcpBridge.prompt,AcpBridge.ts:147) или в сессию демона (DaemonChannelBridge.prompt) — это происходит только тогда, когда предыдущий ход исчерпалactivePrompts, поэтому защитный механизм демонаthrow-guard(:257-261) никогда не срабатывает. - Stream back.
textChunk→onChunk(:416-422);onResponseComplete→DingtalkAdapter.sendMessage()использует кэшированныйsessionWebhook(теплая группа).
Data-flow 2 — запланированный проактивный пуш в холодную группу
- Срабатывание расписания.
ChannelCronScheduler, работающий в шлюзе, пробуждается в 09:00 дляdaily-standup → dingtalk:<convA>. Это не внутри-сессионный cron (он отключен для сессий с тегами, OD-8/§6.2; и все равно мертв после завершения сессии —dispose()очищаетcronQueue,Session.ts:790-803). - Governor (L4). Проверяет проактивный whitelist и тихие часы (явный источник часового пояса). Вне окна / не в whitelist → пропуск + лог. Планировщик проверяет
adapter.canColdSendперед попыткой доставки; если false, он fails loud (логирует + записываетlastError), никогда не завершается молча (Fix #4). - Синтетический envelope.
senderId:'__cron__',chatId: convA,isGroup:true,isMentioned:true, безmessageId. Синтетический промпт несет собственную атрибуцию (createdBy) в элементе очереди. - Сериализация, без вытеснения.
dispatchProactiveвыстраивается в цепочку вChannelBase.sessionQueuesи ожидает завершения любого выполняющегося хода пользователя (activePrompts.get(sessionId)?.done). Он никогда не вызываетsteer/cancelSessionи никогда не вызываетbridge.prompt(), пока удерживаетсяactivePrompts— поэтому исключение демонаPrompt already in flight(:257-261) не может сработать (§6.2, Fix #1). - Отправка в холодную группу.
pushProactive(convA, text)обнаруживает, чтоwebhooks.get(convA)равен undefined, и переключается на новый проактивный путь: сохраненныйopenConversationId, свежий токен учетных данных приложения, POSThttps://api.dingtalk.com/v1.0/robot/groupMessages/sendсrobotCode = config.clientId,msgKey:'sampleMarkdown',msgParam(JSON-строка). (В Feishu шаг 5 — это существующийsendMessage()черезtenant_access_token;canColdSend = true.) - Budget + audit. Проактивный ход потребляет бакет бюджета канала (рекомендательное списание, пока не появится использование на хостинге демона); записывается с
createdByкак исходной идентичностью иoriginatorClientIdна транспортном уровне (человеческая идентичность не выдумывается,eventBus.ts:60).
Почему такая структура (переиспользование вместо изобретения)
Каждый новый слой подключается к существующему стыку: идентичность — в месте сборки promptText, проактивность — в sessionQueues + pushProactive(), память — в механизме instructions/writeContextFile, управление — как обертка над цепочкой gates. Единственное структурное требование — переиспользование механизмов демона слоями 2–4 — удовлетворяется за счет запланированной миграции на демон (§1): Phase 0 работает на AcpBridge; Phase 1+ запускается под qwen serve.
6. Детальный дизайн
6.1 Многопользовательский режим и идентичность (Build Area 1)
«Тег qwen» живет в групповом чате. Каждый участник общается с одним и тем же агентом, который должен (a) поддерживать один общий разговор для всего канала, (b) знать, кто говорит в каждом ходе, (c) не позволять сообщению одного участника разрушить выполняющуюся задачу другого и (d) в идеале запрашивать у группы одобрение на рискованные вызовы инструментов. Сегодня qwen-code имеет примитивы для (a)–(c); (d) — это задача для Phase 1+ на хостинге демона (запланированная миграция, §1).
Общая сессия группы: sessionScope: 'thread'
В режиме 'thread' senderId исключается из ключа маршрутизации, поэтому каждый участник резолвится в один sessionId (SessionRouter.ts:53,72-92) — это делает агента общей, резидентной для канала сущностью, а не N приватными ботами.
- Скоуп для каждого канала, а не глобальный переключатель. Значение по умолчанию для роутера —
'user'(:25), и для конфига канала —'user'(config-utils.ts:91-92). Личные сообщения (DM) и однопользовательские каналы остаются'user'. Профиль тега устанавливаетsessionScope: 'thread'вsettings.json, что применяется для каждого канала черезsetChannelScope()(мульти-канал,start.ts:361-362) или конструкторChannelBase(одно-канал,ChannelBase.ts:62-64). - Стабильность
threadId/chatIdв DingTalk. Адаптер DingTalk никогда не устанавливаетEnvelope.threadId(DingtalkAdapter.ts:541-551), поэтомуroutingKey()использует фоллбэкthreadId || chatIdкchatId, схлопывая группу в одну сессию на каждыйchatId(как и требуется). Предостережение:chatId = conversationId || sessionWebhook(:534). Для реальных групповых сообщенийconversationIdприсутствует и стабилен; если сообщение когда-либо придет без него,chatIdоткатится к истекающему URLsessionWebhook, и ключ треда дестабилизируется. Профиль обрабатывает отсутствующийconversationIdкак жесткую ошибку (отбрасывает сообщение), а не молча использует вебхук в качестве ключа.
Персистентность обеспечивает восстановление после сбоев (SessionRouter.ts:168-244): перезапуск демона повторно подключает группу к той же общей сессии через bridge.loadSession().
Новая опасность: /clear и /status в скоупе треда действуют на весь канал
Общий обработчик /clear вызывает router.removeSession(this.name, senderId, chatId) (ChannelBase.ts:147-152), а /status вызывает router.hasSession(...) (:203-208); оба маршрутизируются через routingKey(), который игнорирует senderId в режиме 'thread'. Таким образом, /clear от любого одного участника стирает общую сессию для всего канала и сбрасывает instructedSessions — это «грабли» для сброса всего одним нажатием.
Решение (OD-4): в общей (тред) группе /clear (и его псевдонимы) требуют явного токена confirm и ограничены config.allowedUsers, если этот список задан; в противном случае они очищают напрямую (в личных сообщениях и группах для каждого пользователя затрагивается только собственная сессия вызывающего, поэтому гейт не нужен). Команда сохраняет имя /clear, потому что слэш-парсер принимает только [a-zA-Z0-9_] (команда с дефисом /clear-channel распарсится как clear + аргумент -channel); явный confirm служит индикатором деструктивного действия. Настоящий гейт владельца для каждого участника (различающий админов и участников независимо от whitelist чата) ожидает внедрения модели идентичности (OD-3/OD-11). /status остается read-only для общей сессии.
Пробел с атрибуцией отправителя и его исправление
handleInbound() собирает promptText из envelope.text, префикса цитаты referencedText, путей вложений и config.instructions (один раз за сессию) (ChannelBase.ts:315-347); envelope.senderName читается только для SenderGate.check() (:246). В группе с режимом 'thread' агент видит недифференцированный поток.
Исправление (OD-6) — префикс [senderName] для групповых ходов, в самом начале сборки промпта (:315-316), каждый ход:
let promptText = envelope.text;
// Multiplayer attribution: in a thread-shared session, tag each turn with the
// speaker. Skip 1:1 sessions (sender is invariant). Must fire EVERY turn —
// not gated by instructedSessions (the speaker changes each message). The
// alreadyPrefixed flag lets collect-mode synthetic re-entry skip this step.
if (envelope.isGroup && !envelope.alreadyPrefixed) {
const who = envelope.senderName || envelope.senderId || 'unknown';
promptText = `[${who}] ${promptText}`;
}
if (envelope.referencedText) {
promptText = `[Replying to: "${envelope.referencedText}"]\n\n${promptText}`;
}- Гейт по
envelope.isGroup(types.ts:75), а не по скоупу. - Префикс перед
referencedText, чтобы порядок читался как[Alice] [Replying to: "..."] <text>. - Использовать
senderName, а неsenderId. В DingTalksenderName = data.senderNick || 'Unknown'(DingtalkAdapter.ts:544), никогда не пустое; цепочкаsenderId → 'unknown'является защитной. - Опасность двойного префикса в режиме
collect, решенная одним новым полем. Объединенный повторный вход собираетsyntheticEnvelope, чейtextпредставляет собой уже префиксованную объединенную строку, и повторно входит вhandleInbound()(:449-462), что добавило бы префикс снова. v2 добавляет одно новое опциональное полеEnvelope,alreadyPrefixed?: boolean(types.ts); синтетический envelope режимаcollectустанавливает его вtrue, и шаг префикса выше пропускается, если оно установлено. (Это исправляет утверждение v1 о том, что изменение «только форматирование, без нового поля envelope» — Fix #2. Это единственное новое поле envelope, которое вводит данный RFC; протокол bridge/ACP не изменен.)
Групповой dispatchMode по умолчанию: steer → followup
steer (значение по умолчанию в рантайме, :354) отменяет выполняющийся промпт через bridge.cancelSession() (:371-379). В общей группе, если Боб отправит что-либо, пока агент работает над запросом Алисы, steer отменит задачу Алисы — случайный отказ в обслуживании. Профиль тега устанавливает dispatchMode: 'followup', чтобы сообщение Боба встало в очередь за задачей Алисы (FIFO sessionQueues, :381-383,394-470). Установите это в профиле группы (groups["*"].dispatchMode = "followup"), а не меняя глобальное значение по умолчанию — в личных сообщениях сохраняется UX самопрерывания steer. Изменения в коде не требуются, кроме задокументированного значения по умолчанию в профиле; v2 исправляет устаревший JSDoc types.ts:42 на 'steer', чтобы код и комментарий совпадали (OD-5). collect приемлем для групп с очень высоким трафиком (ограничивает глубину очереди) ценой размытия атрибуции.
Поскольку профиль тега для групп всегда followup (никогда steer), проактивный движок наследует чистый инвариант: нет гонки между steer и проактивностью, потому что ни один путь в группе с тегом не отменяет выполняющийся промпт. Этот инвариант повторно формулируется и обеспечивается в §6.2.
Handoff — «продолжить с того места, где остановился предыдущий участник»
С 'thread' + префиксами [senderName] + followup handoff является поведением по умолчанию: сессия хранит полную историю с несколькими спикерами. Два эргономичных дополнения: read-only команда /who (через protected registerCommand(name, handler), :141-143 — не приватную карту commands), сообщающая активный sessionId/cwd/краткое описание задачи; и идемпотентное переподключение при перезапуске (уже покрывается restoreSessions()).
Многопользовательские одобрения — фазирование (OD-3, решено)
Намерение верное: рискованные вызовы инструментов должны иметь возможность группового одобрения, и qwen-code поставляет MultiClientPermissionMediator с четырьмя политиками (permissionMediator.ts:348,621-637). Но ни к чему из этого нельзя обратиться из канала на пути AcpBridge в Phase 0:
qwen channel startподключаетAcpBridge, чейrequestPermissionавтоматически одобряет каждый запрос (AcpBridge.ts:108-118). Никакого промпта на одобрение.- Медиатор живет в HTTP-слое serve демона. Единственный способный к работе с правами канальный bridge — это
DaemonChannelBridge(respondToPermission,:346-374) — он становится доступен, когда Phase 1 мигрирует хостинг каналов в демон (запланировано, §1). config.approvalMode— мертвое поле — парсится (config-utils.ts:94) и типизируется (types.ts:36), но не читается ни одним адаптером или bridge.
Принятое фазирование:
- Phase 0: без групповых одобрений. Контроль рисков через whitelist отправителей +
requireMention+ консервативный набор инструментов агента. Не утверждать, чтоapprovalModeчто-то делает. - Phase 1: канал работает на пути daemon-bridge (запланированная миграция); вывод
permission_requestв виде карточки DingTalk; релизfirst-responderс однимclientIdна уровне канала (нажатие любого разрешенного участника разрешает; атрибуция на уровне канала). Не требует картыsenderId → clientId. Автоматический отказ в рискованных инструментах на проактивных ходах (ход, инициированный__cron__, не может ответить на промпт разрешения). - Phase 2: добавить
consensus/designatedдля каждого участника, как только появятся маппингsenderId → clientIdи жизненный циклclientId(очистка, границы refcount). Примечание: один синтетическийclientIdна каждыйsenderIdбесконечно растит карту refcountclientIdsи должен очищаться.
Сводка конкретных изменений (Build Area 1)
| Изменение | Где | Тип |
|---|---|---|
Профиль группы устанавливает sessionScope: 'thread' | settings.json + setChannelScope (start.ts:359-363) | Конфиг |
Обработка отсутствующего conversationId DingTalk как ошибки | DingtalkAdapter.ts ~:534 | Код (S) |
Префикс [senderName] для групповых ходов | ChannelBase.handleInbound ~:316 | Код (S) |
Новое опциональное поле Envelope.alreadyPrefixed | types.ts (Envelope) | Код (S) |
Установка alreadyPrefixed при синтетическом повторном входе collect | ChannelBase.ts:449-462 | Код (S) |
/clear confirm + гейт whitelist в общих группах; /status read-only | общие команды (:147-217) | Код (S) |
Профиль группы устанавливает dispatchMode: 'followup' | groups["*"] в settings.json | Конфиг |
Исправление устаревшего JSDoc dispatchMode → 'steer' | types.ts:42 | Исправление комментария |
Команда handoff /who | registerCommand (:141) | Код (S) |
Миграция на daemon-bridge заменяет авто-одобрение AcpBridge | Хостинг DaemonChannelBridge (запланировано) | Phase 1 (L) |
| Голосование за одобрение для каждого участника + карточка DingTalk | новая обвязка bridge + respondToPermission | Phase 1/2 (L) |
6.2 Проактивный движок: планировщик + исходящий пуш (ЯДРО)
Решение: планировщик, принадлежащий шлюзу, нейтральный к миграции
Принять планировщик, работающий в процессе шлюза qwen channel start. Шлюз владеет SessionRouter (с восстановлением через restoreSessions() — start.ts:275,444), содержит каждый экземпляр адаптера и его bridge, и является единственным местом, где может быть вызван ChannelBase.pushProactive() (и базовый абстрактный sendMessage(), :81). Агент (будь то порожденный дочерний процесс --acp в Phase 0 или сессия демона в Phase 1+) остается чистым исполнителем промптов: планировщик срабатывает, ставя задачу в очередь ChannelBase.sessionQueues, который вызывает bridge.prompt() только после завершения предыдущего хода — никаких новых методов bridge, никаких обратных каналов, никаких маршрутов пуша демона.
Примечание по топологии (запланированная архитектура). Планировщик нейтрален к миграции по своей конструкции: он сериализует через
ChannelBase.sessionQueuesнезависимо от того, какой bridge находится под ним. В Phase 0 он управляетAcpBridge.prompt()через stdio; в Phase 1+ он управляетDaemonChannelBridge.prompt()(хостинг на демоне). Поскольку аудитeventBusдемона и FIFOpromptQueueтребуются для управления в Phase 1+, канал запускается подqwen serveначиная с Phase 1 — но собственная логика планировщика не меняется на границе миграции.
Почему не альтернативы:
- In-
Sessioncron: отклонено —cronQueue/cronProcessingживут в внутрипроцессномSession(Session.ts:667-668), срабатывают только пока сессия открыта, и умирают приdispose()во время 30-минутной очистки из-за простоя (:790-812). Именно этот сбой и избегает планировщик шлюза. И планировщик шлюза является ЕДИНСТВЕННЫМ владельцем cron (OD-8): сессия с тегом никогда не запускает свой внутри-сессионный cron (механизм блокировки ниже). - Отдельный процесс: отклонено — второй долгоживущий процесс, дублирующий учетные данные DingTalk, неспособный переиспользовать внутрипроцессный
SessionRouterи уже подключенный bridge.
Компоненты и их размещение
| Компонент | Файл | Ответственность |
|---|---|---|
ChannelCronStore | packages/channels/base/src/ChannelCronStore.ts (новый) | Персистентная таблица заданий, JSON-сосед sessions.json. atomicWriteJSON (atomicFileWrite.ts:385) + async-mutex Mutex для каждого файла. |
ChannelCronScheduler | packages/channels/base/src/ChannelCronScheduler.ts (новый) | Одинокий переставляемый setTimeout (timer-wheel-of-one); следующий запуск через nextFireTime; догоняющий запуск при рестарте; 60-секундный тик согласователя. Один на шлюз; единственный владелец cron. |
| Примитивы Cron | packages/core/src/utils/cronParser.ts (переиспользование) | parseCron/matches/nextFireTime (:104,141,168). Не переписывать. |
dispatchProactive | ChannelBase.ts (расширение) | Вставка запуска через sessionQueues; ожидание activePrompts.get(sessionId)?.done для любого выполняющегося хода пользователя; никогда не steer; никогда не вызывать bridge.prompt(), пока удерживается activePrompts. |
pushProactive | ChannelBase.ts (расширение; базовое значение по умолчанию = sendMessage) + переопределение DingTalk | Исходящая доставка; переопределения DingTalk для холодных групп. Ограничено возможностью canColdSend. |
canColdSend | Свойство ChannelBase (по умолчанию false) | Флаг возможности, который планировщик проверяет перед холодной отправкой; DingTalk переключает в true, как только выходит проактивный путь API; Feishu — true. |
| Проактивная отправка DingTalk | packages/channels/dingtalk/src/proactive.ts (новый) + DingtalkAdapter.ts | Массовая рассылка проактивных сообщений через robotCode + сохраненный openConversationId (контракт ВЕРИФИЦИРОВАН ниже). |
| Подключение | start.ts (расширение startSingle/startAll) | Создание + запуск планировщика после router.restoreSessions() (:275,444); проброс флага isTagSession в конструкцию сессии (OD-8). |
Инструмент /schedule + schedule_task | ChannelBase.handleInbound() (расширение, после gates :240-252) | Сначала детерминированная команда; инструмент модели во вторую очередь. |
Флаг возможности canColdSend (Fix #4)
Критерий кроссплатформенного MVP («одна и та же задача доставляется в DingTalk и Feishu») требует флага возможностей (capability flag), чтобы планировщик мог оценивать доступность, а не выявлять её отсутствие через молчаливые сбои.
- Объявлен как свойство в
ChannelBase:protected readonly canColdSend: boolean = false;. (Размещен в базовом классе, а не в отдельном реестреChannelPlugin, потому что планировщик уже хранит экземпляр адаптера, аpushProactive/sendMessageявляются методами экземпляра — размещение флага рядом с методом, который он защищает, объединяет их в один тип.) - DingTalk:
canColdSend = falseдо тех пор, пока не будет внедрен путь проактивной отправки (proactive.ts) и не будет сохранен рабочийopenConversationId; переключается наtrueпосле реализацииpushProactive. Пока значениеfalse, DingTalk все еще может отвечать на «теплые» (webhook) обращения —canColdSendуправляет только доставкой в холодные группы. - Feishu:
canColdSend = true(нативная проактивная отправка черезtenant_access_token,FeishuAdapter.ts:622-676). - Планировщик сообщает об ошибках явно (fails loud): перед выполнением задачи (fire) планировщик проверяет
adapter.canColdSend. Еслиfalse, он не пытается вызватьpushProactive; он логирует видимую оператору ошибку, устанавливаетjob.lastStatus='error'+lastError='adapter cannot cold-send', выводит её в/schedule listи (согласно политике) инкрементируетconsecutiveFailures. Он никогда не завершается молча (no-op).
Непересекающиеся хранилища cron + гейт OD-8 (Fix #5)
Существуют два пути сохранения cron-задач, и они находятся на непересекающихся путях файловой системы, поэтому они никогда не могут читать или записывать одни и те же задачи:
- Хранилище шлюза (новое):
path.join(Storage.getGlobalQwenDir(), 'channels', 'cron.json')— глобальное для канала, находится на одном уровне сsessionsPath()(start.ts:56-58), принадлежит пользователю, вне рабочего дерева. - Хранилище сессий (существующее): cron для каждой сессии
Sessionиспользует директорию с хешем для каждого проекта~/.qwen/tmp/<hash>/scheduled_tasks.json(cronTasksFile.ts:1-9).
Поскольку пути не пересекаются, единственный способ, при котором долговременная задача может выполниться дважды, — это если сессия-тег (tag session) также запускает свой внутри-сессионный cron Session в дополнение к планировщику шлюза. OD-8 закрывает эту лазейку: планировщик шлюза является единственным владельцем cron; сессия, размещенная в канале («сессия-тег»), не запускает свой внутри-сессионный cron.
Механизм блокировки (gating) — как сессия узнает, что она является сессией-тегом. Сессия-тег создается с явным флагом, передаваемым от хоста канала:
- В пути демона Phase-1+
DaemonChannelSessionFactoryуже получает структурированный набор опций ({ workspaceCwd, modelServiceId, sessionScope },DaemonChannelBridge.ts:226-241). ДобавьтеisTagSession: trueв этот набор; демонSessionсчитывает его при создании и пропускаетstartCronScheduler()(место вызова, которое в противном случае активировало быcronQueue,Session.ts:667-668). Удаление (disposal) уже очищает cron при сборке (:790-803), поэтому сессия-тег просто никогда его не активирует. - В пути
AcpBridgePhase-0 дочерний агент также не должен активировать внутри-сессионный cron для рабочего пространства тега; передайте тот же флаг через опцию запуска--acp(новое полеAcpBridgeOptions, пробрасываемое как флаг вConfig). Пока этот механизм передачи флага не реализован, Phase 0 просто не регистрирует никаких внутри-сессионных cron-задач (команда/scheduleобращается к хранилищу шлюза), поэтому нечему выполняться дважды.
Это сводит оставшийся риск исключительно к операционному: «не запускать оба планировщика для одних и тех же задач» — и механизм блокировки гарантирует, что сессия-тег никогда не запустит второй планировщик.
Схема долговременного хранилища и восстановление после перезапуска
Схема аналогична DurableCronTask (cronTasksFile.ts:19-26: id/cron/prompt/recurring/createdAt/lastFiredAt — поле называется cron, а не cronExpr):
interface ChannelCronJob {
id: string; // randomUUID()
channelName: string;
target: {
// mirrors SessionRouter PersistedEntry (SessionRouter.ts:5-9)
channelName: string;
senderId: string; // "__cron__" for system jobs
chatId: string; // DingTalk openConversationId — the DURABLE cold-group id
threadId?: string;
};
cwd: string; // validated == bound workspace on load
cron: string; // 5-field (parseCron) OR "@once:<epochMs>"
prompt: string;
label?: string;
recurring: boolean;
enabled: boolean;
createdBy: string; // senderId; advisory under single-token model; carried into the fire's attribution
createdAt: number;
lastFiredAt: number | null;
lastStatus?: 'ok' | 'error' | 'skipped';
lastError?: string;
consecutiveFailures: number; // auto-disable after N (e.g. 5)
}Запись выполняется через atomicWriteJSON под Mutex из async-mutex для каждого файла. Восстановление после перезапуска в start.ts после router.restoreSessions() (:275/:444):
bridge.start()→restoreSessions()перезагружаетsessions.jsonи вызываетbridge.loadSession()для каждой записи.store.load(); отбрасывает записи, у которыхcwd !== boundWorkspace.scheduler.start(): вычисляетnextFireTime(job.cron, new Date())для каждой включенной задачи. Политика пропущенных запусков (решение RFC): периодические задачи, просроченные во время простоя, выполняются один раз немедленно, а затем возобновляют работу — бэклог никогда не проигрывается повторно (лавина бэклога в живую группу — это инцидент со спамом). Одноразовые задачи в прошлом выполняются один раз, а затем удаляются.cronScheduler.tsразличает{ kind: 'catch-up'; ids }(периодические) и{ kind: 'missed'; tasks }(одноразовые, требуют подтверждения) в:81-89,608-707; мы применяем объединение в одно выполнение для периодических.- Устанавливает один
setTimeoutна ближайшую задачу; переустанавливает после каждого выполнения. Добавляет 60-секундный тик согласователя (прецедент:lockProbeTimer,cronScheduler.ts:229,507-538), пересчитывающий время отDate.now(), чтобы компенсировать рассинхронизацию часов при приостановке/возобновлении — интервалы никогда не накапливаются.
Путь выполнения: инъекция в ОБЩУЮ сессию группы (Fix #1 — самое важное)
Инвариант «один активный промпт на сессию» различается в зависимости от топологии, и dispatchProactive в v1 работал некорректно для пути демона:
- Phase 0 (
AcpBridge):AcpBridge.prompt()(:147-180) не имеет собственного гарда конкурентного доступа; единственная сериализация — этоChannelBase.sessionQueues/activePrompts(:29-35,394,466) и собственная ACP-сессия дочернего процесса--acp. - Phase 1+ (
DaemonChannelBridge):DaemonChannelBridge.prompt()выбрасывает исключениеPrompt already in flight, еслиactivePrompts.has(sessionId)(:257-261) — он не ставит в очередь. FIFO-очередьpromptQueue(bridge.ts:2855,3082) находится на стороне демона/acp-bridge, за этим внутрипроцессным гардом с выбрасыванием исключения. Поэтому вызовDaemonChannelBridge.prompt()во время активного обращения человека выбрасывает исключение, а не ждет.
Редизайн (корректный для обеих топологий): никогда не вызывать bridge.prompt(), пока обращение выполняется; сериализовать на уровне канала через sessionQueues, предварительно ожидая activePrompts. Поскольку sessionQueues выстраивает проактивный запуск после завершения предыдущего, к моменту вызова bridge.prompt() activePrompts.get(sessionId) уже очищен — поэтому на пути демона гард с исключением никогда не срабатывает, а на пути AcpBridge незащищенный prompt() также никогда не перекрывается.
// ChannelBase.ts — переиспользует приватные sessionQueues/activePrompts (:29-35).
// Работает идентично для AcpBridge (Phase 0) и DaemonChannelBridge (Phase 1+):
// цепочка гарантирует, что bridge.prompt() выполняется только после завершения предыдущего обращения,
// поэтому исключение `Prompt already in flight` в DaemonChannelBridge (:257-261) не может быть выброшено.
async dispatchProactive(sessionId: string, promptText: string): Promise<string> {
const prev = this.sessionQueues.get(sessionId) ?? Promise.resolve();
const run = prev.then(async () => {
const active = this.activePrompts.get(sessionId);
if (active) await active.done; // дождаться завершения обращения человека — никогда не отменять через steer (:371-379)
return this.bridge.prompt(sessionId, promptText); // только теперь activePrompts очищен
});
this.sessionQueues.set(sessionId, run.then(() => {}, () => {}));
return run;
}Инвариант: проактивное обращение никогда не может быть отменено последующим обращением человека и никогда не отменяет обращение человека. Обеспечение, сформулированное для обоих вариантов:
- Нет отмены проактивного→человеческого:
dispatchProactiveникогда не вызываетsteer/cancelSession. Он толькоawaitитactivePrompts.get(sessionId)?.done, а затем ставится в очередь за ним. - Нет отмены человеческого→проактивного: профиль группы-тега —
followup(никогдаsteer) (§6.1). Посколькуsteer— это единственныйdispatchMode, который вызываетbridge.cancelSession()(:371-379), а группы-теги никогда его не выбирают, входящее обращение человека может только выстроиться в цепочку за выполняющимся проактивным обращением черезsessionQueues— оно не может его отменить. (На пути демона кDaemonChannelBridge.cancelSession(:332) можно попасть только из веткиsteer, которая исключена для групп-тегов.) - Гард с исключением никогда не срабатывает: на обоих путях
bridge.prompt()вызывается только в конце цепочкиsessionQueues, после завершения предыдущего запуска и (для обращений человека) очисткиactivePrompts— поэтому исключение при перекрытии вDaemonChannelBridge(:257-261) структурно недостижимо для трафика тегов.
При выполнении (fire):
- Разрешение общей сессии через
router.resolve(target.channelName, target.senderId, target.chatId, target.threadId, job.cwd)(SessionRouter.ts:72).'thread'→ одинsessionIdна всю группу, поэтому выполнение попадает в контекст, который видят люди. Если восстановленная сессия была удалена,resolve()создает и сохраняет новую. - Постановка в очередь, без вытеснения (followup через
sessionQueues). Намеренно не используетсяsteer. - Маркер + атрибуция (Fix #7). Префикс
[Scheduled task "<label>" set by <createdBy>]\n. ИдентификаторcreatedByпередается вместе с запуском в очереди, а не добавляется по временной метке позже, поэтому любой вызов инструмента/запрос разрешения во время этого выполнения атрибутируется этому проактивному обращению (§6.4). - Перехват + отправка.
dispatchProactiveвозвращает текст завершения; планировщик проверяетadapter.canColdSend, затем вызываетchannel.pushProactive(target.chatId, text)(явный сбой, еслиfalse).
Холодная отправка в группу для DingTalk
Подтвержденное ограничение: DingtalkAdapter.sendMessage() отправляет сообщения только через sessionWebhook, кэшируемый для каждого conversationId (:84,134-142), который заполняется только при входящих сообщениях (:505-517). Холодная группа → молчаливый возврат (:137-141).
Исправление — pushProactive через API DingTalk 主动消息 群发 (контракт теперь ПОДТВЕРЖДЕН, OD-7 решен). Формат вызова также имеет прецедент в репозитории (emotionApi делает POST на api.dingtalk.com/v1.0/robot/... с заголовком x-acs-dingtalk-access-token и телом { robotCode, openConversationId, ... }, :188-197).
Подтвержденные эндпоинт и параметры (полные примечания к источникам см. в §6.5; уверенность указана для каждого пункта):
- Эндпоинт:
POST https://api.dingtalk.com/v1.0/robot/groupMessages/send(высокая уверенность; официальная документация по отправке + aliyun ask/559227). robotCode(ОБЯЗАТЕЛЬНЫЙ, строка): идентификатор робота при установке его в группу; то же пространство значений, что иappKeyдля внутренних корпоративных роботов → используйтеconfig.clientId(:184,435). Новые учетные данные не требуются. (высокая уверенность)openConversationId(ОБЯЗАТЕЛЬНЫЙ, строка): открытый идентификатор беседы целевой группы с префиксомcid; коды ошибокmiss.openConversationId/invalid.openConversationIdподтверждают, что он обязателен и валидируется. Сохраняется вChannelCronJob.target.chatId— стабилен при перезапусках, в отличие отsessionWebhook. (высокая уверенность)msgKey(ОБЯЗАТЕЛЬНЫЙ, строка): ключ шаблона сообщения;'sampleMarkdown'для markdown ('sampleText'для обычного текста). (высокая уверенность; документация по типам сообщений + aliyun ask/585232)msgParam(ОБЯЗАТЕЛЬНЫЙ, JSON-кодированная строка, а не вложенный объект): дляsampleMarkdownстрока имеет вид"{\"title\":\"<заголовок превью>\",\"text\":\"<тело markdown, макс. ~5000 символов>\"}". (высокая уверенность; поля title/text для markdown из документации по типам сообщений, пример текста дословно из aliyun ask/585232)coolAppCode(ОПЦИОНАЛЬНЫЙ): только если робот установлен как групповое крутое приложение (群聊酷应用); не требуется для обычного робота внутреннего корпоративного приложения. (средняя уверенность)conversationId==openConversationId? Для стандартного группового @-callback считайте callbackconversationId(с префиксом cid) напрямую пригодным в качествеopenConversationId— это подтверждается источниками сообщества + совпадающим форматомcid. Отмечено (средняя уверенность): официальная документация не содержит дословного предложения, приравнивающего их для стандартного (не cool-app) робота. Гарантированный документацией путь — это API конвертацииchatId → openConversationId(или получение его из API создания группы /chooseChatJSAPI / callback cool-app, который напрямую доставляетopenConversationId+coolAppCode). Правило отката: если отправка возвращаетinvalid.openConversationId, используйте API конвертацииchatId → openConversationId.
const GROUP_SEND = 'https://api.dingtalk.com/v1.0/robot/groupMessages/send'; // высокая уверенность
async pushProactive(chatId: string, text: string): Promise<void> { // переопределение DingtalkAdapter
const token = await this.tokenManager.get(); // обновляется независимо от жизненного цикла подключения SDK
const robotCode = this.config.clientId;
if (!token || !robotCode) { /* refresh once; else set lastError + return */ return; }
for (const chunk of normalizeDingTalkMarkdown(text)) { // переиспользовать чанкер, ЕСЛИ лимит длины шаблона совпадает
const msgParam = JSON.stringify({ title: extractTitle(text), text: chunk }); // msgParam — это СТРОКА
await sendGroupMessage({ token, robotCode, openConversationId: chatId,
msgKey: 'sampleMarkdown', msgParam }); // при invalid.openConversationId → конвертировать через chatId API, повторить
}
}sendMessage() становится таким: сначала пытается использовать кэшированный sessionWebhook (дешево, не тратит токен); в противном случае откатывается к pushProactive(). Базовое значение по умолчанию pushProactive = (chatId, text) => this.sendMessage(chatId, text), поэтому Feishu не требует переопределения (FeishuAdapter.sendMessage() уже выполняет проактивные отправки на любой chatId со стабильным tenant_access_token, :622-676; canColdSend = true). DingTalk — единственный расходящийся адаптер — асимметрия DingTalk-first. Флаг canColdSend (выше) позволяет движку явно сообщать об ошибке для адаптера, работающего только в реактивном режиме, вместо молчаливого отбрасывания.
Жесткие ограничения развертывания (не код): корпоративный бот должен быть (a) опубликованным внутренним корпоративным ботом, (b) иметь выданное разрешение на проактивные групповые сообщения, (c) участником целевой группы (установленным через групповое cool app / внутреннее корпоративное приложение / стороннее приложение, с его robotCode) (высокая уверенность, что разрешение должно быть включено; высокая уверенность, что установка бота + robotCode являются обязательными условиями), (d) иметь записанный openConversationId. Мы сохраняем conversationId при первом появлении любого входящего сообщения от бота в группе, поэтому «холодная» = неактивная, а не никогда не виденная; по-настоящему никогда не виденная группа не может получить push, пока её openConversationId не будет получен через API конвертации (жесткое ограничение). Требуемое изменение адаптера: сегодня кэшируется только sessionWebhook (:516-517); мы также должны сохранять conversationId (рекомендуемое хранилище: отдельный ~/.qwen/channels/dingtalk-groups.json, не связанный с временем жизни сессии, чтобы холодные группы и cron без живой сессии были представимы).
ВСЁ ЕЩЁ ОТМЕЧЕНО (низкая уверенность) — оставить видимым согласно OD-7: (1) точный код/отображаемое имя точки разрешения для «проактивной отправки группового сообщения» в консоли 权限管理 приложения DingTalk не зафиксирован в документации — DingTalk показывает его в 权限管理 приложения как разрешение на отправку сообщений роботом (обычно семейство robot-message, например,
qyapi_robot_sendmsg/ 企业机器人发送消息权限); подтвердите в консоли, не делайте жестких утверждений о коде. (2) Авторитетное единственное официальное предложение, приравнивающее callbackconversationIdкopenConversationIdдля стандартного (не cool-app) робота, не было найдено дословно в этой сессии — высоковероятное сокращение пути, но гарантированный документацией путь получения — это API конвертацииchatId → openConversationId. Страницы открытой платформы DingTalk рендерятся через JS и не могли быть полностью проскраплены в этой сессии; факты об эндпоинте/параметрах/токене были перекрестно подтверждены через зеркало документации apifox и Q&A разработчиков Aliyun, цитирующие официальные примеры запросов.
Авторизация и жизненный цикл токена (подтверждено; критический риск реализации)
Заголовок авторизации (высокая уверенность). Все вызовы v1.0 (включая groupMessages/send) передают токен в заголовке запроса x-acs-dingtalk-access-token: <accessToken> плюс Content-Type: application/json — ровно тот же заголовок, который уже используют emotionApi() (:188-207) и downloadMedia() (media.ts:36-43).
Получение токена (высокая уверенность). Внутреннее корпоративное приложение, стиль v1.0: POST https://api.dingtalk.com/v1.0/oauth2/accessToken с JSON-телом {"appKey":"<appKey>","appSecret":"<appSecret>"} → { "accessToken": "...", "expireIn": 7200 }. (Устаревший эквивалент GET https://oapi.dingtalk.com/gettoken?appkey=..&appsecret=.. возвращает {access_token, expires_in:7200}, но этот устаревший токен предназначен для старых эндпоинтов oapi; для API api.dingtalk.com v1.0 используйте accessToken v1.0 в заголовке x-acs-dingtalk-access-token.)
Срок действия и кэширование (высокая уверенность). Токены истекают через 7200 с (~2 ч) и ДОЛЖНЫ быть повторно получены после истечения срока действия; в пределах окна валидности повторные запросы возвращают тот же токен и обновляют его. Кэшируйте для каждого приложения; не вызывайте эндпоинт токенов при каждом запросе (частые вызовы попадают под троттлинг).
Почему это критический риск. Stream SDK получает access_token один раз при подключении через GET .../gettoken внутри getEndpoint() (client.mjs:85-87) и никогда его не обновляет; getAccessToken() возвращает кэшированное значение (DingtalkAdapter.ts:172-174). autoReconnect повторно запрашивает его только при закрытии сокета (client.mjs:157-163) — стабильный долгоживущий сокет хранит устаревший токен после истечения ~2-часового TTL, и любая проактивная отправка (а также существующие пути emotion/media) молчаливо завершается ошибкой после его истечения. Функция проактивности должна сама управлять обновлением токена: tokenManager, который запрашивает токен через эндпоинт v1.0 oauth2/accessToken по таймеру (до истечения ~2 ч) и/или при получении 401, кэшируя его для каждого приложения независимо от жизненного цикла подключения SDK (OD-7). Это наиболее вероятная причина сбоя по принципу «работает на демо, умирает через 2 часа».
Ограничения частоты (подтверждено, смешанная уверенность — оставить отмеченным): (1) конкурентность серверного API для каждого приложения ~20 QPS в DingTalk Standard, с месячной квотой Open API ~10 000/мес (Professional ~500k, Dedicated ~5M) (средне-высокая). (2) Часто цитируемый лимит 20 сообщений/минуту → ~10-минутный троттлинг для каждого робота задокументирован для кастомных роботов групповых webhook; он обычно применяется как практическое руководство для пути отправки робота orgapp, но не был явно подтвержден на странице groupMessages/send в этой сессии — считайте точную цифру 20/мин для groupMessages/send низкой/средней уверенностью. Также: не вызывайте эндпоинт токенов слишком часто (отдельный троттлинг). Планировщик должен консервативно ограничивать частоту собственных отправок и делать откат при ответах о троттлинге.
Постоянные инструкции (повторяющиеся запросы на NL → хранилище → потребление)
Двухуровневый перехват в handleInbound() после прохождения гейтов (:240-252): явная команда /schedule "0 9 * * 1-5" post the open PR list (парсится с помощью parseCron, без обхода модели) и инструмент модели Phase-2 schedule_task(cron, prompt, recurring, label). Оба вызывают store.add({...}) → сохранение → scheduler.reschedule(job), затем отвечают в канал. /schedule list|cancel <id>|disable <id> читают/пишут в хранилище. Сохранение с отказом при ошибке (fail-closed): отказываться подтверждать /schedule, если запись выбрасывает исключение.
Режимы отказа
- Шлюз недоступен во время выполнения: восстановление объединяет просроченные периодические выполнения в одно догоняющее; прошедшие одноразовые выполняются один раз, затем удаляются.
- Сбой агента во время выполнения:
bridge.prompt()отклоняется;attachDisconnectHandler(start.ts:241,403) пересоздает (Phase 0) / демон переподключается (Phase 1+). Планировщик устанавливаетlastError, не проставляетlastFiredAtдля периодических → будет повторено. Гарантия at-least-once; дедупликация по ключу выполнения, округленному до минут +lastFiredAt. - Сессия удалена (reaped) /
loadSessionзавершается ошибкой:resolve()создает новую (история группы потеряна; постоянные инструкции должны быть самодостаточными). Память канала (§6.3) — это базовый уровень восстановления. - Адаптер не может выполнять холодную отправку (
canColdSend=false): планировщик логирует + записываетlastError, выводится в/schedule list; никогда не молчит. - Холодная отправка в удаленную группу/группу с отозванными правами: не-2xx →
lastError;invalid.openConversationId→ попытка конвертацииchatId → openConversationId+ одна повторная попытка. - Истек срок действия токена:
tokenManagerобновляет один раз + делает backoff;consecutiveFailures≥ N → автоотключение с видимой оператору записью. - Два шлюза в одном рабочем пространстве:
checkDuplicateInstance()(start.ts:170-179) защищает от множественных экземпляров; дополнительно записывает токен блокировки вcron.json.
6.3 Память и обучение в контексте канала (Область сборки 3)
Тег должен запоминать группу с течением времени, не просачиваясь в соседние группы. Сегодня память qwen-code является глобальной для рабочего пространства (workspace): в ней нет оси чат/канал/группа/сессия.
Факты о топологии и зависимостях (Fix #3). Архитектуру определяют два жестких ограничения: (1) В топологии
AcpBridgeпо умолчанию нет демонаqwen serveи нет маршрутаPOST /workspace/memory— у дочернего процесса--acpнет HTTP-клиента; даже после миграции на демон в Phase-1+ маршрут памяти остается только для демона и со строгой аутентификацией (deps.mutate({ strict: true }),workspace-memory.ts:114). (2)@qwen-code/channel-baseзависит только от@agentclientprotocol/sdk(packages/channels/base/package.json), а не от@qwen-code/qwen-code-core, поэтомуChannelBaseне может выполнитьimport { writeWorkspaceContextFile }. Следовательно, в исправленном дизайне память канала записывается/читается внутри процесса через хелпер core, к которомуchannel-baseобращается через коллбэки, внедряемые слоем CLI (packages/cli, который может зависеть от core) — не по HTTP и без добавления зависимости core вchannel-base.
Текущее состояние: две области видимости, ни одна не привязана к разговору
POST /workspace/memory принимает только scope: 'workspace' | 'global' (workspace-memory.ts:118-125), разрешая путь через resolveContextFilePath() (writeContextFile.ts:223-240): workspace → <root>/QWEN.md, global → ~/.qwen/QWEN.md. В режиме добавления (append) данные складываются в раздел ## Qwen Added Memories (MEMORY_SECTION_HEADER, const.ts:29); файловый мьютекс с дедлайном 30 секунд сериализует записи (writeContextFile.ts:48-57,159-162); при добавлении в существующий файл размером > 16 МБ запись отклоняется (MAX_EXISTING_FILE_BYTES, :255). Маршрут использует строгую аутентификацию (deps.mutate({ strict: true }), :114) — он отклоняет запросы даже на loopback без токена. Следствие: все группы в одном рабочем пространстве используют один общий файл QWEN.md.
Дизайн: область памяти channel с ключом (channelName, chatId)
Единицей изоляции является цель маршрутизации (routing target), а не сессия (сессии удаляются при простое, DEFAULT_SESSION_IDLE_TIMEOUT_MS 30 мин, run-qwen-serve.ts:94). Ключ уже существует: SessionTarget { channelName, senderId, chatId, threadId } (types.ts:88-93). Для памяти группы ключом служит (channelName, chatId).
Структура хранилища повторяет существующее дерево ~/.qwen/channels/:
~/.qwen/channels/
sessions.json
memory/
<channelName>/ # санитизация: отклонять /, .., NUL
<hash(chatId)>/ # sha256(chatId).slice(0,16) — безопасно для путей, без коллизий/выхода за пределы
QWEN.md # "обучение с течением времени" в контексте группы
meta.json # { channelName, chatId, displayName?, createdAt, lastWriteAt }Имя файла учитывает getCurrentGeminiMdFilename() (const.ts:49). Это удерживает память канала вне рабочего дерева, вне привязанного рабочего пространства и вне пути иерархического обнаружения QWEN.md (чтобы она никогда не просочилась между группами).
Путь записи (расширяем хелпер core, не создаем его форк)
В packages/core/src/memory/writeContextFile.ts:
- Расширяем
WriteContextFileScope(:80), добавляя'channel'к'workspace' | 'global'. - Расширяем
WriteContextFileOptions(:83-97), добавляяchannelKey?: { channelName: string; chatId: string }; проверяем его наличие, когдаscope === 'channel'(аналогично защите абсолютного пути:142-146).projectRootостается обязательным в интерфейсе — передаемconfig.cwd, даже если он не используется для области channel. - В
resolveContextFilePath()(:223-240) добавляем веткуchannel, возвращающуюpath.join(Storage.getGlobalQwenDir(), 'channels', 'memory', sanitize(channelName), hash(chatId), getCurrentGeminiMdFilename()). Текущая сигнатура функции —(scope, projectRoot)— в нее нужно добавить параметрchannelKey(приватная функция, локальное изменение). Файловый мьютекс использует в качестве ключа разрешенный путь, поэтому две группы могут писать параллельно без конфликтов.
Точное изменение ChannelBaseOptions + кто его внедряет (Fix #3). channel-base не может импортировать core, поэтому слой CLI предоставляет чтение/запись в виде коллбэков. Расширяем объект опций (ChannelBase.ts:9-12 — реальный интерфейс сегодня это просто { router?: SessionRouter; proxy?: string }; config и bridge — это позиционные аргументы конструктора в :40-46, а не члены объекта). Объект уже содержит router:
// packages/channels/base/src/ChannelBase.ts — ChannelBaseOptions (БЕЗ новых зависимостей от core)
export interface ChannelBaseOptions {
// ...существующие члены: router?: SessionRouter; proxy?: string
/** Читает дистиллированную память этого канала; null, если ее еще нет. Внедряется слоем CLI. */
readChannelMemory?: (target: SessionTarget) => Promise<string | null>;
/** Добавляет/заменяет память этого канала. Внедряется слоем CLI. */
writeChannelMemory?: (
target: SessionTarget,
content: string,
mode: 'append' | 'replace',
) => Promise<void>;
}Кто создает и внедряет их: packages/cli/src/commands/channel/start.ts (который зависит от core). Когда start.ts формирует объект опций для каждого адаптера, он замыкает writeWorkspaceContextFile/хелпер чтения из core и разрешает доверенный сервером (channelName, chatId) из router.getTarget(sessionId) (SessionRouter.ts:94) — адаптер никогда не получает chatId из сети:
// packages/cli/src/commands/channel/start.ts — слой CLI (МОЖЕТ зависеть от core)
import {
writeWorkspaceContextFile,
readChannelContextFile,
} from '@qwen-code/qwen-code-core';
const baseOpts: ChannelBaseOptions = {
router, // config и bridge — позиционные аргументы createChannel(name, config, bridge, baseOpts), а не члены объекта
readChannelMemory: (target) =>
readChannelContextFile({
channelKey: { channelName: target.channelName, chatId: target.chatId },
}),
writeChannelMemory: (target, content, mode) =>
writeWorkspaceContextFile({
scope: 'channel',
channelKey: { channelName: target.channelName, chatId: target.chatId },
mode,
content,
projectRoot: config.cwd, // projectRoot не используется для области channel, но требуется интерфейсом
}),
};
// адаптер создается позиционно, объект опций передается последним: plugin.createChannel(name, config, bridge, baseOpts)Адаптер никогда не обращается к файловой системе, а channel-base не получает новых зависимостей. (Альтернатива для демона в Phase-2: маршрутизированный путь POST /channel/:sessionId/memory, который разрешает channelKey на стороне сервера; он не может переиспользовать POST /workspace/memory, который жестко валидирует scope ∈ {workspace, global} и передает фиксированный projectRoot, :118-125,185-190. Откладываем до тех пор, пока проактивному движку не понадобятся поиски sessionId → target на стороне демона.)
Трансляция событий (fan-out). publishWorkspaceEvent находится на стороне демона в AcpSessionBridge (bridge.ts:3610), а не на стороне канала. В AcpBridge (Phase 0) нет события memory_changed (оно и не нужно — один процесс владеет и записью, и чтением). В топологии с демоном publishWorkspaceEvent транслируется в каждую активную шину сессий без разбора (bridge.ts:3649-3675); BridgeEvent.data имеет произвольную форму (eventBus.ts:51), поэтому событие memory_changed может нести { scope:'channel', channelName, chatId }, но требуется фильтрация на стороне подписчика — издатель не может ограничить доставку.
Путь чтения (память → промпт) — одноразовая за сессию инициализация с переиспользованием instructedSessions
Расширяем блок instructions, выполняемый один раз за сессию (ChannelBase.ts:343-347, управляется instructedSessions): при первом сообщении сессии, цель которой имеет (channelName, chatId), вызываем внедренный readChannelMemory(target) и добавляем его результат перед config.instructions, затем помечаем сессию в instructedSessions точно так же, как сегодня. Поскольку область 'thread' использует один sessionId, это загружает память один раз за время жизни сессии (тот же барьер, который уже предотвращает повторное внедрение config.instructions). Зависимость от core не добавляется — чтение идет через внедренный коллбэк. Память канала никогда не находится на пути иерархического обнаружения; она внедряется для каждой сессии через этот хук.
// ChannelBase.handleInbound() — инициализация первого хода (переиспользует instructedSessions)
if (!this.instructedSessions.has(sessionId)) {
const parts: string[] = [];
if (this.options.readChannelMemory) {
const mem = await this.options.readChannelMemory(target); // target из router.getTarget(sessionId)
if (mem) parts.push(mem);
}
if (config.instructions) parts.push(config.instructions);
if (parts.length) promptText = `${parts.join('\n\n')}\n\n${promptText}`;
this.instructedSessions.add(sessionId);
}Связь с сохранением/восстановлением SessionRouter и транскриптом
| Слой | Что сохраняет | Время жизни | Владелец |
|---|---|---|---|
| Транскрипт сессии | Ходы разговора ACP | До удаления / /clear confirm / перезапуска | Session (агент) |
Сохранение SessionRouter | key → { sessionId, target, cwd } (:5-9,224-244) | Переживает перезапуск моста через loadSession() | SessionRouter (sessions.json) |
| Память канала (новая) | Дистиллированные долговечные факты о группе | Бессрочно | ~/.qwen/channels/memory/ |
Когда restoreSessions() не может перезагрузить сессию (:196), транскрипт теряется, но групповой QWEN.md остается нетронутым — инициализирующее чтение восстанавливает знания агента при следующем сообщении. Память канала — это базовый уровень восстановления для транскрипта. “Обучение с течением времени” — это цикл дистилляции, а не прямое сохранение транскрипта: агент (или запускаемая задача) периодически суммирует важные факты в групповой QWEN.md в режиме добавления.
Изоляция, размер и поэтапность
Изоляция обеспечивается на уровне путей (sales и eng разрешаются в разные директории/файлы/мьютексы с hash(chatId)), пока путь записи всегда несет доверенный сервером chatId. Это изоляция контента, а не граница аутентификации (процесс по-прежнему имеет один глобальный токен, без идентификации на уровне пользователя). Для жесткой изоляции тенантов запускайте по одному процессу на рабочее пространство/тенант (OD-2).
Ограничения размера (переиспользуем существующий механизм): лимит 16 МБ для существующего файла при добавлении наследуется бесплатно (маппим WorkspaceMemoryFileTooLargeError в понятное пользователю “память группы переполнена, запустите проход уплотнения”); маршрут Phase-2 переиспользует лимит 1 МБ на одну запись (MAX_MEMORY_CONTENT_BYTES, workspace-memory.ts:79); уплотнение в режиме замены (writeContextFile.ts:202-211) — это долгосрочное решение проблемы неограниченного роста.
- Phase 0/1: добавляем область
channel+channelKeyвwriteContextFile.ts; выпускаем~/.qwen/channels/memory/+meta.json; связываем коллбэкиreadChannelMemory/writeChannelMemoryслоя CLI черезChannelBaseOptionsи инициализирующее чтение, описанное выше. Никаких новых HTTP-маршрутов, никаких зависимостейchannel-base → core. - Phase 2: добавляем маршрутизированный путь
POST /channel/:sessionId/memory(топология с демоном) иmemory_changedс фильтрацией на стороне подписчика; добавляем триггер дистилляции и CLIqwen channel memory <name> <chatId>. Ограничение дистилляции: cron привязан к сессии и умирает приdispose()(Session.ts:791,799-803,1056); дистилляция должна срабатывать, пока сессия активна — по завершении хода, по явному/rememberили в “разогретой” сессии — никогда из независимого фонового планировщика.
6.4 Управление: бюджеты токенов и журнал аудита (Область сборки 4)
Агенту, работающему в канале, которым может управлять любой участник и который может действовать проактивно, необходимы лимиты расходов, журнал аудита, фиксирующий, кто и что запросил, и изоляция на уровне идентичности. qwen-code предоставляет три из четырех примитивов: rate-limit.ts (бакеты токенов на ключ), кольцевой буфер permission-audit.ts и MultiClientPermissionMediator. Эта область объединяет их и заполняет пробелы (бюджет стоимости отсутствует где-либо; ни одна строка аудита не содержит человеческого отправителя). Руководящий принцип: отклонять, а не обрезать — но, согласно Fix #6, оценочный бюджет никогда жестко не отклоняет пользовательский промпт; он только выдает WARN.
Какой процесс отвечает за управление?
| Развертывание | Мост | Какая инфраструктура serve/ доступна |
|---|---|---|
Phase 0 — qwen channel start / AcpBridge | запускает свой собственный дочерний процесс --acp stdio (start.ts:213,356) | Никакой. Нет сервера Express, нет rate-limit.ts, нет HTTP-маршрутов, нет кольцевого буфера permission-audit.ts. |
Phase 1+ — qwen serve + DaemonChannelBridge | каналы размещаются в демоне | Вся инфраструктура serve/: реальное использование, медиатор, rate-limit, кольцевой буфер аудита, маршруты. |
Решение: допуск по бюджету + отклонение живут в @qwen-code/channel-base (общая точка контроля ChannelBase.handleInbound()), в новом packages/channels/base/src/BudgetLedger.ts — не в serve/budget.ts, потому что процесс канала Phase-0 никогда не загружает serve/, а слой канала — это единственное место с контекстом человеческого отправителя. Аудит + атрибуция также берут начало в слое канала. На пути демона Phase-1+ реестр читает реальное использование и дополнительно предоставляется через маршрут; на пути Phase-0 он оценивает и предоставляется через команду канала (/audit).
Где управление подключается сегодня (и пробелы)
| Задача | Существующий механизм | Пробел |
|---|---|---|
| Ограничение частоты запросов | бакеты токенов на (clientId|ip), 3 уровня (rate-limit.ts) | Нет токенов/стоимости, только количество запросов; только в serve/ |
| Журнал решений постфактум | ограниченный FIFO-кольцевой буфер, 5 типов записей (permission-audit.ts) | Нет человеческого senderId, только clientId; нет GET-маршрута; кольцевой буфер удерживается замыканием (:17-25) |
| Реальное одобрение на действие | четыре политики + кворум консенсуса (permissionMediator.ts:621-637) | Голоса привязаны к clientId, а не к человеку; один канал = один клиент |
| Область инструментов/данных на канал | coreTools/allowedTools/excludeTools (config.ts:727-729); getPermissionsAllow() (:3158); getPermissionsDeny() (:3182); фильтр разрешений MCP (:3327-3333) | Область привязана к Config/процессу; нет пути через аргументы spawn в дочерний процесс --acp |
Два структурных факта: (1) у демона нет человеческой идентичности (BridgeEvent.originatorClientId, каждый PermissionVote.clientId — это транспортные идентификаторы; senderName сохраняется только до SenderGate.check()), поэтому любая корреляция человек↦clientId↦sessionId должна устанавливаться на границе канала; (2) аутентификация и rate-limit глобальны для демона (один bearer-токен auth.ts:259-266; rate-limit с ключом (clientId, ip)), поэтому управление на уровне канала должно начинаться в адаптере.
Бюджеты токенов и стоимости — новый BudgetLedger, рекомендательный до появления реального использования (Fix #6)
Откуда берется использование — оговорка (OD-9). Бюджет токенов может списывать реальные числа только после того, как модель сообщит об использовании. Внутри сессии Session.#recordPromptTokenCount() (Session.ts:2078-2087) сохраняет usageMetadata.promptTokenCount в lastPromptTokenCount, перезаписывая каждый ход — это не накопительный счетчик биллинга. На пути AcpBridge Phase-0 поток ACP session/update не несет usageMetadata, поэтому v1 не может списывать реальные количества токенов там. На пути демона Phase-1+ демон наблюдает использование внутри процесса и может списывать точно.
Правило применения (Fix #6 — критически важное):
- Оценочные бюджеты носят ТОЛЬКО РЕКОМЕНДАТЕЛЬНЫЙ характер. Когда единственное доступное число — это оценка на стороне канала (количество символов промпта+ответа ÷ константа символов на токен), реестр выдает WARN/алерт при достижении порогов и может прикрепить предупреждение к ответу — он никогда жестко не отклоняет пользовательский промпт. Ложноположительная оценка не должна блокировать реальный запрос пользователя.
- Жесткое отклонение ТОЛЬКО по реальным числам. Бюджет может отклонить промпт (отклонить, а не обрезать) только тогда, когда источник списания — это реальный путь использования демона (размещенный в демоне Phase-1+). До этого бюджет — это наблюдаемость + алертинг, а не шлюз.
Это делает бюджет v1 честным: он заранее предупреждает везде и применяет жесткие лимиты ровно там, где числа заслуживают доверия.
Модуль BudgetLedger.ts, смоделированный по образу rate-limit.ts (фабрика, Map бакетов с GC, при переполнении — fail-open):
export type BudgetUnit = 'tokens' | 'usd'; // 'usd' = токены × ставка для конкретной модели
export type UsageSource = 'estimate' | 'daemon'; // 'estimate' => рекомендательный; 'daemon' => может жестко отклонять
export interface BudgetLedger {
// allowed=false только когда source==='daemon'; оценки возвращают allowed=true + флаги предупреждения
admit(key: string): {
allowed: boolean;
spent: number;
limit: number;
advisory: boolean;
};
debit(
key: string,
amount: number,
unit: BudgetUnit,
source: UsageSource,
): void; // вызывает алерты при достижении порогов
snapshot(): Record<
string,
{ spent: number; limit: number; ratio: number; source: UsageSource }
>;
reset(): void;
dispose(): void;
}- Семантика наследования по умолчанию + сводка org по принципу “строжайший побеждает” (OD-9).
admit(key)разрешает эффективное окно с фоллбэком в стилеGroupGate:channel → '*' → built-in. Промпт должен пройти оба окна: для канала и сводку “org” для процесса (строжайший побеждает, списание с обоих). “org” = сводка этого единственного процесса; настоящий межпроцессный лимит org требует общего хранилища (вне скобок). Фиксированное дневное окно. - Алерты 75%/95%.
debit()вызываетonAlertодин раз на порог на окно, используя идиому гистерезиса шины событий (WARN_THRESHOLD_RATIO/WARN_RESET_RATIO,eventBus.ts:101-103). Отправка алерта — это проактивная отправка — жесткая зависимость от Области сборки 2 (оговорка о «холодной» группе DingTalk; Feishu отправляет свободно). Деградирует до “прикрепить предупреждение к следующему ответу”, если проактивный канал отсутствует. - Отклонить, а не обрезать (только когда
source==='daemon'). Проверяется при допуске, доbridge.prompt()(:425). При реальном использовании и!allowedадаптер вызываетsendMessage(chatId, refusal)и возвращается — он не входит в путь steer/cancel, поэтому промпт в полете завершается, а следующий отклоняется. При оценкеallowedвсегда true (рекомендательный). - Стоимость (
usd) умножает токены на таблицу ставок для каждой модели, предоставленную оператором (qwen-code мультимодельный; единой цены нет). Отсутствующая запись → фоллбэк наtokens+ одноразовое предупреждение. - Конфигурация.
ChannelConfig(types.ts:27-51) получаетbudget?: { unit; limit; windowMs; reset? }, парсится черезparseChannelConfig. На пути демонаServeOptionsполучает--budget-org-daily/--budget-unit, аdaemon-status.ts(который уже сообщаетrateLimit,:295-297) получает параллельный блокbudget.
Audit log — человеческий senderId, передаваемый вместе с turn (Fix #7)
PermissionAuditRing (permission-audit.ts:128-172, FIFO 512) — подходящая основа, но каждая строка привязана к clientId. Дизайн — привязка sender↦turn на стороне канала (RequestAttributionRing.ts, та же FIFO-структура).
Наивное соединение по временной метке некорректно в режиме followup (Fix #7). В v1 предлагалось соединять строку разрешения с «самой свежей строкой атрибуции для данного sessionId, чья recordedAtMs предшествует issuedAtMs разрешения». В режиме followup несколько отправителей встают в очередь для одного sessionId через sessionQueues; отправитель, поставленный в очередь последним, часто не является тем, чей turn выполняется в момент срабатывания tool-call/permission. Поэтому соединение по временной метке систематически присваивает атрибуты неверно.
Исправление: передавать senderId ВМЕСТЕ с промптом в очереди. Когда handleInbound() ставит промпт в очередь sessionQueues (и когда планировщик ставит в очередь проактивное срабатывание), элемент очереди / синтетический контекст turn несет свой собственный { senderId, senderName, requestSeq }. Атрибуция для любого tool-call/permission, возникшего во время turn, считывается из текущего выполняемого turn (головы FIFO), а не из сканирования временных меток. Конкретно: цепочка sessionQueues устанавливает атрибуцию для каждого turn currentTurnAttribution.set(sessionId, {senderId, ...}) в момент, когда выполнение достигает головы (прямо перед bridge.prompt()), и очищает её при завершении выполнения; строки audit читают эту карту. Проактивные срабатывания устанавливают createdBy тем же способом (§6.2 шаг 3). Это точно для выполняемого turn и не зависит от порядка постановки в очередь.
Добавьте шестой тип строки task.requested { sessionId, senderId, channelName, chatId, promptDigest, requestedAtMs } при допуске (admission), чтобы audit отвечал на вопрос «кто начал эту задачу» даже для работы только на чтение. Объединение PermissionAuditEntry (:57-104) закрыто, и потребители переключаются по kind, поэтому его расширение (или добавление соседнего ring) затрагивает каждого потребителя.
Путь запроса. Phase-1+ daemon: добавьте GET /workspace/audit (bearer + строгий createMutationGate, auth.ts:356), предоставляя ring из замыкания bridge (документация в заголовке файла предусматривает это, :22-25). Phase-0 AcpBridge: команда канала /audit через sendMessage. Долговечность: ring содержит 512 записей в памяти и теряется при перезапуске — известное ограничение v1; последующая задача (OD-11) сохраняет append-only объединенный audit в ~/.qwen.
Голосующие в консенсусе — не люди. votersAtIssue — это clientId, проставленные daemon, а один канал = один clientId, поэтому «консенсус» из коробки в группе DingTalk — это консенсус между клиентами daemon. Голосование на уровне людей требует реестра зарегистрированных утверждающих, сопоставляющего senderId → отдельный голос — это требование OD-3 Phase-2, а не реализованная функция.
Изоляция инструментов и данных по идентичности
- Разрешение/запрет инструментов по каналам.
ConfigподдерживаетcoreTools/allowedTools/excludeTools(:727-729), доступные черезgetPermissionsAllow()/getPermissionsDeny()/getCoreTools(). (МетодовgetAllowedTools()/getBlockedTools()нет.) В Phase 0 путьAcpBridgeсоздает дочерний процесс для каждого канала, ноAcpBridgeOptionsнесет только{ cliEntryPath, cwd, model }(:17-21), аstart()передает только--acp+--model(:56-63). Для реализации области действия по каналам требуются НОВЫЕ поляAcpBridgeOptions, НОВЫЕ флаги--acpвConfig, а также новые поляChannelConfig. В пути daemon Phase-1+ на каждый daemon приходится одинConfig, поэтому область действия ограничена daemon (рабочим пространством, OD-2), а не дочерним процессом канала. - Область действия MCP по каналам.
Config.getMcpServers()фильтрует поallowedMcpServers(:3327-3333), заданным при создании. ДобавьтеallowMcpServers?: string[]вChannelConfig, передав их по тому же пути аргументов spawn (или в массивmcpServers, который передаетAcpBridge.newSession()— жестко заданный[]в:133). sessionScopeкак граница данных.'thread'заставляет группу использовать одно общее рабочее дерево/контекст; межканальная изоляция обеспечивается ключами маршрутизации с пространством именchannelName. Изоляция по отправителям внутри группы'thread'не предусмотрена дизайном.
Честное ограничение: аутентификация использует единый глобальный для daemon токен без принципов для каждого пользователя, поэтому изоляция работает на уровне канала, а не человека. Для настоящей изоляции инструментов по пользователям требуется Phase-3.
Путь допуска
Входящее сообщение DingTalk
→ ChannelBase.handleInbound()
1. GroupGate.check() + SenderGate.check() [существующее :240-252]
2. budget.admit('channel:<name>') && budget.admit('org') [НОВОЕ]
↳ source==='daemon' && !allowed: sendMessage(refusal); return (НЕ в steer/cancel)
↳ source==='estimate': allowed всегда true → только WARN (Fix #6)
3. постановка в sessionQueues С {senderId, senderName, requestSeq} [НОВОЕ — Fix #7]
+ строка task.requested
4. в голове FIFO, установка currentTurnAttribution → bridge.prompt(...) [существующее :425]
↳ tool call → permission (авто-одобрение в AcpBridge Phase 0; медиатор в daemon Phase 1+)
↳ строка audit читает currentTurnAttribution[sessionId] (ВЫПОЛНЯЕМЫЙ turn)
5. по завершении: использование известно (daemon) или оценено (AcpBridge) → budget.debit(..., source) [НОВОЕ]
↳ публикация оповещения 75%/95% проактивна → зависит от Build Area 2Жесткие зависимости, которые стоит отметить: (1) реальное списание токенов (и, следовательно, жесткий отказ) требует пути использования daemon Phase-1+ — до этого бюджеты носят рекомендательный характер (Fix #6); (2) проактивные оповещения о бюджете требуют Build Area 2; (3) голосование на уровне людей и атрибуция audit на уровне людей требуют реестра зарегистрированных утверждающих OD-3.
6.5 Платформа DingTalk (основная) + доработка Feishu
Примечание по интеграции (утвержденная архитектура). Phase 0:
qwen channel startсоздаетAcpBridge(start.ts:213,350;AcpBridge.ts:38), который запускаетnode <cli> --acpи предоставляетnewSession(cwd)/loadSession(sessionId, cwd)(:131,137); область действия сессии контролируетсяSessionRouter, а не bridge. Phase 1+: каналы размещаются подqwen serveчерезDaemonChannelBridge(его значения по умолчанию для'thread'в:229,240; его исключение при перекрытии в:257-261). Миграция утверждена и не является опциональной (§1).
Проблема истечения срока действия sessionWebhook
В режиме DingTalk Stream каждое входящее сообщение доставляется с короткоживущим sessionWebhook; адаптер кэширует его по ключу conversationId (:84, заполняется в onMessage() :517), а sendMessage() (:134-170) ищет его, логируя No webhook for chatId и молча возвращая управление, если его нет (:137-141). Два фатальных факта для проактивного использования: (1) срок действия webhook истекает (тип SDK RobotMessageBase содержит sessionWebhookExpiredTime, constants.d.ts:13, но интерфейс DingTalkMessageData адаптера опускает его и никогда не читает — кэшированный webhook может устареть даже внутри активного окна); (2) карта заполняется только входящим трафиком, поэтому для «холодной» группы записи нет.
Отправка в холодную группу через API проактивных сообщений робота (主动消息) — ПРОВЕРЕНО (OD-7)
Исправление — это API проактивных сообщений бота DingTalk — POST https://api.dingtalk.com/v1.0/robot/groupMessages/send (эндпоинт проверен, высокая уверенность). В отличие от webhook, он адресуется через долговечный openConversationId (проверено, высокая уверенность), аутентифицируется с помощью заголовка x-acs-dingtalk-access-token (проверено, высокая уверенность — уже используется в emotionApi() :188-207 и downloadMedia() media.ts:36-43) и несет robotCode бота (проверено, высокая уверенность; = config.clientId, :184,435). Тело представляет собой пару msgKey/msgParam (проверено, высокая уверенность), где msgParam — это сама по себе JSON-кодированная строка (а не вложенный объект), например, для msgKey:'sampleMarkdown':
{
"robotCode": "ding...", // = config.clientId
"openConversationId": "cid6KeBBLov...", // долговечный id группы (из входящего conversationId; конвертировать, если недействителен)
"msgKey": "sampleMarkdown",
"msgParam": "{\"title\":\"<preview title>\",\"text\":\"# hi\\n...markdown ≤ ~5000 chars\"}",
}Это новый метод наряду с sendMessage(), а не его изменение (набросок в §6.2). ChannelBase.sendMessage() остается абстрактным (:81); проактивному движку требуется новый исходящий шов pushProactive?(target, text) — совершенно новый и центральный результат для платформы. проверено [высокая уверенность] согласно официальной документации send + aliyun ask/559227, ask/585232 + документации по типам сообщений для формы эндпоинта/параметров/msgParam.
Необходимое разрешение: разрешение робота/сообщения «отправка проактивного сообщения в групповой чат» должно быть предоставлено внутреннему корпоративному приложению перед тем, как groupMessages/send заработает (документация send перечисляет это необходимое условие) (проверено, высокая уверенность, что разрешение должно быть включено). ВСЕ ЕЩЕ ОТМЕЧЕНО (низкая уверенность): точное отображаемое имя/код точки разрешения не зафиксировано по документации в этой сессии — консоль DingTalk показывает его в разделе 权限管理 приложения как разрешение на отправку сообщений роботом (обычно семейство robot-message, например, qyapi_robot_sendmsg / 企业机器人发送消息权限); подтвердите в консоли, не делайте жестких утверждений о коде. Адаптер должен логировать resp.status + тело при !resp.ok/throw — текущий пустой catch в emotionApi (:214-216) — это антипаттерн, который скроет неправильную конфигурацию из-за отсутствия разрешения.
Получение и сохранение openConversationId
Два источника: (1) сбор из входящих — каждое сообщение несет conversationId (:506), который передается как openConversationId в emotion API (:197); сохраняйте его в тот момент, когда видите. проверено [средняя уверенность] согласно aliyun ask/559227, ask/585233 + соответствующему формату 'cid', что callback conversationId (с префиксом cid) можно использовать напрямую как openConversationId для стандартного группового @-callback. ВСЕ ЕЩЕ ОТМЕЧЕНО: нет официальной дословной фразы, приравнивающей их для робота не cool-app; гарантированный документацией путь получения — это API конвертации chatId → openConversationId (obtain-group-openconversationid), или получение из API создания группы / chooseChat JSAPI, или callback cool-app (который доставляет openConversationId+coolAppCode напрямую). Резервный вариант: при invalid.openConversationId конвертируйте через API chatId и повторите попытку. (2) события bot-added-to-group через registerAllEventListener (client.mjs:58-61): события текут onEvent → onEventReceived при стандартном topic:'*' (client.mjs:14-19,241-254), тогда как адаптер устанавливает только callback робота (:107), поэтому события org/bot в настоящее время принимаются и попадают в no-op по умолчанию (client.mjs:35-37). Тема события и поле openConversationId во время установки не проверены — не хардкодьте имя события.
Сохранение. Используйте отдельное хранилище ~/.qwen/channels/dingtalk-groups.json, а не цель SessionRouter: ID группы должен переживать любую сессию (проактивная отправка в холодную группу по cron срабатывает без живой сессии), а PersistedEntry существует только после создания сессии для ключа маршрутизации — связывание идентичности группы с временем жизни сессии оставляет холодные группы непредставленными.
Многопользовательская область действия включается явно, а не по умолчанию
Область действия 'thread' (:53) — это то, что дает один общий агент на группу, но parseChannelConfig() по умолчанию устанавливает sessionScope в 'user' (config-utils.ts:91-92), что дает сессии для каждого участника. Оператор должен явно установить sessionScope: 'thread'. При установке применяются два многопользовательских следствия: (a) стандартный dispatchMode: 'steer' отменяет текущую работу, когда любой участник отправляет сообщение (:371-379) — профиль тега устанавливает 'followup' (§6.1); (b) пробел в атрибуции отправителя (§6.1).
Парсинг входящих @
Групповые ограничения работают: GroupGate использует envelope.isMentioned, устанавливаемый из data.isInAtList (:520). Очистка текста удаляет только первый @token (:527-529), позиционно, а не по идентичности — @qwen @alice корректно, но упоминание человека первым удалит упоминание человека. Последующее усиление будет удалять по собственному chatbotUserId бота. Контекст ответа/цитаты извлекается (extractQuotedContext(), :272-298), при этом isReplyToBot вычисляется относительно chatbotUserId (:280,292), а referencedText внедряется как [Replying to: "…"] (ChannelBase.ts:317-319). Атрибуция отправителя закрыта в §6.1 через префикс [senderName].
Рендеринг Markdown / карточек
markdown.ts уже выполняет нормализацию платформы, которую повторно использует проактивный путь: таблицы → pipe-текст (convertTables(), :44-80), разбивка на части по 3800 символов с балансировкой fence (splitChunks(), :84-188; CHUNK_LIMIT=3800, :10), извлечение заголовка, обрезанное до 20 символов, с резервным вариантом 'Reply' (extractTitle(), :190-195). Повторное использование условно тем, что шаблон sampleMarkdown принимает тот же поднабор markdown и тело до ~5000 символов (проверено, высокая уверенность — документация по типам сообщений); сохраняйте CHUNK_LIMIT ≤ этого бюджета. Потоковые интерактивные карточки (путь TOPIC_CARD, constants.d.ts:4) — аналог потоковой карточки Feishu — выходят за рамки основного этапа; проактивность v1 основана на markdown-сообщениях.
Доработка Feishu (кратко)
Feishu опережает именно в том аспекте, который важен: проактивная отправка является нативной (sendMessage(chatId, text) в любой chat_id, :622-676 — нет проблемы холодной группы; canColdSend = true), стабильный tenant_access_token с отслеживаемым по сроку действия обновлением (refreshToken(), :581-620 — то, что еще предстоит сделать DingTalk), гибкая подписка на события (WebSocket или HMAC webhook, :146-176) и первоклассные потоковые карточки (markdown.ts, :742-792). Но общие проблемы ChannelBase/SessionRouter — явное включение области действия 'thread', отмена dispatchMode, отсутствующая атрибуция отправителя, новый исходящий шов — в равной степени применимы к Feishu. Feishu решает доступность, а не кто-что-сказал или один-участник-отменяет-другого. Перенос проактивного движка на Feishu напрямую переиспользует существующий sendMessage() (базовый pushProactive по умолчанию); единственная новая работа для платформы — это сопоставление целевой группы движка с сохраненным chat_id и опциональная маршрутизация через путь потоковых карточек.
7. Поэтапное внедрение (Phase 0–2) и MVP
Каждый этап можно мержить независимо, он завершается демонстрационной версией и ограничивается явными критериями приемки. Phase 0 заставляет существующий стек вести себя как общий резидентный агент — конфигурация плюс несколько небольших изменений в коде, на базе AcpBridge. Phase 1 мигрирует хостинг каналов в qwen serve (утвержденная архитектура) и добавляет проактивный движок и единый замкнутый цикл MVP. Phase 2 добавляет память канала, бюджеты и audit.
Топология: утвержденная миграция daemon (ранее OD-1)
Решение принято, а не ожидается: Phase 0 поставляется на AcpBridge; Phase 1+ запускает каналы под qwen serve (через DaemonChannelBridge или runner каналов daemon), потому что сохранение памяти для каждой комнаты, медиатор разрешений, audit шины событий, FIFO promptQueue и маршруты запросов бюджета/audit требуют daemon. Планировщик, принадлежащий шлюзу (§6.2), нейтрален к миграции — он сериализуется через ChannelBase.sessionQueues независимо от bridge — поэтому он поставляется в Phase 1 и не зависит от переключения. Интеграция Phase 0 добавляет путь подключения DaemonChannelBridge (или флаг --daemon <url>), чтобы миграция была шагом конфигурации на границе Phase-1, а не переписыванием. Обратите внимание на острую грань, вокруг которой спроектирован планировщик: DaemonChannelBridge.prompt() не ставит в очередь — он выбрасывает Prompt already in flight при перекрытии (:257-261); daemon FIFO promptQueue находится на стороне acp-bridge (bridge.ts:2855,3082); сериализация на стороне канала — это ChannelBase.sessionQueues (:394), поэтому проактивный движок никогда не вызывает prompt(), пока turn активен (§6.2, Fix #1).
Phase 0 — Конфигурация + внедрение идентичности (на AcpBridge)
Цель. Группа DingTalk, где любой участник упоминает бота через @, каждый участник использует одну общую сессию, агент знает, кто говорит, и текущая задача не уничтожается последующим сообщением коллеги.
0.1 — Конфигурационный профиль “qwen tag” (в основном settings.json):
// settings.json → channels."team-eng"
{
"team-eng": {
"type": "dingtalk",
"clientId": "$DINGTALK_CLIENT_ID",
"clientSecret": "$DINGTALK_CLIENT_SECRET",
"cwd": "/srv/repos/our-service",
// Многопользовательский режим: ВСЯ группа использует ОДИН sessionId. routingKey → ${name}:${threadId||chatId} (:53).
// DingTalk НЕ устанавливает threadId (:541-551) → ключ откатывается к chatId = conversationId||sessionWebhook (:534).
// Сообщение без conversationId использовало бы БЫСТРОТЕКУЩИЙ webhook — считайте это жесткой ошибкой.
"sessionScope": "thread",
// groupPolicy по умолчанию "disabled" (GroupGate :13; config-utils :98) — ДОЛЖЕН быть установлен, иначе все групповые сообщения отбрасываются.
// В режиме allowlist "*" НЕ является шаблоном членства (GroupGate :42); перечислите каждый chatId. "*" задает только ЗНАЧЕНИЯ ПО УМОЛЧАНИЮ.
"groupPolicy": "allowlist",
"groups": {
"cidXXXXXXXX": { "requireMention": true, "dispatchMode": "followup" },
"*": { "requireMention": true, "dispatchMode": "followup" },
},
"senderPolicy": "open",
"instructions": "You are the team's shared engineering agent in this DingTalk group...",
},
}Примечания, привязанные к фактическому состоянию: requireMention по умолчанию true (GroupGate.ts:49); sessionScope по умолчанию 'user' (config-utils.ts:92) — 'thread' это весь многопользовательский механизм; групповое значение по умолчанию для dispatchMode должно быть 'followup' (а не runtime 'steer', :354).
0.2 — Атрибуция отправителя. Префикс [senderName] в сиде promptText (ChannelBase.ts:316), ограниченный isGroup, срабатывает каждый turn (не ограничивается instructedSessions), при этом новый флаг Envelope.alreadyPrefixed защищает от повторного входа в collect. См. §6.1.
0.3 — Согласование dispatchMode. Установите dispatchMode для каждой группы явно; исправьте устаревший JSDoc в types.ts:42 ('collect' → 'steer'), чтобы код и комментарий совпадали (OD-5).
Затронутые файлы (Phase 0). start.ts (добавьте опциональный путь подключения DaemonChannelBridge, чтобы утвержденная миграция Phase 1 была на расстоянии одного флага); ChannelBase.ts (сид senderName + защита alreadyPrefixed + шлюз подтверждения/allowlist для /clear + /who); types.ts (новое поле Envelope.alreadyPrefixed + исправление JSDoc); docs/ (рецепт + подводные камни).
Критерии приемки.
- Два участника упоминают бота через @; оба разрешаются в один и тот же
sessionId(утверждение через картыSessionRouter); ключ маршрутизации —team-eng:<conversationId>, а не URL webhook. - Агент использует атрибуцию отправителя (
[senderName]присутствует для группы, отсутствует для 1:1); повторный вход вcollectне добавляет префикс дважды (утверждение путиalreadyPrefixed). - Групповое сообщение без упоминания отбрасывается (причина
mention_required); группа не из allowlist отбрасывается (not_allowlisted). - При
dispatchMode: 'followup'сообщение участника B во время задачи участника A не отменяет A; сообщение B выполняется после A. - В общей группе (thread)
/clearтребуетconfirmи ограниченconfig.allowedUsers, если они заданы (не свободный сброс для всех);/statusостается только для чтения. - Модульные тесты на уровне хуков (без UI-тестов с
wait(ms)): равенство ключей маршрутизации для разных отправителей; наличие префиксаpromptTextдляisGrouptrue и false; пропускalreadyPrefixed.
Phase 1 — Миграция Daemon + Проактивный движок + Замкнутый цикл MVP
Определение MVP. Единый замкнутый цикл с запланированной сводкой: оператор регистрирует cron-подобную задачу для канала; при срабатывании шлюз разрешает сессию канала с областью действия thread, запускает промпт с инструментами и отправляет результат обратно в холодный канал без запроса. Одна задача, один канал, один путь доставки. Более богатое поведение выходит за рамки MVP.
Утвержденная миграция. Phase 1 размещает каналы под qwen serve через DaemonChannelBridge (решение OD-1), наследуя FIFO promptQueue, медиатор, eventBus и маршруты. Проактивный движок описан в §6.2 (принадлежащий шлюзу, нейтральный к миграции планировщик; dispatchProactive сериализуется через sessionQueues; резервный вариант холодной отправки DingTalk через проверенный API groupMessages/send; обновление tokenManager; флаг возможности canColdSend). Три факта делают это нетривиальным: сегодня cron привязан к сессии и умирает при dispose (закрыто шлюзом единственного владельца OD-8); DingTalk не может отправлять сообщения в холодную группу (закрыто проверенным проактивным API + сохраненным openConversationId); и проактивный промпт должен сериализоваться через sessionQueues и никогда не вызывать bridge.prompt(), пока удерживается activePrompts — иначе DaemonChannelBridge выбросит Prompt already in flight (:257-261).
Затронутые пакеты. ChannelCronStore.ts/ChannelCronScheduler.ts (новые, channel-base); cronParser.ts (переиспользование); ChannelBase.ts (dispatchProactive, pushProactive, флаг canColdSend, /schedule); DingtalkAdapter.ts + dingtalk/src/proactive.ts (новая холодная отправка + сохраняемый openConversationId + tokenManager); FeishuAdapter.ts (без изменений; эталонный адаптер с поддержкой проактивной отправки, canColdSend = true); start.ts (хостинг под демоном; создание и запуск планировщика после restoreSessions(); проброс isTagSession в создание сессии, чтобы отключить cron внутри сессии — OD-8); создание сессии (пропуск startCronScheduler() для тег-сессий, Session.ts:667-668).
Критерии приемки.
- Каналы работают под управлением
qwen serve(хостятся демоном); вызов инструмента возвращаетpermission_request(медиатор доступен), что подтверждает миграцию. - Оператор регистрирует одну задачу дайджеста; она сохраняется после перезапуска шлюза (перезагружается из
~/.qwen/channels/cron.json). - При срабатывании задачи, когда нет открытой сессии, шлюз определяет сессию для треда, выполняет промпт с инструментами и доставляет сообщение в неактивную группу DingTalk через путь холодной отправки — это доказывает работу холодной доставки в группу. Движок генерирует явную ошибку (логирует, записывает
lastError, не выполняет silent no-op) приcanColdSend = false. - Та же задача доставляет сообщение в Feishu через
tenant_access_token, подтверждая работу абстракцииcanColdSend. - Срабатывание задачи не нарушает правило “один промпт на сессию”: если участник находится в процессе диалога, проактивный промпт встает в очередь через
sessionQueues(awaitactivePrompts.get(sessionId)?.done), никогда не отменяется черезsteerи никогда не вызывает исключение overlap вDaemonChannelBridge. - Проактивный ход не может быть отменен последующим ходом человека (тег-группы используют
followup, никогдаsteer). -
tokenManagerобновляет v1.0accessTokenдо истечения срока действия (~2 часа) и при получении 401, поэтому отправка после того, как сокет открыт > 2 часов, все равно проходит успешно. - Никаких двойных срабатываний для любых долговременных задач: планировщик шлюза является единственным владельцем; тег-сессия не запускает свой внутри-сессионный cron (OD-8); два хранилища находятся на непересекающихся путях.
- Удаление задачи останавливает будущие срабатывания.
- Тесты на уровне хуков/сервисов (планировщик против фейковых часов; холодная отправка против мокнутого HTTP-клиента) — никаких
wait(ms).
Фаза 2 — Память каналов + Бюджеты токенов + Журнал аудита
2.1 — Память в скоупе канала (§6.3): добавить скоуп 'channel' + channelKey в writeContextFile.ts (WriteContextFileScope :80, WriteContextFileOptions :83-97, resolveContextFilePath :223-240); поставлять ~/.qwen/channels/memory/<channelName>/<hash(chatId)>/QWEN.md; подключить CLI-коллбэки readChannelMemory/writeChannelMemory через ChannelBaseOptions + бутстрап-чтение с переиспользованием instructedSessions. Daemon-роут Фазы-2 POST /channel/:sessionId/memory только для топологии демона.
2.2 — Бюджеты токенов для каждого канала (§6.4): BudgetLedger.ts с ключом по каналу, рекомендательный (только WARN) для оценки на стороне канала, жесткий отказ только при реальном использовании демона (Fix #6/OD-9); агрегация по организации для каждого процесса + окна для каждого канала, побеждает строжайший лимит, фиксированное дневное окно; алерты на 75%/95% (зависимость от проактивной отправки).
2.3 — Журнал аудита (§6.4): RequestAttributionRing + строка task.requested; атрибуция передается вместе с выполняемым ходом (построчная currentTurnAttribution), а не через джойн по таймстемпу (Fix #7); GET /workspace/audit (демон) или команда канала /audit. In-memory FIFO на 512 записей, теряется при перезапуске (известное ограничение v1; follow-up в виде append-only файла в ~/.qwen, OD-11).
Затронутые файлы. writeContextFile.ts, workspace-memory.ts (валидация скоупа + GET walker, путь демона); BudgetLedger.ts, RequestAttributionRing.ts (channel-base); permission-audit.ts (источник паттерна) / новый channel-audit.ts (демон); ChannelBase.ts (передача senderId/senderName в queued turns + currentTurnAttribution; хуки бюджета); server.ts (монтирование роутов после express.json :2025, блокировка мутаций через mutate({ strict: true })).
Критерии приемки.
-
scope: 'channel'пишет в~/.qwen/channels/memory/<channel>/<hash(chatId)>/QWEN.md; две группы получают независимые файлы; общийQWEN.mdрабочего пространства не затрагивается; запись проходит через внедренный коллбэк (нет зависимостиchannel-base → core). - Добавление в память канала идемпотентно при конкурентном доступе (файловый мьютекс) и эмитит
memory_changedтолько при реальной мутации (путь демона; фильтрация на стороне подписчика). - На пути демона, после того как канал превышает лимит окна реального использования, следующий входящий промпт отклоняется (не обрезается), а проактивные задачи ставятся на паузу; счетчики сбрасываются при переходе на новое дневное окно; бюджеты для каждого канала независимы. На пути только с оценкой бюджет выдает WARN, но никогда не делает жесткий отказ (Fix #6).
- Вызов инструмента/запрос разрешения, возникший во время выполнения queued turn отправителя A, атрибутируется A, даже если B поставил в очередь свой ход позже через
followup(Fix #7). - Каждое проактивное срабатывание, запись в память канала и событие бюджета попадают в кольцо аудита с best-effort
senderId/senderName, доступны через поверхность аудита и не транслируются в SSE-шину. - Юнит-тесты Ring/route/resolver (FIFO eviction, разрешение путей скоупа, математика порогов бюджета, атрибуция выполняемого хода) — без UI/тайминг-тестов.
Границы фаз и дальнейшие планы
Фазы 0→1→2 аддитивны: мультиплеер + идентификация (на AcpBridge) → миграция на демона + проактивный MVP → память + бюджеты + аудит. Многоидентификационный шлюз Фазы-3 (различные идентичности/учетные данные ботов для каждого канала, настоящие принципы per-user, токены для каждого канала) не входит в скоуп, это естественный следующий шаг, который снимает ограничения единого глобального токена / одного рабочего пространства на демон. Даже в рамках Фаз 0–2 “qwen tag” требует один процесс агента на рабочее пространство (OD-2); деплоймент, обслуживающий несколько репозиториев, запускает несколько процессов.
8. qwen tag против Claude Tag (компромиссы)
Claude Tag — это хостинговый мульти-тенантный агент: Anthropic управляет рантаймом, идентификацией и поминутным учетом для каждого пользователя; приложение канала является тонким клиентом. qwen tag — это инверсия: он работает на инфраструктуре, контролируемой оператором, поверх адаптеров qwen-code. Эта инверсия — всё ценностное предложение и вся поверхность рисков.
Где qwen выигрывает
- Открытый / self-hosted, данные остаются внутри. Агент работает локально — через stdio в Фазе 0 (
AcpBridge.start()запускаетnode <cli> --acp), in-process подqwen serveначиная с Фазы 1 — никаких вендорских API. Содержимое репозиториев, трафик моделей и транскрипты остаются на хостах оператора. Claude Tag не может этого гарантировать. - MCP / любой инструмент. Строгое надмножество набора инструментов закрытого хостингового агента.
- Голосование за разрешения для каждого действия — возможность Фазы 1+ после перехода на хостинг демона. qwen-code поставляется с
MultiClientPermissionMediator(четыре политики, кворум консенсусаfloor(M/2)+1, отдельное кольцо аудита). Это реальное преимущество — недостижимое на путиAcpBridgeФазы 0 (requestPermissionавтоматически одобряет,:108-118), достижимое, когда Фаза 1 хостит каналы в демоне; даже там голоса ключуются поclientId, а канал является единственным клиентом, пока не появится реестр OD-3. Несуществующее полеChannelConfig.approvalMode(types.ts:36) подтверждает, что это было запланировано, но отсутствует. - Долговечное, инспектируемое состояние. Персистентность
SessionRouter, обычные файлыQWEN.md/AGENTS.mdи (демон, Фаза 1+) кольцо реплея Last-Event-ID. Ничего непрозрачного.
Где он расходится и должен компенсировать
- Одно рабочее пространство + один глобальный токен + нет человеческой идентификации. Один процесс привязывается к одному рабочему пространству; несколько рабочих пространств = N процессов (OD-2). Единый глобальный токен применяется к HTTP-демону; путь канала
AcpBridgeФазы 0 не имеет HTTP-поверхности и токена (его граница —SenderGate/GroupGate). Человеческая идентификация отсутствует везде —senderNameявляется только рекомендательным текстом промпта (OD-11). Компенсация: один процесс на рабочее пространство/команду; внедрение атрибуции отправителя на уровне канала; сохранениеclientIdв качестве границы безопасности; требование--require-auth+ токена для любого демона не на loopback (OD-12). - Проактивные / холодные сообщения в каналах неоднородны. Только реактивные ответы в DingTalk (истекающий
sessionWebhook); Feishu отправляет свободно черезtenant_access_token. Компенсация: проверенная проактивная отправка в группу Фазы 1 по сохраняемомуopenConversationId(DingTalk,canColdSendстановится true); для Feishu ничего не нужно. - Планировщик привязан к сессии, а не к демону. Cron умирает при
dispose()во время сбора неактивных сессий через 30 минут. Компенсация: планировщик, принадлежащий шлюзу (§6.2) — долгоживущий, переживает сбор, единственный владелец cron (OD-8). - Память глобальна для рабочего пространства, а не для каждого канала. Компенсация: один процесс на канал (ноль кода) или скоуп
channelФазы 2 (OD-10). - Мульти-идентификация / настоящий мульти-тенантинг не входят в скоуп (Фаза 3). В Фазах 0–2 моделируется как мульти-процесс.
Риски и митигация
| # | Риск | Серьезность | Митигация |
|---|---|---|---|
| R1 | Вызовы инструментов стека канала автоматически одобряются на пути AcpBridge Фазы 0 (AcpBridge.ts:108-118) — скомпрометированный канал запускает любой инструмент без шлюза. | Высокая | Запланированная миграция на демона Фазы 1 внедряет медиатор; до этого ограничить набор инструментов + доверенный хост. |
| R2 | Утечка единого глобального токена демона предоставляет полный доступ к рабочему пространству (путь HTTP-демона; путь AcpBridge не имеет токена). | Высокая | Loopback по умолчанию + bearer-шлюз; --require-auth для не-loopback (OD-12); доверенный хост; ротация через перезапуск; блокировка деструктивных инструментов за consensus после внедрения. |
| R3 | dispatchMode по умолчанию 'steer' отменяет текущую работу при любом сообщении участника (в JSDoc было 'collect', теперь исправлено на 'steer', types.ts:42). | Высокая | Тег-группы устанавливают 'followup'; JSDoc согласован (OD-5). |
| R4 | Отсутствие атрибуции отправителя → агент путает говорящих. | Высокая | Внедрение [senderName] Фазы 0 для групповых ходов (+ alreadyPrefixed, OD-6). |
| R5 | Холодная отправка в группу DingTalk / проактивность с истекшим вебхуком тихо падает (:137-141). | Средняя | Проверенная проактивная отправка в группу Фазы 1 по сохраняемому openConversationId; canColdSend генерирует явную ошибку; отображение деградаций. |
| R6 | Cron/уведомления умирают при сборе сессии (30 мин, run-qwen-serve.ts:94); также требует исходящего пути (R5). | Средняя | Планировщик, принадлежащий шлюзу (§6.2); OD-8 gate единственного владельца. |
| R7 | requireMention true → неупомянутые групповые сообщения тихо отбрасываются (GroupGate.ts:51-52). | Низкая/Средняя | Оставить по умолчанию; задокументировать; опциональная подсказка для первого сообщения. |
| R8 | Общая память рабочего пространства перекрестно загрязняет соседствующие группы. | Средняя | Один процесс на канал или скоуп channel Фазы 2 (OD-10). |
| R9 | Rate-limit на clientId/IP, а не на пользователя (путь демона); путь AcpBridge не имеет его. | Низкая | Приемлемо для сингл-тенанта; поминутный учет на пользователя — это Фаза 3. |
| R10 | Набор голосующих для консенсуса снимается на момент запроса; участники канала сегодня не являются разными clientId. | Низкая | OD-3: first-responder в Фазе 1; решить маппинг senderId→голос до консенсуса. |
| R11 | DingTalk SDK никогда не обновляет токен доступа ~2 ч, если сокет не закрывается — проактивные/эмоции/медиа тихо падают. | Высокая | tokenManager, принадлежащий проактивной фиче, обновляется через эндпоинт v1.0 oauth2/accessToken (§6.2, проверено). |
| R12 | Проактивное срабатывание, вызывающее DaemonChannelBridge.prompt() во время хода человека, выбросит Prompt already in flight (:257-261). | Высокая | dispatchProactive сериализуется через sessionQueues и ждет activePrompts перед bridge.prompt() — throw-guard структурно недостижим (Fix #1, §6.2). |
| R13 | Ложноположительное срабатывание оценочного бюджета может отклонить легитимный промпт пользователя. | Средняя | Оценки только WARN; жесткий отказ только при реальном использовании демона (Fix #6, §6.4). |
| R14 | Очередь followup неверно атрибутирует вызовы инструментов последнему поставленному в очередь отправителю. | Средняя | Передача senderId в queued turn; аудит читает выполняемый ход (Fix #7, §6.4). |
9. Принятые решения
Все Open Decisions v1 разрешены ниже с выбранными ответами. Единственные по-настоящему открытые вопросы — это детали API DingTalk с низкой степенью уверенности в рамках OD-7, выделенные в последней строке.
| ID | Вопрос | Решение |
|---|---|---|
| OD-1 | Мигрировать хостинг каналов в qwen serve для Фазы 1+, или остаться на AcpBridge? | РЕШЕНО — Мигрировать. Фаза 0 выходит на AcpBridge; Фаза 1+ хостит каналы под qwen serve через DaemonChannelBridge / daemon channel runner, наследуя FIFO promptQueue, MultiClientPermissionMediator, eventBus, /workspace/memory и rate-limit. Фаза 0 добавляет путь подключения (или --daemon <url>), чтобы переключение было шагом конфигурации. Планировщик шлюза (§6.2) нейтрален к миграции. Больше не является gate — закоммиченная архитектура. |
| OD-2 | Единица деплоя = один процесс на рабочее пространство/канал? | РЕШЕНО — Да. Один процесс на рабочее пространство/канал: изоляция памяти и секретов для каждого канала, ограничение радиуса поражения единого глобального токена. Размещение нескольких каналов вместе — это задача Фазы 3 (требует скоупа channel + governor). |
| OD-3 | Политика разрешений для мультиплеерного тега (один канал = один clientId демона)? | РЕШЕНО — Фаза 1: first-responder с одним clientId на уровне канала (любой разрешенный участник подтверждает; атрибуция на уровне канала; нет маппинга senderId→clientId). Фаза 2: consensus/designated, когда появится реестр senderId→clientId + жизненный цикл (сбор, границы refcount). Автоматический отказ в высоко-рисковых инструментах на проактивных ходах. |
| OD-4 | /clear//status в скоупе треда являются общими для канала. | РЕШЕНО — в общей (тред) группе /clear требует confirm и ограничен config.allowedUsers, если они заданы (дефисный /clear-channel не парсится; owner-gate для каждого участника отложен до модели идентификации, OD-3/OD-11); /status остается read-only для общей сессии. |
| OD-5 | Несоответствие dispatchMode по умолчанию (JSDoc 'collect' vs runtime 'steer'). | РЕШЕНО — Исправить JSDoc в types.ts:42 на 'steer' (соответствует runtime); профиль тег-группы явно устанавливает dispatchMode: 'followup'. |
| OD-6 | Формат маркера отправителя + двойной префикс в collect. | РЕШЕНО — Префикс [senderName] для каждого хода, БЕЗ привязки к instructedSessions, плюс ОДНО новое опциональное поле Envelope — alreadyPrefixed (types.ts), чтобы синтетический повторный вход в режиме collect пропускал повторное добавление префикса. (Исправляет утверждение v1 “никаких новых полей”). |
| OD-7 | Проактивная отправка DingTalk: эндпоинт/разрешение, эквивалентность openConversationId, обновление токена. | РЕШЕНО с проверенными фактами (§6.2/§6.5): эндпоинт POST https://api.dingtalk.com/v1.0/robot/groupMessages/send (высокая уверенность); тело { robotCode=config.clientId, openConversationId, msgKey:'sampleMarkdown', msgParam:<JSON string {title,text}> } (высокая); заголовок auth x-acs-dingtalk-access-token с токеном v1.0 oauth2/accessToken, TTL ~7200 с, кэшируется и обновляется tokenManager, принадлежащим фиче (высокая); сохранять openConversationId в ~/.qwen/channels/dingtalk-groups.json; callback conversationId≈openConversationId (средняя; fallback на API конвертации chatId→openConversationId при invalid.openConversationId). Осталось открытым (низкая уверенность): точный код/отображаемое имя permission-point; дословная официальная фраза об эквивалентности; применяется ли троттлинг 20/мин к groupMessages/send. |
| OD-8 | Двойное срабатывание Cron между планировщиками шлюза и сессии. | РЕШЕНО — Планировщик шлюза является ЕДИНСТВЕННЫМ владельцем cron. Сессия, хостируемая в канале (тег), не запускает свой внутри-сессионный Session cron; она узнает, что является тег-сессией через флаг isTagSession, проброшенный из хоста канала при создании сессии (пакет опций DaemonChannelSessionFactory для Фазы 1+; опция спавна --acp для Фазы 0), что пропускает startCronScheduler() (Session.ts:667-668). Два хранилища cron находятся на непересекающихся путях (шлюз ~/.qwen/channels/cron.json против сессии ~/.qwen/tmp/<hash>/scheduled_tasks.json), поэтому единственный риск коллизии — запуск обоих планировщиков для одних и тех же задач — устранен gate. |
| OD-9 | Скоуп бюджета токенов, источник истины, окно. | РЕШЕНО — Агрегация “org” для каждого процесса + окна для каждого канала, побеждает строжайший, фиксированное дневное окно. v1 оценивает токены на стороне канала (рекомендательно, только WARN — никогда не делает жесткий отказ, Fix #6) и читает путь использования демона для точного списания (и жесткого отказа) после перехода на хостинг демона. |
| OD-10 | Неймспейсинг памяти для каждой комнаты + полномочия на запись. | РЕШЕНО — Добавить скоуп channel (+channelKey) в writeContextFile.ts; channel-base получает запись/чтение через CLI-коллбэк, внедренный через ChannelBaseOptions (readChannelMemory/writeChannelMemory) — НЕТ зависимости channel-base → core. Глобальное пользовательское расположение ~/.qwen/channels/memory/. Агент добавляет через интент save_memory; бутстрап-чтение переиспользует gate instructedSessions. |
| OD-11 | Модель человеческой идентификации + долговечность аудита. | РЕШЕНО — senderName только рекомендательный; clientId остается единственным security principal. Best-effort атрибуция передается с выполняемым ходом (Fix #7); in-memory FIFO audit ring на 512 + follow-up append-only файл в ~/.qwen. |
| OD-12 | Усиление токена для деплойментов с демоном не на loopback. | РЕШЕНО — Требовать --require-auth + токен для любого деплоя с демоном не на loopback. Только loopback — только для разработки; --require-auth — задокументированная позиция по умолчанию (run-qwen-serve.ts уже требует токен для не-loopback). |
| OPEN (only remaining) | Детали API DingTalk с низкой уверенностью в рамках OD-7. | ВСЕ ЕЩЕ ОТКРЫТО — проверить в консоли / по живой документации перед кодингом: (1) точный код/отображаемое имя permission-point для “проактивной отправки группового сообщения” (низкая); (2) авторитетная официальная фраза, приравнивающая callback conversationId к openConversationId для стандартного не-cool-app робота (средняя; гарантированный документацией путь — API конвертации chatId→openConversationId); (3) применяется ли лимит “20 сообщений/минуту → ~10-минутный троттлинг” дословно к groupMessages/send (низкая/средняя — задокументировано для кастомных webhook-роботов, не подтверждено на странице отправки orgapp). |
10. Риски и меры по их снижению
См. сводную таблицу в §8. Ключевые риски в порядке приоритета:
- R1 — автоподтверждение в канальном пути Phase-0. Пока запланированная в Phase-1 миграция демона не внедрит опосредованный транспорт, агент, работающий в канале, выполняет любой инструмент без проверок. Самый важный пробел в безопасности; снижаем риск с помощью консервативного набора инструментов + доверенного хоста до наступления Phase 1.
- R12 — исключение при проактивном перекрытии. Вызов
DaemonChannelBridge.prompt()во время хода пользователя выбрасываетPrompt already in flight(:257-261). Закрывается сериализацией черезsessionQueues(Fix #1) — центральным элементом §6.2. - R11 — истечение срока действия токена DingTalk. Ошибка из разряда «работает в демо, но падает через 2 часа». Проактивная функция использует собственный
tokenManager(проверенный эндпоинт v1.0, TTL ~7200 с) до выпуска любой долгоживущей функции. - R5 — тихий сбой в «холодной» группе DingTalk. Проактивный вывод в неактивные группы невозможен без проверенного пути отправки;
canColdSendгенерирует явную ошибку вместо молчаливого игнорирования. - R3 — отмена
steerв группах. Случайный DoS для нескольких пользователей при значении по умолчанию в рантайме; профиль тега устанавливаетfollowup. - R13/R14 — ложные срабатывания бюджета и неверное присвоение. Оценки выдают только WARN (Fix #6); присвоение переносится вместе с выполняемым ходом (Fix #7).
- R8 — перекрестное загрязнение общей памяти. Один процесс на канал — это мера снижения риска без изменения кода; скоуп
channelрешает проблему совместного размещения.
Каждый риск привязан к фазе: R1/R3/R4 относятся к Phase 0–1, R5/R6/R11/R12 — к Phase 1, R8/R13/R14 и риски аудита/бюджета — к Phase 2.
11. Приложение: Индекс файлов и символов
Базовый канал (packages/channels/base/src/)
SessionRouter.ts—routingKey()(:44-60, thread:53, single:55, user:58), скоуп по умолчанию'user'(:25),setChannelScope()(:40-42),resolve()(:72-92),getTarget()(:94),persist()/restoreSessions()(:168-244),PersistedEntry(:5-9).ChannelBase.ts—handleInbound()(:238-471), формирование промпта (:316-347), вызовbridge.prompt()(:425), гейты (:240-252), разрешениеdispatchMode(:353-354), steer (:371-379), collect (:361-370,445-463), followup (:381-383,394-470),activePrompts(:32-35,356),sessionQueues(:394,466), абстрактныйsendMessage()(:81),registerCommand()(:141-143), роутер конструктора (:62-64),ChannelBaseOptions(:9-22,46),/clear//status(:147-217).AcpBridge.ts— spawn--acp(:53-70),newSession(cwd)(:131),prompt()(:147-180), автоподтверждениеrequestPermission(:108-118),AcpBridgeOptions(:17-21).DaemonChannelBridge.ts—newSession/loadSessionsessionScope'thread'(:229,240), пакет опций фабрики сессий (:226-241), защитаactivePrompts/ выбросPrompt already in flight(:257-261),cancelSession(:332),respondToPermission(:346-374), события разрешений (:557-633).GroupGate.ts—requireMentionпо умолчанию true (:49), членство (:42), гейтинг упоминаний (:51-52), цепочка fallback (:48), политика по умолчанию'disabled'(:13).SenderGate.ts—check()+ pairing (:42).types.ts—GroupConfig(:10-13),ChannelConfig(:27-51),approvalMode(:36), JSDocdispatchModeисправлен на'steer'(:42),senderName(:69), новое полеalreadyPrefixed,isGroup(:75),SessionTarget(:88-93).
DingTalk (packages/channels/dingtalk/src/)
DingtalkAdapter.ts— mapwebhooks(:84),sendMessage()(:134-170, возврат без webhook:137-141), кэш webhook (:516-517),getAccessToken()(:172-174),emotionApi()(:188-207, robotCode:184, openConversationId:197, антипаттерн empty-catch:214-216), media robotCode (:435), входящийconversationId(:506), удаление упоминания (:527-529),isMentioned(:520),senderName(:544),extractQuotedContext()(:272-298),chatId(:534), отсутствиеthreadId(:541-551).proactive.ts(новый) —sendGroupMessage()черезPOST /v1.0/robot/groupMessages/send(robotCode+openConversationId+msgKey:'sampleMarkdown'+msgParamJSON-строка),tokenManager(v1.0oauth2/accessToken, TTL ~7200 с, таймер + обновление по 401), fallback конвертацииchatId→openConversationId.markdown.ts—convertTables()(:44-80),splitChunks()(:84-188),CHUNK_LIMIT=3800(:10; ≤ бюджетаsampleMarkdown~5000 символов),extractTitle()(:190-195),normalizeDingTalkMarkdown()(:198-201).media.ts— заголовокdownloadMedia(:39), тело:42.- SDK:
client.mjsgettoken (:85-87), reconnect (:157-163), разделение event/callback (:14-19,35-37,58-61,241-257);constants.d.tssessionWebhookExpiredTime(:13),robotCode(:19),TOPIC_CARD(:4).
Feishu (packages/channels/feishu/src/)
FeishuAdapter.ts— проактивныйsendMessage()(:622-676, эндпоинт:651;canColdSend = true),refreshToken()(:581-620), режимыconnect()(:146-176),updateCard()(:742-792), дедупликация ingest (:1633-1870).markdown.ts— содержимое карточки schema-v2 (:69-189),splitChunks()(:198-256).
Core (packages/core/src/)
memory/writeContextFile.ts—WriteContextFileScope(:80, +'channel'),WriteContextFileOptions(:83-97, +channelKey),resolveContextFilePath()(:223-240, +веткаchannel+ параметрchannelKey), мьютекс на файл (:48-57,159-162), защита абсолютного пути (:142-146),MAX_EXISTING_FILE_BYTES(:255), режим замены (:202-211).utils/cronParser.ts—parseCron/matches/nextFireTime(:104,141,168).utils/cronTasksFile.ts—DurableCronTask(:19-26), хешированный путь для каждого проекта (:1-9).Session.ts— объявления полейcronQueue/cronProcessing(:667-668),startCronScheduler()(:758, пропускается для сессий с тегами согласно OD-8), очистка cron вdispose()(:790-812),#recordPromptTokenCount()(:2078-2087),setNotificationCallback()(:2638-2668),isIdle()(:777).
Serve / daemon (packages/cli/src/serve/, packages/acp-bridge/src/)
bridge.ts— FIFOpromptQueueдля каждогоSessionEntry(:232,2855,3082),publishWorkspaceEvent(:3610,3649-3675).eventBus.ts—BridgeEvent.dataпроизвольной формы (:51),originatorClientId(:60), пороги гистерезиса (:101-103), кольцевой буфер повтора (:92).permissionMediator.ts— четыре политики + кворум консенсуса (:348,621-637).permission-audit.ts—PermissionAuditRingFIFO 512 (:128-172), объединение закрытых записей (:57-104), документация в заголовке, предполагающая реализацию GET-запросов (:22-25).rate-limit.ts— токены-бакеты для каждого(clientId|ip);X-Qwen-Client-Id(:110).auth.ts— глобальный bearer-токен (:259-266), строгийcreateMutationGate(:356).workspace-memory.ts— скоупыworkspace|global(:118-125), мутация со строгой аутентификацией (:114), лимит на записьMAX_MEMORY_CONTENT_BYTES(:79), фиксированная передачаprojectRoot(:185-190).
Команды CLI для каналов (packages/cli/src/commands/channel/)
start.ts—startCommand(:479-499), созданиеAcpBridge(:213,268,356,435),setChannelScope(:361-362),restoreSessions(:275,444),sessionsPath()(:56-58),checkDuplicateInstance()(:170-179), обработчик отключения (:241,403); путь подключения демона Phase 1+; внедрениеreadChannelMemory/writeChannelMemoryна уровне CLI.config-utils.ts—parseChannelConfig()(:81-100, sessionScope по умолчанию:91-92, approvalMode:94, groupPolicy:98),resolveEnvVars()(:6-18).channel-registry.ts—ensureBuiltins()(:6-32), типы каналов (:10-14).