RFC: “qwen tag” — персистентный мультиплеерный агент, резидент канала для qwen-code (в первую очередь для DingTalk)
Статус: Черновик (v2) Дата: 2026-06-25 Автор: (qwen-code)
Список изменений (v1 → v2)
В этой редакции закрыты все открытые решения (Open Decisions) из v1 (теперь это Принятые решения, §9) и исправлены семь дефектов корректности/согласованности, выявленных при ревью. Два ключевых изменения:
- OD-1 больше не является блокером — это зафиксированная архитектура. Фаза 0 (Phase 0) поставляется на текущем пути
AcpBridge; Фаза 1+ переносит хостинг каналов в демонqwen serve(черезDaemonChannelBridge/ раннер демонов каналов) для повторного использования пер-сессионной FIFOpromptQueue,MultiClientPermissionMediator,eventBus,/workspace/memoryи rate-limit. Каждый раздел, где ранее было написано “OD-1 открыт / блокирует всё”, теперь отражает принятое решение, и обязательство по использованию демона распространяется на §1, §4, §5, §6.1, §6.2, §6.3, §6.4 и §7. - Проактивный путь запуска (fire-path) переработан под реальный демон-путь.
dispatchProactiveиз v1 был написан под семантикуAcpBridge(очередиsessionQueuesна стороне канала). При миграции на демонDaemonChannelBridge.prompt()выбрасывает исключениеPrompt already in flightпри наложении запросов (DaemonChannelBridge.ts:257-261) вместо постановки в очередь. В v2 проактивные промпты сериализуются черезChannelBase.sessionQueuesдля обоих вариантов, поэтому защита от выброса исключения никогда не срабатывает, а инвариант “никогда не отменяется” (never-cancellable) явно зафиксирован (§6.2).
Включенные решения и исправления:
- OD-2 принято: один процесс на воркспейс/канал.
- OD-3 принято: в Фазе 1 используется
first-responder+ единыйclientIdна уровне канала; в Фазе 2 —consensus/designatedпосле появления реестраsenderId→clientIdи жизненного цикла; автоматический запрет (auto-deny) инструментов высокого риска на проактивных ходах. - OD-4 принято: в общей группе (тред)
/clearтребует явногоconfirmи ограничен спискомconfig.allowedUsers, если он задан;/statusтолько для чтения. (Команда/clear-channelс дефисом не парсится грамматикой слэш-команд; полноценный гейт владельца на каждого участника ожидает внедрения модели идентификации — OD-3/OD-11.) - OD-5 принято: исправить устаревший JSDoc в
types.ts:42на'steer'; профиль группы тегов явно устанавливаетdispatchMode: 'followup'. - OD-6 принято: префикс
[senderName]на каждый ход (turn), без привязки к гейтуinstructedSessions; одно новое опциональное полеEnvelope—alreadyPrefixed, чтобы синтетический повторный вход в режимеcollectпропускал повторное добавление префикса. (Исправляет утверждение из v1 “нет новых полей envelope” — Fix #2.) - OD-7 решено на основе проверенных фактов о DingTalk API (§6.2/§6.5), элементы с низкой уверенностью по-прежнему помечены.
- OD-8 принято: планировщик шлюза/демона является единственным владельцем cron; сессия тега не запускает свой внутри-сессионный
Sessioncron; два хранилища cron находятся на непересекающихся путях, поэтому коллизия возможна только если оба планировщика запущены для одних и тех же задач. - OD-9 принято: агрегация “org” на каждый процесс + окна на каждый канал, побеждает самый строгий, фиксированное дневное окно; в v1 оценка токенов происходит на стороне канала, а путь использования демона читается после перехода на хостинг в демоне.
- OD-10 принято: добавить скоуп
channel(+channelKey) вwriteContextFile.ts; channel-base получает запись/чтение через колбэк CLI-слоя, внедряемый черезChannelBaseOptions(без зависимостиchannel-base → core); глобальное пользовательское расположение~/.qwen/channels/memory/. - OD-11 принято:
senderNameтолько для справки;clientId— единственный принципал безопасности; кольцо аудита в памяти + файл follow-up с добавлением в конец в~/.qwen. - OD-12 принято: требовать
--require-auth+ токен для любого развертывания с демоном, не использующего loopback.
Исправления корректности сверх решений OD:
- Fix #1 — конкурентность проактивного пути запуска переработана для демон-пути (§6.2), инвариант “никогда не отменяется” применяется как для варианта Фазы 0 с
AcpBridge, так и для варианта Фазы 1+ с демоном. - Fix #2 — внутреннее противоречие удалено: §6.1/G2 больше не утверждает “нет новых полей envelope”; он признает наличие одного поля
alreadyPrefixed. - Fix #3 — спроектирована привязка памяти (§6.3): точное изменение
ChannelBaseOptions(колбэкиreadChannelMemory/writeChannelMemory) и то, кто их создает/внедряет вstart.ts, при этом первичное чтение при загрузке сессии (bootstrap read) повторно использует гейтinstructedSessions. - Fix #4 — спроектирован флаг возможности
canColdSend(§6.2): где он объявлен, как его устанавливают DingTalk/Feishu и как планировщик выдает явную ошибку (fails loud). - Fix #5 — уточнение непересекающихся хранилищ для OD-8 (§6.2): хранилище шлюза и хранилище
Session— это разные пути; единственный риск коллизии — если сессия тега также запускает внутри-сессионный cron, что закрыто гейтом OD-8. - Fix #6 — принудительное применение оценочного бюджета (§6.4): оценка может выдавать WARN/алерт, но никогда не должна жестко отклонять (hard-decline) промпт пользователя; ЖЕСТКОЕ отклонение только по реальным цифрам использования демона.
- Fix #7 — атрибуция аудита в режиме
followup(§6.4): передачаsenderIdвместе с промптом в очереди, чтобы вызов инструмента/разрешение атрибутировались ходу, который фактически выполняется, а не последнему поставленному в очередь отправителю.
Проверенные факты из v1 (топология AcpBridge, авто-одобрение AcpBridge, абстрактный sendMessage, скоупы, дефолты парсера) сохранены без изменений.
1. Резюме
“qwen tag” — это единый общий агент qwen-code, который живет внутри чат-канала (в первую очередь группы DingTalk, во вторую — Feishu) и вызывается любым участником этого канала через @-упоминание. После вызова он запускает полный цикл агента qwen-code (инструменты, редактирование файлов, shell, MCP) для привязанного воркспейса, стримит свою работу обратно в канал по мере выполнения, запоминает канал между ходами и перезапусками и может действовать проактивно или по расписанию, не дожидаясь запроса. Это повторяет форм-фактор Claude Tag — единый персистентный мультиплеерный агент, который является резидентом комнаты, а не ботом для личных сообщений 1-на-1, — но он полностью построен на существующем стеке адаптеров каналов qwen-code (qwen channel start, packages/channels/*) и демоне qwen serve, а не на новом хостинговом сервисе.
Намеренный фокус этого RFC заключается в том, что реактивная половина форм-фактора по большей части уже поставлена, а проактивная/memory-половина — нет. Компоненты, которые делают сложным создание агента ответов в стиле Claude Tag, — долгоживущий процесс с мультиплексированием сессий, транспорт агента, сохраняющий инвариант “один промпт на сессию”, маршрутизация мультиплеерных сессий, контроль доступа на уровне канала, рендеринг стримящихся карточек и надежное сохранение сессий, — уже существуют и используются текущими адаптерами каналов. Не хватает четко ограниченного набора возможностей, которые превращают реактивного бота ответов в агента-резидента: атрибуция отправителя в общих сессиях, проактивный/планируемый путь вывода, память для каждой комнаты и мультиплеерное управление. Этот RFC определяет этот пробел в виде четырех областей разработки и специфицирует их для Фаз 0–2.
Примечание о “80%”: в ранних черновиках это формулировалось как “~80% поставлено”. Эта цифра непроверяема и преувеличивает ситуацию — весь проактивный движок (Область разработки 2) и память для каждой комнаты (Область разработки 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 начиная с Фазы 1.
- Фаза 0 поставляется на текущем пути
AcpBridge(инъекция идентификации не требует ни HTTP-маршрутов, ни медиатора). - Фаза 1+ запускает каналы под управлением демона
qwen serve(черезDaemonChannelBridgeили раннер демонов каналов), поскольку проактивному движку, сохранению памяти для каждой комнаты и управлению требуются долговечность, маршруты,promptQueue, медиатор и шина событий демона.
Это больше не “открытый вопрос” и не “блокатор”: проводка Фазы 0 добавляет путь подключения DaemonChannelBridge (или флаг --daemon <url>), чтобы миграция была доступна в момент начала Фазы 1. Принадлежащий шлюзу планировщик (§6.2) построен нейтральным к миграции, чтобы он работал одинаково до и после переключения.
Что такое “qwen tag” конкретно
Развертывание “qwen tag” — это единый процесс агента, привязанный к одному воркспейсу, плюс адаптер qwen channel start dingtalk, настроенный так, что вся группа использует одну сессию агента. Должны совпадать две различные концепции скоупа:
- Скоуп маршрутизации канала (
ChannelConfig.sessionScope, используетсяSessionRouter.routingKey()): определяет, как входящие сообщения сопоставляются с ключом маршрутизации. Для тега это должно быть'thread', чтобы вся группа использовала один ключ маршрутизации (channel:(threadId||chatId),SessionRouter.ts:53). Дефолт парсера —'user', а не'thread'(config-utils.ts:91-92), поэтому в рецепте тега его нужно задавать явно. - Скоуп сессии бриджа/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,
markdown.ts; разбивка на чанки markdown в DingTalk,DingtalkAdapter.ts:144-169). - Запоминает канал.
SessionRouter.persist()/restoreSessions()надежно сохраняютsessionId, цель иcwdи восстанавливают их черезbridge.loadSession()при перезапусках (SessionRouter.ts:168-244); память воркспейса (QWEN.md/~/.qwen/QWEN.md) читается/пишется черезGET/POST /workspace/memory(workspace-memory.ts). Эта память имеет скоуп воркспейса/глобальный, а не для каждой комнаты — см. Область разработки 3. - Может действовать проактивно / по расписанию. Это та половина, которой еще не существует сквозным образом и которая является сердцем Фазы 1.
2. Мотивация
Инфраструктура, которая обычно требуется резидентному мультиплеерному ответному агенту, уже реализована в этом репозитории. Фактически не реализованной остается работа в четырех направлениях разработки.
| Функциональность, необходимая для форм-фактора Tag | Уже реализовано (ссылка) |
|---|---|
| Долгоживущий, многосессионный процесс | AcpBridge запускает долгоживущий дочерний процесс --acp (AcpBridge.ts:53-70); путь демона добавляет FIFO promptQueue для каждой сессии (bridge.ts:232,2855,3082) |
| Мультиплеерная маршрутизация “одна комната, одна сессия” | SessionRouter, область действия 'thread' (SessionRouter.ts:53), переопределение для каждого канала setChannelScope() (SessionRouter.ts:40) |
| Семантика вызова по упоминанию | GroupGate, requireMention по умолчанию true (GroupGate.ts:49-52) |
| Контроль доступа + онбординг | SenderGate, whitelist + флоу с кодом сопряжения; гейты применяются сначала для группы, затем для отправителя (ChannelBase.ts:240-252) |
| Сохранение маппинга сессий между перезапусками | Персистентность SessionRouter (SessionRouter.ts:168-244) |
| Чтение/запись памяти рабочего пространства | GET / POST /workspace/memory (workspace-memory.ts); только области действия workspace и global; только для демона |
| Контроль прав нескольких участников + аудит (только для демона) | MultiClientPermissionMediator, четыре политики, включая кворум consensus (permissionMediator.ts:621-637); отдельное кольцо аудита разрешений (permission-audit.ts) |
| Аутентификация, ограничение частоты запросов, безопасность loopback (только для демона) | Глобальный bearer-токен (auth.ts:259-266) + многоуровневое ограничение частоты запросов для каждого clientId/IP (rate-limit.ts) |
| Примитив push-уведомлений в сессии (фоновые задачи) | Очередь уведомлений Session + setNotificationCallback() направляет вывод фоновых задач/мониторов/оболочки в открытую сессию (Session.ts:688-689,2638-2668); isIdle() учитывает это (Session.ts:777) |
| Доставка на платформы (DingTalk + Feishu) | Рабочие адаптеры с потоковыми карточками, медиа и реакциями (DingtalkAdapter.ts, FeishuAdapter.ts) |
Поскольку Фаза 1+ выполняется под управлением демона (зафиксированная архитектура, §1), строки таблицы, помеченные как “только для демона”, становятся доступными возможностями для проактивного движка, сохранения памяти и управления — а не просто “целями на случай миграции”.
Четыре направления разработки, подробно описанные в §6:
- Конфигурация и идентификация для объявления тега (Фаза 0). Задокументированный рецепт конфигурации —
sessionScope: 'thread',groupPolicy,requireMention,instructions,dispatchMode— плюс пробел с атрибуцией отправителя:handleInbound()намеренно не внедряетsenderNameвpromptText(ChannelBase.ts:316-347;senderNameиспользуется только для контроля доступа вChannelBase.ts:246). В общей сессии'thread'агент не может понять, кто говорит. Фаза 0 внедряет маркер отправителя, точно так же, как уже внедряется контекст цитирования ответа (ChannelBase.ts:318). - Проактивный движок / движок инициации исходящих сообщений (Фаза 1). Сегодня нет проактивного пути на границе канала:
ChannelBase.sendMessage()является абстрактным (ChannelBase.ts:81) и вызывается только изнутри ответа. В DingTalksendMessage()может отвечать только через кратковременныйsessionWebhook, кэшируемый для каждогоconversationIdпри входящем сообщении (DingtalkAdapter.ts:134-142), поэтому холодной группе вообще нельзя написать (DingtalkAdapter.ts:137-141возвращает пустой результат). Фаза 1 добавляет резидентный для демона планировщик и проактивный путь отправки для DingTalk. - Резидентная для канала память + извлечение (Фаза 2, часть памяти). Память рабочего пространства является глобальной для рабочего пространства, а не для каждой комнаты:
POST /workspace/memoryпринимает толькоscope: 'workspace' | 'global'(workspace-memory.ts:118-125) и является строго аутентифицированным маршрутом мутации (deps.mutate({ strict: true }),workspace-memory.ts:114). Тегу, который “помнит этот канал”, нужно пространство имен памяти для каждой комнаты. - Мультиплеерное управление + безопасность (Фаза 2, часть управления). Политики разрешений, подходящие для групп, ограничители для проактивных действий и детальный аудит, построенные на существующем механизме уровня
clientId(а не уровня личности человека).
3. Цели и не-цели
Цели
- G1 — Задокументировать и выпустить конфигурацию “тега” в 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через CLI-колбэк, внедряемый черезChannelBaseOptions(без зависимостиchannel-base → core). - G5 — Мультиплеерное управление. Политики разрешений, подходящие для групп, ограничители для проактивных действий и аудит, построенные на
MultiClientPermissionMediatorи кольце аудита разрешений. Должен учитывать тот факт, что голоса атрибутируютсяclientId, а не личности человека, и что в одной общей сессии'thread'каждый участник группы является одним и тем же клиентом демона. - G6 — Паритет Feishu для всего в G1–G5, рассматривается как последующий этап. Стабильный
tenant_access_tokenFeishu уже поддерживает проактивные отправки в любой чат, имея толькоchatId(FeishuAdapter.ts:622-651), поэтому Feishu не нужен новый API отправки для G3 — только механизм пробуждения/планирования на уровне демона. Feishu объявляетcanColdSend = true. - G7 — Повторное использование вместо изобретения заново. Каждая область разработки расширяет существующий механизм (гейты, роутер, бридж, медиатор, маршруты памяти, путь уведомлений в сессии, cron), а не вводит параллельную подсистему.
Не-цели
- NG1 — Не размещенный, мультитенантный SaaS. “Тег qwen” — это один агентский процесс, привязанный к одному рабочему пространству (
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, whitelist + сопряжение (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 + CLI-колбэк (без зависимости от core) | Код (M) |
| Голосование за разрешения для нескольких участников | MultiClientPermissionMediator, 4 политики (permissionMediator.ts:621-637) | D (унаследовано от Фазы 1+) | AcpBridge автоматически одобряет (AcpBridge.ts:108-118); голоса привязаны к clientId, один клиент на канал | Код (L) |
| Журнал аудита | PermissionAuditRing FIFO 512 (permission-audit.ts) | D + кольцо на стороне канала | Нет человеческого senderId; в памяти, теряется при перезапуске; дополнение с append-only в ~/.qwen | Код (M) |
| Бюджет токенов / затрат | нет (ограничение частоты только по количеству запросов, rate-limit.ts) | реестр на стороне канала + использование D | Нет счетчика расходов; оценки v1 (рекомендательные), реальный дебет только при размещении на демоне | Код (M) |
| Область действия инструментов/MCP для каждого канала | coreTools/allowedTools/excludeTools (config.ts:727-729); фильтр разрешений MCP (:3327-3333) | для каждого Config | Нет пути аргументов spawn от канала к дочернему процессу --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 — это не новый runtime. Это четыре тонких слоя, добавленных поверх существующего стека адаптеров. Базовый слой уже обеспечивает работу агента с поддержкой многопользовательского режима, выполнения инструментов и MCP, доступного через чат-канал. Четыре новых слоя закрывают следующие пробелы 1:1: (1) кто говорит — идентификатор отправителя никогда не попадает в промпт; (2) действие без запроса — нет пути для исходящей инициации, внутри-сессионный cron умирает вместе с сессией; (3) память канала — память глобальна для рабочего пространства; (4) управление общим мозгом — авторизация использует один глобальный токен, без бюджета на канал.
Каждый слой ниже указывает, какую топологию он предполагает (см. §1). Принятое разделение: Фаза 0 на AcpBridge; Фаза 1+ на демоне qwen serve через DaemonChannelBridge.
Базовый слой (существующий) — топология qwen channel start (Фаза 0)
один хост, одно рабочее пространство
┌──────────────────────────────────────────────────────────────────────────────┐
│ qwen channel start dingtalk │
│ │
│ ┌────────────────────┐ Envelope ┌───────────────────────────────────┐ │
│ │ DingtalkAdapter │ ──────────────▶ │ ChannelBase.handleInbound() │ │
│ │ (stream client, │ │ 1 GroupGate.check (упоминание/ │ │
│ │ webhooks map by │ ◀────────────── │ политика/белый список) │ │
│ │ conversationId) │ text/markdown │ 2 SenderGate.check (сопряжение) │ │
│ │ sendMessage() │ │ 3 команды slash / "!" │ │
│ └────────────────────┘ │ 4 router.resolve(...) │ │
│ ▲ sessionWebhook (истекает, │ 5 dispatchMode (управление по │ │
│ │ только для входящих сообщ.) └───────────────┬───────────────────┘ │
│ │ │ sessionId │
│ │ ┌────────────────▼──────────────────┐ │
│ │ │ SessionRouter │ │
│ │ │ routingKey(): user|thread|single │ │
│ │ │ persist() → JSON (восст. после сбоя)│
│ │ └────────────────┬──────────────────┘ │
│ │ события textChunk / toolCall ┌────────────────▼──────────────────┐ │
│ └─────────────────────────────── │ AcpBridge (НЕ HTTP-демон) │ │
│ │ запускает дочерний `node <cli> --acp`│
│ │ ClientSideConnection через stdio │ │
│ │ requestPermission АВТО-ОДОБРЕНИЕ │ │
│ └────────────────┬──────────────────┘ │
└──────────────────────────────────────────────────────────┼─────────────────────┘
│ ACP / NDJSON (stdio)
┌──────────────────▼─────────────────────┐
│ дочерний процесс агента (`--acp`) │
│ один промпт в полете на ACP сессию │
│ внутри-сессионный cron (Session.ts) — │
│ ОТКЛЮЧЕН для tag сессий (OD-8); MCP, │
│ инструменты. │
│ НЕТ promptQueue/eventBus/mediator │
└─────────────────────────────────────────┘Топология с хостингом на демоне (Фаза 1+) — qwen serve + DaemonChannelBridge
один хост, одно рабочее пространство, ОДИН демон
┌──────────────────────────────────────────────────────────────────────────────┐
│ qwen channel start dingtalk (каналы размещены ВНУТРИ демона) │
│ ┌────────────────────┐ Envelope ┌────────────────────────────────────────┐│
│ │ DingtalkAdapter │ ──────────▶ │ ChannelBase.handleInbound() ││
│ │ pushProactive() │ ◀────────── │ gates → governor.admit → router ││
│ │ canColdSend = false*│ │ → sessionQueues (FIFO, сериализация) ││
│ └────────────────────┘ └───────────────┬────────────────────────┘│
│ ▲ проактивная групповая отправка │ bridge.prompt() ││
│ │ (openConversationId) ┌───────────────▼────────────────────────┐│
│ ┌──────┴────────────┐ │ DaemonChannelBridge ││
│ │ ChannelCronSched │──fire────────▶│ prompt() ВЫБРАСЫВАЕТ исключение при ││
│ │ (принадлежит │ dispatchProa- │ перекрытии (:257-261) ││
│ │ шлюзу, единственный│ ctive через │ → поэтому все промпты ДОЛЖНЫ поступать ││
│ │ владелец cron) │ sessionQueues │ сериализованно через sessionQueues ││
│ └────────────────────┘ └───────────────┬────────────────────────┘│
│ │ in-process Session │
│ ┌────────────────▼────────────────────────┐│
│ │ демон: acp-bridge FIFO promptQueue, ││
│ │ MultiClientPermissionMediator, eventBus, ││
│ │ маршруты /workspace/memory + /channel, ││
│ │ rate-limit, bearer auth ││
│ └──────────────────────────────────────────┘│
└──────────────────────────────────────────────────────────────────────────────┘
* canColdSend в DingTalk становится true после выпуска пути проактивной отправки (§6.2).Ключевые инварианты, на которые мы опираемся (проверено):
- Thread scope — ключ к многопользовательскому режиму.
routingKey()возвращает${channelName}:${threadId || chatId}в режиме'thread'(SessionRouter.ts:53);resolve()переиспользует этот ключ (:79-83). Scope по умолчанию —'user'(:25);qwen channel startустанавливает scope для каждого канала через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). Значение по умолчанию в runtime —'steer'(:354); JSDoc вtypes.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).
Четыре новых слоя
┌───────────── управление (Слой 4) ─────────────┐
│ шлюз бюджета ходов/затрат для каждого канала │
│ белый список проактивных действий, тихие часы,│
│ аварийный выключатель │
└───────────────────────┬─────────────────────────┘
│ оборачивает все входящие + исходящие
inbound ┌──────────────────────────▼─────────────────────────┐ outbound
───────▶ │ внедрение идентификатора (Слой 1) │ ────────▶
│ добавление к promptText контекста спикера + канала │
└──────────────────────────┬─────────────────────────┘
│
┌──────────────────────────▼─────────────────────────┐
│ память канала (Слой 3) │
│ фрагмент для каждого канала, внедряется при │
│ старте сессии; сохраняется через callback │
│ на уровне CLI (базовый помощник) │
└──────────────────────────┬─────────────────────────┘
│
┌──────────────────────────▼─────────────────────────┐
│ проактивный движок (Слой 2) │
│ планировщик шлюза → sessionQueues → bridge.prompt →│
│ channel.pushProactive() с фоллбэком для хол. групп │
└─────────────────────────────────────────────────────┘Слой 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, срабатывающего раз за сессию), плюс новый scope channel на пути записи, доступный из channel-base через внедренные callbacks (без зависимости channel-base → core). Подробно описано в §6.3.
Слой 4 — Управление. Топология: обертка-шлюз на стороне канала; rate-limiter на стороне демона в Фазе 1+. Демон имеет один глобальный bearer токен (auth.ts:259-266), ограничение скорости для каждого clientId/IP и не имеет бюджета на канал. Дизайн: ChannelGovernor/BudgetLedger, оборачивающие handleInbound() и планировщик. Подробно описано в §6.4.
Поток данных 1 — входящий @qwen в групповом треде
Этот поток идентичен по форме в обеих топологиях; единственное отличие заключается в том, где находятся сериализация и разрешения. В AcpBridge (Фаза 0) сериализация обеспечивается ChannelBase.sessionQueues, а разрешения автоматически одобряются дочерним процессом; в демоне (Фаза 1+) сериализация по-прежнему осуществляется через ChannelBase.sessionQueues (защита от исключений демона никогда не срабатывает, потому что слой канала уже выполнил сериализацию), а разрешения проходят через MultiClientPermissionMediator.
- DingTalk → адаптер. Участник отправляет “@qwen summarize today’s incidents”. Stream-клиент доставляет
DingTalkMessageDataсconversationId,sessionWebhook, отправителем иisInAtList.DingtalkAdapterкэшируетwebhooks.set(conversationId, sessionWebhook)(:516-517) и генерируетEnvelopeсisGroup:true,isMentioned:true,chatId = conversationId. - Governor (Слой 4).
ChannelGovernor/BudgetLedger.admit()проверяет бюджет ходов/затрат канала (рекомендательный режим, пока не появятся реальные данные об использовании, §6.4) и аварийный выключатель. Жесткое отключение / явный лимит с реальными числами → отклонить и ответить; превышение порога только по оценке → WARN, никогда не жесткое отклонение (Fix #6). - Шлюзы.
GroupGate.check()проходит (упоминание удовлетворяет значению по умолчаниюrequireMention:true);SenderGate.check()проходит (:246). - Маршрутизация.
router.resolve(...)вычисляетdingtalk:<conversationId>в scope'thread'(требуетсяsessionScope:"thread"), возвращает общийsessionIdгруппы.persist()записывает его. - Память (Слой 3) + идентификатор (Слой 1). На первом ходу память канала +
config.instructionsдобавляются в начало один раз (instructedSessions,:344-347). Внедрение идентификатора добавляет[Alice]в начало каждого сообщения. - Фиксация авторства. Разрешенные
senderId/senderNameзаписываются в элемент очереди, переносимый вsessionQueues(Fix #7), а не объединяются позже по временной метке. - Диспетчеризация. Профиль tag устанавливает
followup(никогдаsteer); параллельное сообщение Боба выстраивается в цепочку черезsessionQueues(:394-470). - Bridge.
bridge.prompt(sessionId, promptText, {imageBase64, imageMimeType})перенаправляет через stdio ACP (AcpBridge.prompt,AcpBridge.ts:147) или в сессию демона (DaemonChannelBridge.prompt) — достигается только тогда, когда предыдущий ход исчерпалactivePrompts, поэтому защита от исключений демона (:257-261) никогда не срабатывает. - Обратный поток.
textChunk→onChunk(:416-422);onResponseComplete → DingtalkAdapter.sendMessage()использует кэшированныйsessionWebhook(“теплая” группа).
Поток данных 2 — запланированная проактивная отправка в «холодную» группу
- Срабатывает планировщик. Размещенный в шлюзе
ChannelCronSchedulerпробуждается в 09:00 дляdaily-standup → dingtalk:<convA>. Это не внутри-сессионный cron (он отключен для tag-сессий, OD-8/§6.2; и все равно умирает, как только сессия утилизируется —dispose()очищаетcronQueue,Session.ts:790-803). - Governor (L4). Проверяет проактивный allowlist и тихие часы (явный источник часового пояса). Вне окна / нет в allowlist → пропуск + лог. Планировщик проверяет
adapter.canColdSendперед попыткой доставки; если false, он генерирует явную ошибку (логирует + записывает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— поэтому daemon-исключениеPrompt already in flight(:257-261) не может сработать (§6.2, Fix #1). - Отправка в «холодную» группу.
pushProactive(convA, text)обнаруживает, чтоwebhooks.get(convA)равен undefined, и переключается на новый проактивный путь: сохраненныйopenConversationId, свежий токен app-credentials, POSThttps://api.dingtalk.com/v1.0/robot/groupMessages/sendсrobotCode = config.clientId,msgKey:'sampleMarkdown',msgParam(JSON строка). (В Feishu шаг 5 — это существующийsendMessage()черезtenant_access_token;canColdSend = true.) - Бюджет + аудит. Проактивный ход потребляет бакет бюджета канала (рекомендательное списание, пока не станет доступно использование на хосте daemon); записывается с
createdByв качестве исходной идентичности иoriginatorClientIdна транспортном уровне (человеческая идентичность не выдумывается,eventBus.ts:60).
Почему такая форма (переиспользование вместо изобретения)
Каждый новый слой подключается к существующей точке расширения: идентичность — в месте сборки promptText, проактивность — в sessionQueues + pushProactive(), память — в механизме instructions/writeContextFile, управление — как обертка над цепочкой gate. Единственное структурное требование — переиспользование механизмов daemon слоями 2–4 — удовлетворяется за счет зафиксированной миграции на daemon (§1): Фаза 0 поставляется на AcpBridge; Фаза 1+ работает под qwen serve.
6. Детальный дизайн
6.1 Многопользовательский режим и Identity (Build Area 1)
«Тег qwen» живет в групповом чате. Каждый участник общается с одним и тем же агентом, который должен (a) поддерживать один общий разговор для всего канала, (b) знать, кто говорит в каждом ходе, (c) не позволять сообщению одного участника уничтожить выполняющуюся задачу другого, и (d) в идеале запрашивать у группы одобрение на рискованные вызовы инструментов. Сегодня в qwen-code есть примитивы для (a)–(c); (d) — это работа для Фазы 1+ на хосте daemon (зафиксированная миграция, §1).
Общая для группы сессия: sessionScope: 'thread'
В режиме 'thread' senderId исключается из ключа маршрутизации, поэтому каждый участник резолвится в один sessionId (SessionRouter.ts:53,72-92) — это делает агента общей, резидентной для канала сущностью, а не N приватными ботами.
- Scope для каждого канала, а не глобальный переключатель. По умолчанию роутер использует
'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как жесткую ошибку (отбрасывает сообщение), а не молча использует webhook в качестве ключа.
Персистентность покрывает восстановление после сбоев (SessionRouter.ts:168-244): перезапуск daemon повторно подключает группу к той же общей сессии через bridge.loadSession().
Новая опасность: /clear и /status с thread-scope действуют на весь канал
Общий обработчик /clear вызывает router.removeSession(this.name, senderId, chatId) (ChannelBase.ts:147-152), а /status вызывает router.hasSession(...) (:203-208); оба маршрутизируются через routingKey(), который игнорирует senderId в режиме 'thread'. Таким образом, /clear от любого одного участника стирает общую сессию для всего канала и сбрасывает instructedSessions — это «грабли», которые сбрасывают всё для всех одним нажатием.
Решение (OD-4): в общей (thread) группе /clear (и его алиасы) требует явного токена confirm и ограничен config.allowedUsers, если этот список задан; в противном случае очистка происходит напрямую (в личных сообщениях и группах для одного пользователя затрагивается только собственная сессия вызывающего, поэтому gate не нужен). Команда сохраняет имя /clear, потому что слэш-парсер принимает только [a-zA-Z0-9_] (команда с дефисом /clear-channel распарсится как clear + аргумент -channel); явный confirm служит индикатором деструктивного действия. Настоящий owner-gate для каждого участника (различающий админов и участников независимо от allowlist чата) ожидает внедрения модели идентичности (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}`;
}- Gate по
envelope.isGroup(types.ts:75), а не по scope. - Префикс перед
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', чтобы сообщение Боба встало в очередь за задачей Алисы (sessionQueues FIFO, :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). Но ни к одному из них нельзя обратиться из канала на пути Phase-0 AcpBridge:
qwen channel startподключаетAcpBridge, чейrequestPermissionавтоматически одобряет каждый запрос (AcpBridge.ts:108-118). Никакого промпта для одобрения.- Медиатор живет в HTTP-слое serve daemon. Единственный способный обрабатывать разрешения channel bridge — это
DaemonChannelBridge(respondToPermission,:346-374) — он становится доступен, когда Фаза 1 мигрирует хостинг канала в daemon (зафиксировано, §1). config.approvalMode— это мертвое поле — оно парсится (config-utils.ts:94) и типизируется (types.ts:36), но не читается ни одним адаптером или bridge.
Принятое фазирование:
- Фаза 0: никаких групповых одобрений. Контролируем риски с помощью sender allowlist +
requireMention+ консервативного набора инструментов агента. Не утверждаем, чтоapprovalModeчто-то делает. - Фаза 1: канал работает на пути daemon-bridge (зафиксированная миграция); выводим
permission_requestв виде карточки DingTalk; поставляемfirst-responderс однимclientIdна уровне канала (нажатие любого разрешенного участника разрешает запрос; атрибуция на уровне гранулярности канала). Не требует картыsenderId → clientId. Автоматически отклоняем инструменты с высоким риском на проактивных ходах (ход, инициированный__cron__, не может ответить на промпт разрешения). - Фаза 2: добавляем
consensus/designatedдля каждого участника, как только появятся маппингsenderId → clientIdи жизненный циклclientId(утилизация, границы refcount). Примечание: один синтетическийclientIdна каждыйsenderIdбесконечно растит карту refcountclientIdsи должен утилизироваться.
Сводка конкретных изменений (Build Area 1)
| Изменение | Где | Тип |
|---|---|---|
Профиль группы устанавливает sessionScope: 'thread' | settings.json + setChannelScope (start.ts:359-363) | Конфиг |
Обработка отсутствующего DingTalk conversationId как ошибки | DingtalkAdapter.ts ~:534 | Код (S) |
Префикс [senderName] для групповых ходов | ChannelBase.handleInbound ~:316 | Код (S) |
Новое опциональное поле Envelope.alreadyPrefixed | types.ts (Envelope) | Код (S) |
Установка alreadyPrefixed при синтетическом повторном входе collect | ChannelBase.ts:449-462 | Код (S) |
/clear confirm + allowlist gate в общих группах; /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 (зафиксировано) | Фаза 1 (L) |
| Голосование за одобрение для каждого участника + карточка DingTalk | новая обвязка bridge + respondToPermission | Фаза 1/2 (L) |
6.2 Проактивный движок: планировщик + исходящий push (ЯДРО)
Решение: планировщик, принадлежащий шлюзу, нейтральный к миграции
Используйте планировщик, работающий в процессе шлюза qwen channel start. Шлюз владеет SessionRouter (с механизмом восстановления restoreSessions() — start.ts:275,444), содержит каждый экземпляр адаптера и его bridge, и это единственное место, где можно вызвать ChannelBase.pushProactive() (и базовый абстрактный sendMessage(), :81). Агент (будь то запущенный дочерний процесс --acp в Фазе 0 или сессия демона в Фазе 1+) остается чистым исполнителем промптов: планировщик срабатывает, ставя задачу в очередь ChannelBase.sessionQueues, которая вызывает bridge.prompt() только после завершения предыдущего хода — никаких новых методов bridge, никаких обратных каналов, никаких маршрутов push от демона.
Примечание по топологии (утвержденная архитектура). Планировщик изначально нейтрален к миграции: он сериализует запросы через
ChannelBase.sessionQueuesнезависимо от того, какой bridge используется под капотом. В Фазе 0 он управляетAcpBridge.prompt()через stdio; в Фазе 1+ —DaemonChannelBridge.prompt()(размещенным в демоне). Поскольку аудитeventBusдемона и FIFOpromptQueueнеобходимы для управления в Фазе 1+, канал запускается подqwen serveначиная с Фазы 1, но собственная логика планировщика на границе миграции не меняется.
Почему не альтернативные варианты:
- Внутри-
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 (таймер-колесо-из-одного); время следующего срабатывания через 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() (расширение, после проверок :240-252) | Сначала детерминированная команда; затем инструмент модели. |
Флаг возможности canColdSend (Исправление #4)
Кроссплатформенный критерий MVP («одна и та же задача доставляется в DingTalk и Feishu») требует флага возможности, чтобы планировщик мог рассуждать о доступности, а не узнавать о ней путем молчаливых сбоев.
- Объявлен как свойство в
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). - Планировщик сообщает об ошибках явно: перед выполнением срабатывания планировщик проверяет
adapter.canColdSend. Еслиfalse, он не пытается выполнитьpushProactive; он логирует видимую для оператора ошибку, устанавливаетjob.lastStatus='error'+lastError='adapter cannot cold-send', выводит ее в/schedule listи (согласно политике) инкрементируетconsecutiveFailures. Он никогда не завершается без действий.
Раздельные хранилища cron + блокировка OD-8 (Исправление #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).
Поскольку пути не пересекаются, единственный способ, при котором долговременная задача может сработать дважды, — это если тег-сессия также запустит свой внутри-сессийный Session cron в дополнение к планировщику шлюза. OD-8 закрывает эту лазейку: планировщик шлюза является единственным владельцем cron; сессия, размещенная в канале («тег-сессия»), не запускает свой внутри-сессийный cron.
Механизм блокировки — как сессия узнает, что она является тег-сессией. Тег-сессия создается с явным флагом, передаваемым от хоста канала:
- На пути демона Фазы 1+
DaemonChannelSessionFactoryуже получает структурированный объект опций ({ workspaceCwd, modelServiceId, sessionScope },DaemonChannelBridge.ts:226-241). ДобавьтеisTagSession: trueв этот объект; демонSessionсчитывает его при создании и пропускаетstartCronScheduler()(место вызова, которое в противном случае активировало быcronQueue,Session.ts:667-668). Очистка (disposal) уже удаляет cron при сборке (:790-803), поэтому тег-сессия просто никогда его не активирует. - На пути
AcpBridgeФазы 0 дочерний агент также не должен активировать внутри-сессийный cron для тег-рабочего пространства; передайте тот же флаг через опцию запуска--acp(новое полеAcpBridgeOptions, пробрасываемое как флаг вConfig). Пока эта передача флага не реализована, Фаза 0 просто не регистрирует никаких внутри-сессийных задач cron (команда/scheduleобращается к хранилищу шлюза), поэтому нечему срабатывать дважды.
Это сводит оставшийся риск к чисто операционному: «не запускать оба планировщика для одних и тех же задач» — и блокировка гарантирует, что тег-сессия никогда не запустит второй планировщик.
Схема долговременного хранилища и восстановление при перезапуске
Схема аналогична DurableCronTask (cronTasksFile.ts:19-26: id/cron/prompt/recurring/createdAt/lastFiredAt — поле называется cron, а не cronExpr):
interface ChannelCronJob {
id: string; // randomUUID()
channelName: string;
target: {
// повторяет SessionRouter PersistedEntry (SessionRouter.ts:5-9)
channelName: string;
senderId: string; // "__cron__" для системных задач
chatId: string; // DingTalk openConversationId — ДОЛГОВРЕМЕННЫЙ id холодной группы
threadId?: string;
};
cwd: string; // проверяется == привязанное рабочее пространство при загрузке
cron: string; // 5 полей (parseCron) ИЛИ "@once:<epochMs>"
prompt: string;
label?: string;
recurring: boolean;
enabled: boolean;
createdBy: string; // senderId; рекомендательный при модели с одним токеном; переносится в атрибуты срабатывания
createdAt: number;
lastFiredAt: number | null;
lastStatus?: 'ok' | 'error' | 'skipped';
lastError?: string;
consecutiveFailures: number; // автоотключение после N (например, 5)
}Запись через atomicWriteJSON под async-mutex 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(), чтобы компенсировать рассинхронизацию часов при приостановке/возобновлении — никогда не накапливайте интервалы.
Путь срабатывания: внедрение в ОБЩУЮ сессию группы (Исправление #1 — самое важное)
Инвариант «один активный промпт на сессию» различается в зависимости от топологии, и dispatchProactive в v1 ошибся для пути демона:
- Фаза 0 (
AcpBridge):AcpBridge.prompt()(:147-180) не имеет собственного механизма защиты от параллелизма; единственная сериализация — этоChannelBase.sessionQueues/activePrompts(:29-35,394,466) и собственная ACP-сессия дочернего процесса--acp. - Фаза 1+ (
DaemonChannelBridge):DaemonChannelBridge.prompt()выбрасываетPrompt already in flight, еслиactivePrompts.has(sessionId)(:257-261) — он не ставит в очередь. FIFOpromptQueue(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) структурно недостижимо для трафика тегов.
При срабатывании:
- Разрешение общей сессии через
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(ОБЯЗАТЕЛЬНЫЙ, строка): открытый ID беседы целевой группы с префиксом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 из документации по типам сообщений, пример text дословно из aliyun ask/585232)coolAppCode(НЕОБЯЗАТЕЛЬНЫЙ): только если робот установлен как групповое cool app (群聊酷应用); не требуется для обычного робота внутреннего корпоративного приложения. (средняя уверенность)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) { /* обновить один раз; иначе установить 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. Флаг 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 v1.0 api.dingtalk.com используйте токен v1.0 accessToken в заголовке 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 ~500 тыс., Dedicated ~5 млн) (средне-высокая). (2) Часто цитируемый лимит 20 сообщений/минуту → ~10-минутный троттлинг на робота задокументирован для кастомных роботов групповых вебхуков; он обычно применяется как практическое руководство для пути отправки робота orgapp, но не был явно подтвержден на странице groupMessages/send в этой сессии — относитесь к точной цифре 20/мин для groupMessages/send с низкой/средней уверенностью. Также: не вызывайте эндпоинт токенов слишком часто (отдельный троттлинг). Планировщик должен консервативно ограничивать частоту собственных отправок и отступать при ответах с троттлингом.
Постоянные инструкции (повторяющиеся запросы на естественном языке → хранилище → потребление)
Двухуровневый перехват в 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) перезапускает (Фаза 0) / демон переподключается (Фаза 1+). Планировщик устанавливаетlastError, не обновляетlastFiredAtдля периодических задач → повторная попытка. Гарантия “хотя бы один раз”; ключ срабатывания, округленный до минуты, + дедупликация поlastFiredAt. - Сессия очищена /
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 является глобальной для рабочего пространства: нет оси чат/канал/группа/сессия.
Факты о топологии / зависимостях (Fix #3). Архитектура определяется двумя жесткими ограничениями: (1) В топологии
AcpBridgeпо умолчанию нет демонаqwen serveи нет маршрутаPOST /workspace/memory— у дочернего процесса--acpнет HTTP-клиента; даже после миграции на демон в Фазе 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 }. Следовательно, в исправленной архитектуре память канала записывается/читается внутри процесса через вспомогательную функцию ядра, доступную изchannel-baseчерез callback-и, внедряемые слоем CLI (packages/cli, который может зависеть от ядра) — не по HTTP и без добавления зависимости от ядра в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)
Единицей изоляции является цель маршрутизации, а не сессия (сессии очищаются при простое, 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>/ # sanitize: reject /, .., NUL
<hash(chatId)>/ # sha256(chatId).slice(0,16) — path-safe, no collision/escape
QWEN.md # group-scoped "learning over time"
meta.json # { channelName, chatId, displayName?, createdAt, lastWriteAt }Имя файла соответствует getCurrentGeminiMdFilename() (const.ts:49). Это удерживает память канала вне рабочего дерева, вне привязанного рабочего пространства и вне пути иерархического обнаружения QWEN.md (чтобы она никогда не просачивалась между группами).
Путь записи (расширяем вспомогательную функцию ядра, не создаем форк)
В packages/core/src/memory/writeContextFile.ts:
- Расширяем
WriteContextFileScope(:80) с'workspace' | 'global', добавляя'channel'. - Расширяем
WriteContextFileOptions(:83-97), добавляяchannelKey?: { channelName: string; chatId: string }; проверяем наличие, когдаscope === 'channel'(аналогично защите абсолютного пути:142-146).projectRootостается обязательным в интерфейсе — передаемconfig.cwd, даже если он не используется для области канала. - В
resolveContextFilePath()(:223-240) добавляем веткуchannel, возвращающуюpath.join(Storage.getGlobalQwenDir(), 'channels', 'memory', sanitize(channelName), hash(chatId), getCurrentGeminiMdFilename()). Текущая сигнатура функции —(scope, projectRoot)— в нее нужно добавить параметрchannelKey(приватная функция, локальное изменение). Файловый мьютекс использует разрешенный путь в качестве ключа, поэтому две группы могут писать параллельно без конфликтов.
Точное изменение ChannelBaseOptions + кто их внедряет (Fix #3). channel-base не может импортировать ядро, поэтому слой CLI предоставляет чтение/запись в виде callback-ов. Расширяем объект опций (ChannelBase.ts:9-12 — реальный интерфейс сегодня это просто { router?: SessionRouter; proxy?: string }; config и bridge являются позиционными аргументами конструктора в :40-46, а не членами объекта). Объект уже содержит router:
// packages/channels/base/src/ChannelBase.ts — ChannelBaseOptions (NO new core dependency)
export interface ChannelBaseOptions {
// ...existing members today: router?: SessionRouter; proxy?: string
/** Read this channel's distilled memory; null if none yet. Injected by the CLI layer. */
readChannelMemory?: (target: SessionTarget) => Promise<string | null>;
/** Append/replace this channel's memory. Injected by the CLI layer. */
writeChannelMemory?: (
target: SessionTarget,
content: string,
mode: 'append' | 'replace',
) => Promise<void>;
}Кто их создает и внедряет: packages/cli/src/commands/channel/start.ts (который зависит от ядра). Когда start.ts формирует объект опций для каждого адаптера, он замыкается на writeWorkspaceContextFile ядра / вспомогательной функции чтения и разрешает доверенный сервером (channelName, chatId) из router.getTarget(sessionId) (SessionRouter.ts:94) — адаптер никогда не передает chatId из сети:
// packages/cli/src/commands/channel/start.ts — CLI layer (CAN depend on core)
import {
writeWorkspaceContextFile,
readChannelContextFile,
} from '@qwen-code/qwen-code-core';
const baseOpts: ChannelBaseOptions = {
router, // config & bridge are positional args of createChannel(name, config, bridge, baseOpts) — not bag members
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 unused for channel scope but required by the interface
}),
};
// adapter is created positionally with the bag last: plugin.createChannel(name, config, bridge, baseOpts)Адаптер никогда не обращается к файловой системе, а channel-base не получает новых зависимостей. (Альтернатива для демона в Фазе 2: ограниченный маршрут POST /channel/:sessionId/memory, который разрешает channelKey на стороне сервера; он не может переиспользовать POST /workspace/memory, который жестко валидирует scope ∈ {workspace, global} и передает фиксированный projectRoot, :118-125,185-190. Отложить до тех пор, пока проактивному движку действительно не понадобится поиск sessionId → target на стороне демона.)
Трансляция событий. publishWorkspaceEvent находится на стороне демона в AcpSessionBridge (bridge.ts:3610), а не на стороне канала. В AcpBridge (Фаза 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). Зависимость от ядра не добавляется — чтение проходит через внедренный callback. Память канала никогда не находится на пути иерархического обнаружения; она внедряется для каждой сессии через этот хук.
// ChannelBase.handleInbound() — first-turn bootstrap (reuses instructedSessions)
if (!this.instructedSessions.has(sessionId)) {
const parts: string[] = [];
if (this.options.readChannelMemory) {
const mem = await this.options.readChannelMemory(target); // target from 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 как видимое пользователю “память группы переполнена, запустите проход уплотнения”); маршрут Фазы 2 переиспользует лимит 1 МБ на запись (MAX_MEMORY_CONTENT_BYTES, workspace-memory.ts:79); уплотнение в режиме замены (writeContextFile.ts:202-211) — это долгосрочное решение проблемы неограниченного роста.
- Фаза 0/1: добавляем область
channel+channelKeyвwriteContextFile.ts; выпускаем~/.qwen/channels/memory/+meta.json; подключаем callback-иreadChannelMemory/writeChannelMemoryслоя CLI черезChannelBaseOptionsи инициализирующее чтение, описанное выше. Никаких новых HTTP-маршрутов, никаких зависимостейchannel-base → core. - Фаза 2: добавляем ограниченный маршрут
POST /channel/:sessionId/memory(топология с демоном) иmemory_changedс фильтрацией на стороне подписчика; добавляем триггер дистилляции и CLI-командуqwen channel memory <name> <chatId>. Ограничение дистилляции: cron привязан к сессии и завершается приdispose()(Session.ts:791,799-803,1056); дистилляция должна срабатывать, пока сессия активна — по завершении хода, по явной команде/rememberили в сессии в режиме keep-warm — никогда из независимого фонового планировщика.
6.4 Governance: бюджеты токенов и журнал аудита (Build Area 4)
Агенту, работающему в канале, которым может управлять любой участник и который может действовать проактивно, необходимы лимиты расходов, журнал аудита, фиксирующий, кто и что запросил, а также изоляция на уровне идентичности. В qwen-code реализованы три из четырех примитивов: rate-limit.ts (токен-бакеты для каждого ключа), кольцевой буфер permission-audit.ts и MultiClientPermissionMediator. Данный раздел объединяет их и заполняет пробелы (отсутствует бюджет затрат; ни одна строка аудита не содержит информацию о реальном пользователе). Основной принцип: отклонять, а не обрезать — однако, согласно Fix #6, оценочный бюджет никогда не жестко отклоняет промпт пользователя; он лишь выдает WARNING.
Какой процесс отвечает за governance?
| Deployment | Bridge | Какой функционал 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, кольцевой буфер аудита, маршруты. |
Решение: проверка бюджета (admission) и отклонение реализованы в @qwen-code/channel-base (общая точка входа ChannelBase.handleInbound()), в новом модуле packages/channels/base/src/BudgetLedger.ts, а не в serve/budget.ts, поскольку процесс канала Phase-0 никогда не загружает serve/, и уровень канала — это единственное место, где доступен контекст реального отправителя. Аудит и атрибуция также берут начало на уровне канала. В пути демона Phase-1+ ledger считывает реальное использование и дополнительно предоставляется через маршрут; в пути Phase-0 он выполняет оценку и предоставляется через команду канала (/audit).
Где сегодня подключается governance (и существующие пробелы)
| Задача | Существующий механизм | Пробел |
|---|---|---|
| Ограничение частоты запросов | токен-бакеты для каждого (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)), поэтому governance для каждого канала должно начинаться в адаптере.
Бюджеты токенов и затрат — новый BudgetLedger, рекомендательный режим до появления реального использования (Fix #6)
Откуда берется использование — оговорка (OD-9). Бюджет токенов может списывать реальные значения только после того, как модель сообщит об использовании. В рамках сессии Session.#recordPromptTokenCount() (Session.ts:2078-2087) сохраняет usageMetadata.promptTokenCount в lastPromptTokenCount, перезаписывая его каждый ход, — это не накопительный счетчик для биллинга. В пути Phase-0 AcpBridge поток ACP session/update не несет usageMetadata, поэтому v1 не может списывать реальные количества токенов на этом пути. В пути демона Phase-1+ демон наблюдает использование внутри процесса и может точно его списывать.
Правило применения (Fix #6 — критически важное):
- Оценочные бюджеты носят ТОЛЬКО РЕКОМЕНДАТЕЛЬНЫЙ характер. Когда единственное доступное значение — это оценка на стороне канала (количество символов промпта+ответа ÷ константа символов на токен), ledger выдает WARNING/алерт при достижении пороговых значений и может прикреплять предупреждение к ответу — он никогда жестко не отклоняет промпт пользователя. Ложноположительная оценка не должна блокировать реальный запрос пользователя.
- ЖЕСТКОЕ отклонение только по реальным числам. Бюджет может отклонить промпт (отклонить, а не обрезать) только в том случае, если источник списания — это реальный путь использования демона (размещенный в демоне Phase-1+). До этого момента бюджет — это наблюдаемость + алертинг, а не шлюз.
Это делает бюджет v1 честным: он заранее предупреждает везде и применяет жесткие лимиты именно там, где значения достоверны.
Модуль BudgetLedger.ts, смоделированный по образу rate-limit.ts (фабрика, Map бакетов с GC, overflow fail-open):
export type BudgetUnit = 'tokens' | 'usd'; // 'usd' = tokens × per-model rate
export type UsageSource = 'estimate' | 'daemon'; // 'estimate' => advisory; 'daemon' => may hard-decline
export interface BudgetLedger {
// allowed=false only when source==='daemon'; estimates return allowed=true + warn flags
admit(key: string): {
allowed: boolean;
spent: number;
limit: number;
advisory: boolean;
};
debit(
key: string,
amount: number,
unit: BudgetUnit,
source: UsageSource,
): void; // fires threshold alerts
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один раз для каждого порога в каждом окне, используя идиому гистерезиса event-bus (WARN_THRESHOLD_RATIO/WARN_RESET_RATIO,eventBus.ts:101-103). Отправка алерта — это проактивная отправка — жесткая зависимость от Build Area 2 (оговорка по холодной группе DingTalk; Feishu отправляет свободно). Деградация до “прикрепить предупреждение к следующему ответу”, если проактивный канал отсутствует. - Отклонить, а не обрезать (только когда
source==='daemon'). Проверяется при допуске (admission), до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.
Журнал аудита — человеческий senderId, передаваемый вместе с ходом (Fix #7)
PermissionAuditRing (permission-audit.ts:128-172, FIFO 512) — это правильная основа, но каждая строка привязана к clientId. Дизайн — привязка отправитель↦ход на стороне канала (RequestAttributionRing.ts, та же FIFO-структура).
Наивное соединение по временной метке неверно при followup (Fix #7). В v1 предлагалось соединять строку разрешения с “самой последней строкой атрибуции для этого sessionId, чья recordedAtMs предшествует issuedAtMs разрешения”. При followup несколько отправителей встают в очередь для одного sessionId через sessionQueues; отправитель, поставленный в очередь последним, часто не является тем, чей ход выполняется в момент срабатывания вызова инструмента/разрешения. Следовательно, соединение по временной метке систематически присваивает атрибуты неверно.
Исправление: передавать senderId ВМЕСТЕ с промптом в очереди. Когда handleInbound() ставит в очередь sessionQueues (и когда планировщик ставит в очередь проактивный запуск), элемент очереди / синтетический контекст хода несет свой собственный { senderId, senderName, requestSeq }. Атрибуция для любого вызова инструмента/разрешения, возникшего во время хода, считывается из текущего выполняемого хода (головы FIFO), а не из сканирования временных меток. Конкретно: цепочка sessionQueues устанавливает для каждого хода currentTurnAttribution.set(sessionId, {senderId, ...}) в момент, когда выполнение достигает головы (непосредственно перед bridge.prompt()), и очищает его, когда выполнение завершается; строки аудита читают эту карту. Проактивные запуски устанавливают createdBy таким же образом (§6.2 шаг 3). Это точно для выполняемого хода и не зависит от порядка постановки в очередь.
Добавьте шестой тип строки task.requested { sessionId, senderId, channelName, chatId, promptDigest, requestedAtMs } при допуске (admission), чтобы аудит отвечал на вопрос “кто начал эту задачу” даже для работы только на чтение. Объединение PermissionAuditEntry (:57-104) является закрытым, и потребители используют switch по kind, поэтому его расширение (или добавление соседнего кольцевого буфера) затрагивает каждого потребителя.
Путь запроса. Демон Phase-1+: добавить GET /workspace/audit (bearer + строгий createMutationGate, auth.ts:356), предоставляя кольцевой буфер из замыкания bridge (документация в заголовке файла предвосхищает это, :22-25). Phase-0 AcpBridge: команда канала /audit через sendMessage. Долговечность: кольцевой буфер содержит 512 записей в памяти, теряется при перезапуске — известное ограничение v1; последующее обновление (OD-11) сохраняет аудит с добавлением только в конец (append-only) в ~/.qwen.
Голосующие в консенсусе — не люди. votersAtIssue — это clientId, установленные демоном, а один канал = один clientId, поэтому “консенсус” из коробки в группе DingTalk — это консенсус между клиентами демона. Голосование на уровне людей требует реестра зарегистрированных утверждающих, сопоставляющего 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. В пути демона Phase-1+ на каждый демон приходится одинConfig, поэтому область действия привязана к демону (к рабочему пространству, OD-2), а не к дочернему процессу канала. - Область действия MCP для каждого канала.
Config.getMcpServers()фильтрует поallowedMcpServers(:3327-3333), заданным при создании. ДобавьтеallowMcpServers?: string[]вChannelConfig, передав их по тому же пути аргументов spawn (или в массивmcpServers, который передаетAcpBridge.newSession()— жестко задан[]в:133). sessionScopeкак граница данных.'thread'заставляет группу использовать одно рабочее дерево/контекст; межканальная (channel) изоляция обеспечивается ключами маршрутизации с пространством именchannelName. Изоляция для каждого отправителя внутри группы'thread'не предусмотрена по дизайну. Честное ограничение: аутентификация использует единый глобальный для демона токен без привязки к конкретному пользователю (principal), поэтому изоляция работает на уровне канала, а не отдельного человека. Для настоящей изоляции инструментов на уровне пользователя требуется Phase-3.
Путь допуска
DingTalk inbound
→ 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 always true → WARN only (Fix #6)
3. enqueue onto sessionQueues WITH {senderId, senderName, requestSeq} [НОВОЕ — Fix #7]
+ task.requested row
4. at FIFO head, stamp currentTurnAttribution → bridge.prompt(...) [существующий :425]
↳ tool call → permission (автоматически одобряется в AcpBridge Phase 0; медиатор в daemon Phase 1+)
↳ audit row reads currentTurnAttribution[sessionId] (исполняемый ход)
5. on completion: usage known (daemon) or estimated (AcpBridge) → budget.debit(..., source) [НОВОЕ]
↳ 75%/95% alert post is proactive → depends on Build Area 2Важные зависимости, которые нужно учесть: (1) реальное списание токенов (и, следовательно, жесткий отказ) требует пути использования демона в Phase-1+ — до этого бюджеты носят рекомендательный характер (Fix #6); (2) проактивные предупреждения о бюджете требуют Build Area 2; (3) консенсусное голосование на уровне человека и атрибуция аудита на уровне человека требуют реестра зарегистрированных утверждающих 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, а не мостом. 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) события добавления бота в группу через registerAllEventListener (client.mjs:58-61): события идут по пути onEvent → onEventReceived при дефолтном topic:'*' (client.mjs:14-19,241-254), при этом адаптер устанавливает только callback робота (:107), поэтому события организации/бота в настоящее время принимаются и отбрасываются в дефолтный no-op (client.mjs:35-37). Топик события и поле openConversationId на момент установки не проверены — не хардкодьте имя события.
Сохранение. Используйте отдельное хранилище ~/.qwen/channels/dingtalk-groups.json, а не target 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 уже выполняет нормализацию платформы, которую повторно использует проактивный путь: пропускание markdown-таблиц, разбиение на части по 3800 символов с балансировкой фенсов (splitChunks(); CHUNK_LIMIT=3800) и извлечение заголовка, обрезанное до 20 символов, с фоллбэком 'Reply' (extractTitle()). Повторное использование условно тем, что шаблон 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 добавляет память каналов, бюджеты и аудит.
Топология: утвержденная миграция демона (ранее OD-1)
Решение принято, а не ожидается: Phase 0 поставляется на AcpBridge; Phase 1+ запускает каналы под qwen serve (через DaemonChannelBridge или runner каналов демона), потому что сохранение памяти для каждой комнаты, медиатор разрешений, аудит шины событий, FIFO promptQueue и маршруты запросов бюджета/аудита — все это требует демона. Планировщик, принадлежащий шлюзу (§6.2), нейтрален к миграции — он сериализует через ChannelBase.sessionQueues независимо от моста, поэтому он поставляется в Phase 1 и не затрагивается переключением. Интеграция Phase 0 добавляет путь подключения DaemonChannelBridge (или флаг --daemon <url>), чтобы миграция стала шагом конфигурации на границе Phase-1, а не переписыванием. Обратите внимание на острую грань, вокруг которой спроектирован планировщик: DaemonChannelBridge.prompt() не ставит в очередь — он выбрасывает Prompt already in flight при пересечении (:257-261); демонный FIFO promptQueue находится на стороне acp-bridge (bridge.ts:2855,3082); сериализация на стороне канала — это ChannelBase.sessionQueues (:394), поэтому проактивный движок никогда не вызывает prompt(), пока ход активен (§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 "*" НЕ является wildcard для членства (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, срабатывал каждый ход (не ограничивался instructedSessions), при этом новый флаг Envelope.alreadyPrefixed защищает от повторного входа в collect. См. §6.1.
0.3 — Согласование dispatchMode. Явно задавайте dispatchMode для каждой группы; исправьте устаревший JSDoc в types.ts:42 ('collect' → 'steer'), чтобы код и комментарий совпадали (OD-5).
Измененные файлы (Фаза 0). start.ts (добавлен опциональный путь подключения DaemonChannelBridge, чтобы коммит-миграция Фазы 1 была на расстоянии одного флага); ChannelBase.ts (сид senderName + защита alreadyPrefixed + гейт подтверждения и allowlist для /clear + /who); types.ts (новое поле Envelope.alreadyPrefixed + исправление JSDoc); docs/ (рецепт + подводные камни).
Критерии приемки.
- Два участника
@-упоминают бота; оба разрешаются в один и тот жеsessionId(проверяется через мапыSessionRouter); ключ маршрутизации —team-eng:<conversationId>, а не URL вебхука. - Агент использует атрибуцию отправителя (
[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.
Фаза 1 — Миграция демона + Проактивный движок + Замкнутый цикл MVP
Определение MVP. Один замкнутый цикл с запланированным дайджестом: оператор регистрирует cron-подобную задачу для канала; при срабатывании шлюз разрешает сессию канала с областью действия thread, запускает промпт с инструментами и отправляет результат обратно в холодный канал без запроса. Одна задача, один канал, один путь доставки. Более сложное поведение выходит за рамки MVP.
Закоммиченная миграция. Фаза 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() для tag-сессий, Session.ts:667-668).
Критерии приемки.
- Каналы работают под
qwen serve(хостятся демоном); вызов инструмента выдаетpermission_request(медиатор доступен), что подтверждает миграцию. - Оператор регистрирует одну задачу дайджеста; она сохраняется при перезапуске шлюза (перезагружается из
~/.qwen/channels/cron.json). - Когда задача срабатывает, если нет открытой сессии, шлюз разрешает сессию с областью действия thread, запускает промпт с инструментами и доставляет результат в неактивную группу DingTalk через путь холодной отправки — доказывая возможность доставки в холодную группу. Движок громко падает (логирует, записывает
lastError, не делает silent no-op) приcanColdSend = false. - Та же задача доставляет результат в Feishu через
tenant_access_token, доказывая абстракциюcanColdSend. - Срабатывающая задача не нарушает правило одного промпта на сессию: если участник находится в процессе диалога, проактивный промпт ставится в очередь за ним через
sessionQueues(awaitactivePrompts.get(sessionId)?.done), никогда не отменяется черезsteerи никогда не вызывает ошибку перекрытияDaemonChannelBridge. - Проактивный ход не может быть отменен последующим человеческим ходом (tag-группы используют
followup, никогдаsteer). -
tokenManagerобновляетaccessTokenv1.0 до истечения срока действия (~2 часа) и при ошибке 401, поэтому отправка после того, как сокет открыт > 2 часов, все равно проходит успешно. - Никаких двойных срабатываний для любой долговременной задачи: планировщик шлюза является единственным владельцем; tag-сессия не активирует свой внутри сессионный 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; подключить коллбэки readChannelMemory/writeChannelMemory CLI-слоя через 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; атрибуция переносится с выполняемым ходом (per-turn currentTurnAttribution), а не через джойн по таймстемпу (Fix #7); команда GET /workspace/audit (демон) или /audit канала. 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 в ходах в очереди + 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).
- Вызов инструмента/запрос разрешения, возникший во время выполнения хода отправителя A из очереди, атрибутируется A, даже если B поставил свой ход в очередь позже в режиме
followup(Fix #7). - Каждое проактивное срабатывание, запись в память канала и событие бюджета попадают в кольцо аудита с best-effort
senderId/senderName, читаемым через поверхность аудита, и не транслируются в SSE-шину. - Юнит-тесты кольца/маршрута/резолвера (вытеснение FIFO, разрешение путей областей, математика порогов бюджета, атрибуция выполняемого хода) — без UI/тайминговых тестов.
Границы фаз и взгляд в будущее
Фазы 0→1→2 аддитивны: многопользовательская работа + идентификация (на AcpBridge) → миграция демона + проактивный MVP → память + бюджеты + аудит. Шлюз с множественной идентификацией Фазы 3 (различные идентичности/учетные данные бота для каждого канала, настоящие принципы per-user, токены per-channel) выходит за рамки, это естественный следующий шаг, который снимает ограничения единого глобального токена / одного рабочего пространства на демон. Даже в рамках Фаз 0–2 “qwen tag” требует один процесс агента на рабочее пространство (OD-2); развертывание, обслуживающее несколько репозиториев, запускает несколько процессов.
8. qwen tag против Claude Tag (компромиссы)
Claude Tag — это хостинговый мульти-тенантный агент: Anthropic управляет runtime, идентификацией и учетом использования per-user; приложение канала является тонким клиентом. qwen tag — это обратное: он работает на инфраструктуре, контролируемой оператором, поверх адаптеров qwen-code. Эта инверсия — всё ценностное предложение и вся поверхность рисков.
Где qwen выигрывает
- Открытый / self-hosted, данные остаются внутри. Агент работает локально — через stdio в Фазе 0 (
AcpBridge.start()запускаетnode <cli> --acp), в процессе под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-минутного idle reaping. Компенсация: планировщик, принадлежащий шлюзу (§6.2) — долгоживущий, переживает reaping, единственный владелец cron (OD-8). - Память глобальна для рабочего пространства, а не для каждого канала. Компенсация: один процесс на канал (ноль кода) или область
channelФазы 2 (OD-10). - Множественная идентификация / настоящий мульти-тенантинг выходят за рамки (Фаза 3). Моделируется как мульти-процессность в Фазах 0–2.
Риски и меры по их снижению
| # | Риск | Критичность | Меры по снижению |
|---|---|---|---|
| R1 | Вызовы инструментов в стеке каналов автоматически одобряются на пути Phase-0 AcpBridge (AcpBridge.ts:108-118) — скомпрометированный канал может запускать любой инструмент без проверок. | Высокая | Запланированный на Phase-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] в Phase-0 для ходов в группе (+ alreadyPrefixed, OD-6). |
| R5 | Проактивные действия в “холодной” группе DingTalk или при истекшем вебхуке завершаются без вывода ошибок (:137-141). | Средняя | Проверенная проактивная отправка в группу в Phase-1 на основе сохраненного openConversationId; canColdSend с явным выводом ошибок; отображение деградаций. |
| R6 | Cron/уведомления перестают работать при очистке сессии (30 мин, run-qwen-serve.ts:94); также требуется исходящий путь (R5). | Средняя | Планировщик, принадлежащий шлюзу (§6.2); OD-8 — шлюз единоличного владельца. |
| R7 | requireMention = true → сообщения в группе без упоминания молча отбрасываются (GroupGate.ts:51-52). | Низкая/Средняя | Оставить по умолчанию; задокументировать; опциональная подсказка в первом сообщении. |
| R8 | Общая память рабочего пространства приводит к перекрестному загрязнению данных совместно размещенных групп. | Средняя | Один процесс на канал или область channel в Phase-2 (OD-10). |
| R9 | Ограничение частоты запросов действует на clientId/IP, а не на пользователя (путь демона); путь AcpBridge не имеет ограничений. | Низкая | Приемлемо для single-tenant; учет на пользователя — в Phase 3. |
| R10 | Набор голосующих для консенсуса снимается в момент запроса; сегодня участники канала не являются отдельными clientId. | Низкая | OD-3: first-responder в Phase 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() — защита от выброса структурно недостижима (Fix #1, §6.2). |
| R13 | Ложноположительное срабатывание оценки бюджета может отклонить легитимный запрос пользователя. | Средняя | Оценки только WARN; жесткое отклонение только при реальном использовании демона (Fix #6, §6.4). |
| R14 | Очередь followup ошибочно приписывает вызовы инструментов последнему добавленному в очередь отправителю. | Средняя | Передавать senderId в поставленном в очередь ходе; аудит читает выполняемый ход (Fix #7, §6.4). |
9. Принятые решения
Все открытые решения v1 ниже разрешены с указанием выбранного ответа. Единственными по-настоящему открытыми вопросами остаются детали API DingTalk с низкой степенью уверенности в рамках OD-7, указанные в последней строке.
| ID | Вопрос | Решение |
|---|---|---|
| OD-1 | Перенести хостинг каналов в qwen serve для Phase 1+ или остаться на AcpBridge? | ПРИНЯТО — Мигрировать. Phase 0 поставляется на AcpBridge; Phase 1+ хостит каналы в qwen serve через DaemonChannelBridge / runner каналов демона, наследуя FIFO promptQueue, MultiClientPermissionMediator, eventBus, /workspace/memory и rate-limit. Phase 0 добавляет путь подключения (или --daemon <url>), чтобы переключение было шагом конфигурации. Планировщик шлюза (§6.2) не зависит от миграции. Больше не является блок-фактором — архитектура утверждена. |
| OD-2 | Единица развертывания = один процесс на рабочее пространство/канал? | ПРИНЯТО — Да. Один процесс на рабочее пространство/канал: память и изоляция секретов для каждого канала, ограничение радиуса поражения при утечке единого глобального токена. Совместное размещение нескольких каналов — задача для Phase-3 (требуется область channel + governor). |
| OD-3 | Политика разрешений для многопользовательского тега (один канал = один clientId демона)? | ПРИНЯТО — Phase 1: first-responder с единым clientId на уровне канала (любой разрешенный участник отвечает; атрибуция с гранулярностью канала; нет маппинга senderId→clientId). Phase 2: consensus/designated после появления реестра senderId→clientId + жизненного цикла (очистка, границы refcount). Автоматический запрет инструментов высокого риска при проактивных ходах. |
| OD-4 | /clear//status с областью действия в треде работают на весь канал. | ПРИНЯТО — в общей группе (треде) /clear требует confirm и ограничен config.allowedUsers, если задано (дефисный /clear-channel не парсится; шлюз владельца для каждого участника отложен до модели идентификации, 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}> } (высокая); заголовок авторизации x-acs-dingtalk-access-token с токеном v1.0 oauth2/accessToken, TTL ~7200 с, кэшируется и обновляется tokenManager, принадлежащим функции (высокая); сохранять openConversationId в ~/.qwen/channels/dingtalk-groups.json; callback conversationId≈openConversationId (средняя; откат к API конвертации chatId→openConversationId при invalid.openConversationId). Оставшиеся открытыми (низкая уверенность): точный код точки разрешения/отображаемое имя; дословное официальное предложение об эквивалентности; применяется ли троттлинг 20/мин к groupMessages/send. |
| OD-8 | Двойное срабатывание Cron между планировщиками шлюза и сессии. | ПРИНЯТО — Планировщик шлюза является ЕДИНСТВЕННЫМ владельцем Cron. Сессия, размещенная в канале (с тегами), не запускает свой внутри-сессионный Session cron; она узнает, что является сессией с тегами, через флаг isTagSession, передаваемый от хоста канала при создании сессии (набор опций DaemonChannelSessionFactory для Phase 1+; опция запуска --acp для Phase 0), что пропускает startCronScheduler() (Session.ts:667-668). Два хранилища cron находятся на непересекающихся путях (шлюз ~/.qwen/channels/cron.json против сессии ~/.qwen/tmp/<hash>/scheduled_tasks.json), поэтому единственный риск коллизии — запуск обоих планировщиков для одних и тех же задач — устранен шлюзом. |
| OD-9 | Область действия бюджета токенов, источник истины, окно. | ПРИНЯТО — Сводка “org” для каждого процесса + окна для каждого канала, побеждает самое строгое, фиксированное дневное окно. v1 оценивает токены на стороне канала (рекомендательно, только WARN — никогда не делает жестких отказов, Fix #6) и читает путь использования демона для точного списания (и жесткого отказа) после размещения на демоне. |
| OD-10 | Пространство имен памяти для каждой комнаты + права на запись. | ПРИНЯТО — Добавить область channel (+channelKey) в writeContextFile.ts; channel-base получает запись/чтение через callback CLI-слоя, внедряемый через ChannelBaseOptions (readChannelMemory/writeChannelMemory) — НЕТ зависимости channel-base → core. Глобальное расположение для пользователя ~/.qwen/channels/memory/. Агент добавляет данные через интент save_memory; начальное чтение повторно использует шлюз instructedSessions. |
| OD-11 | Модель идентификации человека + долговечность аудита. | ПРИНЯТО — senderName носит только рекомендательный характер; clientId остается единственным субъектом безопасности. Атрибуция по мере возможностей передается с выполняемым ходом (Fix #7); кольцо аудита FIFO 512 в памяти + файл последующих действий ~/.qwen только для добавления. |
| OD-12 | Усиление защиты токена для развертываний с демоном не на loopback. | ПРИНЯТО — Требовать --require-auth + токен для любого развертывания с демоном не на loopback. Только loopback — только для разработки; --require-auth — задокументированная позиция по умолчанию (run-qwen-serve.ts уже требует токен для не-loopback). |
| ОТКРЫТО (единственный оставшийся) | Детали API DingTalk с низкой степенью уверенности в рамках OD-7. | ВСЕ ЕЩЕ ОТКРЫТО — проверить в консоли / по актуальной документации перед написанием кода: (1) точный код точки разрешения/отображаемое имя для “проактивной отправки сообщения в группу” (низкая); (2) авторитетное официальное предложение, приравнивающее callback conversationId к openConversationId для стандартного робота не cool-app (средняя; гарантированный документацией путь — это API конвертации chatId→openConversationId); (3) применяется ли ограничение “20 сообщений/минуту → ~10-минутный троттлинг” дословно к groupMessages/send (низкая/средняя — задокументировано для роботов с кастомным вебхуком, не подтверждено на странице отправки 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. Приложение: Индекс файлов и символов
Channel base (packages/channels/base/src/)
SessionRouter.ts—routingKey()(:44-60, тред:53, одиночный:55, пользователь: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— запуск--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), JSDoc дляdispatchModeисправлен на'steer'(:42),senderName(:69), новое полеalreadyPrefixed,isGroup(:75),SessionTarget(:88-93).
DingTalk (packages/channels/dingtalk/src/)
DingtalkAdapter.ts— картаwebhooks(:84),sendMessage()(:134-170, возврат без webhook:137-141), кэш webhook (:516-517),getAccessToken()(:172-174),emotionApi()(:188-207, robotCode:184, openConversationId:197, антипаттерн пустого catch:214-216), media robotCode (:435), inboundconversationId(: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— пропуск таблиц,splitChunks(),CHUNK_LIMIT=3800(≤ бюджета ~5000 символов дляsampleMarkdown),extractTitle(),normalizeDingTalkMarkdown().media.ts— заголовокdownloadMedia(:39), тело:42.- SDK:
client.mjsgettoken (:85-87), переподключение (: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— FIFOPermissionAuditRingна 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), строгая аутентификация для mutate (:114), лимит на записьMAX_MEMORY_CONTENT_BYTES(:79), жесткая передачаprojectRoot(:185-190).
CLI channel commands (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+; инъекция на уровне CLI дляreadChannelMemory/writeChannelMemory.config-utils.ts—parseChannelConfig()(:81-100, sessionScope по умолчанию:91-92, approvalMode:94, groupPolicy:98),resolveEnvVars()(:6-18).channel-registry.ts—ensureBuiltins()(:6-32), типы каналов (:10-14).