Skip to Content
ДизайнChannelsRFC: "qwen tag" — персистентный мультиплеерный агент-резидент канала для qwen-code (с приоритетом на DingTalk)

RFC: “qwen tag” — персистентный мультиплеерный агент-резидент канала для qwen-code (с приоритетом на DingTalk)

Статус: Черновик (v2) Дата: 2026-06-25 Автор: (qwen-code)


История изменений (v1 → v2)

В этой редакции закрыты все Open Decision из v1 (теперь это Resolved Decisions, §9) и исправлены семь дефектов корректности/согласованности, выявленных при ревью. Два ключевых изменения:

  • OD-1 больше не является блок-фактором — это зафиксированная архитектура. Phase 0 поставляется на текущем пути AcpBridge; Phase 1+ мигрирует хостинг каналов в демон qwen serve (через DaemonChannelBridge / daemon channel runner) для повторного использования пер-сессионной FIFO promptQueue, MultiClientPermissionMediator, eventBus, /workspace/memory и rate-limit. Каждый раздел, где ранее было написано “OD-1 open / gates everything”, теперь отражает принятое решение, а обязательство по использованию демона распространено на §1, §4, §5, §6.1, §6.2, §6.3, §6.4 и §7.
  • Проактивный путь выполнения (fire-path) переработан под демон, на котором он будет фактически работать. dispatchProactive из v1 был написан под семантику AcpBridge (канал-side sessionQueues). При миграции на демон DaemonChannelBridge.prompt() выбрасывает Prompt already in flight при перекрытии (DaemonChannelBridge.ts:257-261) вместо постановки в очередь. v2 сериализует проактивные промпты через ChannelBase.sessionQueues для обоих вариантов, поэтому защита от выброса исключения никогда не срабатывает, а инвариант “никогда не отменяется” (never-cancellable) явно зафиксирован (§6.2).

Включенные решения и исправления:

  • OD-2 решено: один процесс на workspace/channel.
  • OD-3 решено: Phase 1 first-responder + единый clientId на уровне канала; Phase 2 consensus/designated после появления ростера senderId→clientId + жизненного цикла; автоматический отказ (auto-deny) для инструментов высокого риска при проактивных ходах.
  • OD-4 решено: в общей (thread) группе /clear требует явного confirm и ограничен config.allowedUsers, если этот список задан; /status только для чтения. (Дефисный /clear-channel не парсится грамматикой слэш-команд; настоящий owner-gate для каждого участника ждет модели идентификации — OD-3/OD-11.)
  • OD-5 решено: исправить устаревший JSDoc в types.ts:42 на 'steer'; профиль группы tag явно устанавливает dispatchMode: 'followup'.
  • OD-6 решено: префикс [senderName] для каждого хода, без привязки к instructedSessions; одно новое опциональное поле EnvelopealreadyPrefixed, чтобы синтетический повторный вход в режиме collect пропускал повторное добавление префикса. (Исправляет утверждение v1 “нет новых полей envelope” — Fix #2.)
  • OD-7 решено на основе проверенных фактов о DingTalk API (§6.2/§6.5), элементы с низкой уверенностью по-прежнему помечены.
  • OD-8 решено: планировщик gateway/daemon является единственным владельцем cron; сессия tag не запускает свой внутри-сессионный Session cron; два хранилища cron находятся на непересекающихся путях, поэтому коллизия возможна только в случае, если оба планировщика запущены для одних и тех же задач.
  • OD-9 решено: агрегация “org” для каждого процесса + окна для каждого канала, побеждает самый строгий, фиксированное дневное окно; v1 оценивает токены на стороне канала и читает путь использования демона после перехода на хостинг в демоне.
  • OD-10 решено: добавить область channel (+channelKey) в writeContextFile.ts; channel-base получает запись/чтение через callback на уровне CLI, внедряемый через ChannelBaseOptions (без зависимости channel-base → core); пользовательское глобальное расположение ~/.qwen/channels/memory/.
  • OD-11 решено: senderName только для справки; clientId — единственный принципал безопасности; кольцо аудита в памяти + файл follow-up с добавлением в конец ~/.qwen.
  • OD-12 решено: требовать --require-auth + токен для любого развертывания с демоном, не являющегося loopback.

Исправления корректности помимо решений OD:

  • Fix #1 — конкурентность проактивного fire-path переработана для пути демона (§6.2), инвариант “никогда не отменяется” применяется как для варианта AcpBridge из Phase-0, так и для варианта демона из Phase-1+.
  • Fix #2 — внутреннее противоречие удалено: §6.1/G2 больше не утверждает “нет новых полей envelope”; он признает наличие одного поля alreadyPrefixed.
  • Fix #3 — спроектирована проводка памяти (§6.3): точное изменение ChannelBaseOptions (callback-и readChannelMemory/writeChannelMemory) и то, кто их создает/внедряет в start.ts, при этом первичное чтение при загрузке (bootstrap read) один раз за сессию повторно использует gate instructedSessions.
  • Fix #4 — спроектирован флаг возможности canColdSend (§6.2): где он объявлен, как его устанавливают DingTalk/Feishu и как планировщик выдает явную ошибку (fails loud).
  • Fix #5 — уточнение непересекающихся хранилищ OD-8 (§6.2): хранилище gateway и хранилище Session — это разные пути; единственный риск коллизии — это сессия tag, которая также запускает внутри-сессионный cron, что закрыто gate OD-8.
  • Fix #6 — принудительное применение оценочного бюджета (§6.4): оценка может выдавать WARN/alert, но никогда не должна жестко отклонять (hard-decline) промпт пользователя; жесткое отклонение (HARD-decline) только по реальным цифрам использования демона.
  • Fix #7 — атрибуция аудита при followup (§6.4): передача senderId вместе с промптом в очереди, чтобы вызов инструмента/разрешение атрибутировались ходу, который фактически выполняется, а не последнему поставленному в очередь отправителю.

Проверенные факты из v1 (топология AcpBridge, auto-approve в AcpBridge, абстрактный sendMessage, области, значения по умолчанию парсера) сохранены без изменений.


1. Обзор

“qwen tag” — это общий агент qwen-code, который живет внутри чат-канала (в первую очередь группы DingTalk, во вторую — Feishu), и которого любой участник этого канала вызывает с помощью @-упоминания. После вызова он запускает полный цикл агента qwen-code (инструменты, редактирование файлов, shell, MCP) для привязанного workspace, потоково передает свою работу обратно в канал по мере выполнения, запоминает канал между ходами и перезапусками и может действовать проактивно или по расписанию, не дожидаясь запроса. Это повторяет форм-фактор Claude Tag — единый персистентный мультиплеерный агент, который является резидентом комнаты, а не ботом для личных сообщений 1-на-1, — но он полностью построен на существующем стеке адаптеров каналов qwen-code (qwen channel start, packages/channels/*) и демоне qwen serve, а не на новом хостинговом сервисе.

Преднамеренная рамка этого RFC заключается в том, что реактивная половина форм-фактора по большей части уже поставлена, а проактивная/memory половина — нет. Части, которые делают ответного агента в стиле Claude Tag сложным — долгоживущий процесс, мультиплексирующий сессии, транспорт агента, сохраняющий инвариант “один промпт на сессию”, мультиплеерная маршрутизация сессий, контроль доступа для каждого канала, потоковый рендеринг карточек и долговременное сохранение сессий, — уже существуют и используются текущими адаптерами каналов. То, чего не хватает, — это четко ограниченный набор возможностей, которые превращают реактивного бота-ответчика в агента-резидента: атрибуция отправителя в общих сессиях, проактивный/планируемый путь вывода, память для каждой комнаты и мультиплеерное управление. Этот RFC сводит этот пробел к четырем областям разработки и специфицирует их в рамках Phase 0–2.

Примечание о “80%”: в ранних черновиках это формулировалось как “~80% поставлено”. Эта цифра непроверяема и преувеличивает ситуацию — весь проактивный движок (Build Area 2) и память для каждой комнаты (Build Area 3) совершенно новые, а в DingTalk в частности вообще нет пути для инициирования исходящих сообщений. Вместо этого мы формулируем это так: “реактивный путь построен; проактивный путь и пути памяти — нет”.

Факт топологии, ограничивающий весь RFC

Существует два различных способа подключения адаптера канала к агенту qwen, в двух разных процессах, и их смешение — самая распространенная ошибка в ранних черновиках:

  • qwen channel start <name> (поставляемый путь). start.ts создает new AcpBridge(bridgeOpts) (start.ts:213,268,356,435), и AcpBridge.start() порождает дочерний процесс node <cliEntryPath> --acp (AcpBridge.ts:53-70), который общается по ACP через NDJSON на stdio. Этот дочерний процесс — автономный агент, а не HTTP-демон qwen serve. В этой топологии нет HTTP-демона, нет маршрута /workspace/memory, нет MultiClientPermissionMediator, нет кольца воспроизведения eventBus и нет демонической promptQueue — всё это находится в packages/acp-bridge + packages/cli/src/serve, которые qwen channel start никогда не инстанцирует. Сериализация промптов здесь выполняется полностью на стороне канала с помощью ChannelBase (мьютекс activePrompts в ChannelBase.ts:356-391 + цепочка sessionQueues в :394-470) и собственным инвариантом ACP “один промпт на сессию” дочернего процесса. AcpBridge.requestPermission автоматически одобряет каждый вызов инструмента (AcpBridge.ts:108-118).
  • qwen serve + DaemonChannelBridge (хостинг в демоне). DaemonChannelBridge (packages/channels/base/src/DaemonChannelBridge.ts) — это внутрипроцессный мост, чей sessionFactory создает объекты Session демона. Этот путь запускает каналы внутри демона и, таким образом, наследует FIFO promptQueue из 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); FIFO promptQueue, до которой он в итоге доходит, находится на стороне демона/acp-bridge, за этим внутрипроцессным защитным выбросом. Поэтому проактивный движок должен сериализоваться на уровне канала (§6.2).

Зафиксированная архитектура (было OD-1, теперь решено): механика мультиклиентского демона используется повторно путем миграции хостинга каналов в демон qwen serve начиная с Phase 1.

  • Phase 0 поставляется на текущем пути AcpBridge (внедрение идентификации не требует ни HTTP-маршрутов, ни медиатора).
  • Phase 1+ запускает каналы под демоном qwen serve (через DaemonChannelBridge или daemon channel runner), поскольку проактивному движку, сохранению памяти для каждой комнаты и управлению требуются долговечность, маршруты, promptQueue, медиатор и шина событий демона.

Это больше не “открыто” и не “блокирует”: проводка Phase 0 добавляет путь подключения DaemonChannelBridge (или флаг --daemon <url>), чтобы миграция была доступна в тот момент, когда начинается Phase 1. Планировщик, принадлежащий gateway (§6.2), построен нейтральным к миграции, чтобы он работал одинаково до и после переключения.

Что такое “qwen tag” конкретно

Развертывание “qwen tag” — это единый процесс агента, привязанный к одному workspace, плюс адаптер qwen channel start dingtalk, настроенный так, что вся группа использует одну сессию агента. Должны совпадать две различные концепции области (scope):

  1. Область маршрутизации канала (ChannelConfig.sessionScope, используется SessionRouter.routingKey()): определяет, как входящие сообщения сопоставляются с ключом маршрутизации. Для tag это должно быть 'thread', чтобы вся группа использовала один ключ маршрутизации (channel:(threadId||chatId), SessionRouter.ts:53). Значение по умолчанию парсера — 'user', а не 'thread' (config-utils.ts:91-92), поэтому в рецепте tag его нужно задать явно.
  2. Область сессии Bridge/ACP (sessionScope в DaemonChannelBridge / acp-bridge): определяет, как демон использует базовую сессию ACP. DaemonChannelBridge.newSession() по умолчанию устанавливает это в 'thread' (DaemonChannelBridge.ts:229,240); внутрипроцессный путь acp-bridge по умолчанию устанавливает 'single' (bridge.ts:709). Это отдельный регулятор, отличный от области маршрутизации канала, и его нет на пути qwen channel start (AcpBridge.newSession(cwd) принимает только cwd, AcpBridge.ts:131).

При их наличии:

  • Один агент на комнату, вызывается по упоминанию. GroupGate обеспечивает соблюдение requireMention (по умолчанию true, GroupGate.ts:49), поэтому агент молчит, пока его не упомянут через @ или пока это не будет ответ боту (GroupGate.ts:51). Мультиплеерный ключ — это sessionScope: 'thread', сопоставляемый с channel:(threadId||chatId) (SessionRouter.ts:50-53), поэтому каждый участник повторно использует один и тот же sessionId независимо от отправителя.
  • Настоящая многоэтапная работа с инструментами. Входящие сообщения становятся промптами через ChannelBase.handleInbound(), который строит promptText из текста сообщения, контекста цитаты ответа, путей к файлам вложений и (один раз за сессию) config.instructions (ChannelBase.ts:316-347), затем диспатчит через bridge.prompt(sessionId, promptText, { imageBase64, imageMimeType }) (ChannelBase.ts:425promptText это позиционный аргумент; объект опций несет только поля изображения).
  • Потоково передает свою работу обратно в комнату. Адаптеры рендерят инкрементальный вывод как нативные карточки платформы (Feishu create/update/finalize, markdown.ts; разбивка markdown на части в DingTalk, DingtalkAdapter.ts:144-169).
  • Запоминает канал. SessionRouter.persist() / restoreSessions() долговременно хранят sessionId, target и cwd и восстанавливают их через bridge.loadSession() при перезапусках (SessionRouter.ts:168-244); память workspace (QWEN.md / ~/.qwen/QWEN.md) читается/пишется через GET / POST /workspace/memory (workspace-memory.ts). Эта память имеет область workspace/global, а не для каждой комнаты — см. Build Area 3.
  • Может действовать проактивно / по расписанию. Это та половина, которой еще не существует сквозным образом (end-to-end) и которая является сердцем Phase 1.

2. Мотивация

Инфраструктура, обычно требуемая мультиплеерному ответному агенту-резиденту, уже реализована в этом репозитории. Действительно не хватает работы только над четырьмя областями.

Возможность, необходимая для форм-фактора TagУже присутствует (ссылка)
Долгоживущий мульти-сессионный процессAcpBridge порождает долгоживущий дочерний процесс --acp (AcpBridge.ts:53-70); путь демона добавляет пер-сессионную FIFO promptQueue (bridge.ts:232,2855,3082)
Мультиплеерная маршрутизация “одна комната, одна сессия”Область 'thread' в SessionRouter (SessionRouter.ts:53), переопределение для каждого канала setChannelScope() (SessionRouter.ts:40)
Семантика вызова по упоминаниюrequireMention в GroupGate по умолчанию true (GroupGate.ts:49-52)
Контроль доступа + онбордингallowlist в SenderGate + флоу с кодом сопряжения; гейты применяются сначала к группе, затем к отправителю (ChannelBase.ts:240-252)
Долговременное сопоставление сессий при перезапускахСохранение в SessionRouter (SessionRouter.ts:168-244)
Чтение/запись памяти workspaceGET / POST /workspace/memory (workspace-memory.ts); только области workspace + global; только для демона
Контроль разрешений для нескольких участников + аудит (только для демона)Четыре политики MultiClientPermissionMediator, включая кворум consensus (permissionMediator.ts:621-637); отдельное кольцо аудита разрешений (permission-audit.ts)
Аутентификация, rate limiting, безопасность loopback (только для демона)Глобальный bearer-токен (auth.ts:259-266) + многоуровневый rate limit для каждого clientId/IP (rate-limit.ts)
Примитив push внутри сессии (фоновые задачи)Очередь уведомлений Session + setNotificationCallback() передает вывод фоновых задач/монитора/shell в открытую сессию (Session.ts:688-689,2638-2668); isIdle() учитывает это (Session.ts:777)
Доставка на платформу (DingTalk + Feishu)Рабочие адаптеры с потоковыми карточками, медиа, реакциями (DingtalkAdapter.ts, FeishuAdapter.ts)

Поскольку Phase 1+ работает под демоном (зафиксированная архитектура, §1), строки “только для демона” выше становятся доступными возможностями для проактивного движка, сохранения памяти и управления, а не просто “целями, если мы мигрируем”.

Четыре области разработки, подробно раскрытые в §6:

  1. Конфигурация + идентификация для объявления tag (Phase 0). Задокументированный рецепт конфигурации — sessionScope: 'thread', groupPolicy, requireMention, instructions, dispatchMode — плюс пробел атрибуции отправителя: handleInbound() намеренно не внедряет senderName в promptText (ChannelBase.ts:316-347; senderName используется только для контроля доступа в ChannelBase.ts:246). В общей сессии 'thread' агент не может понять, кто говорит. Phase 0 внедряет маркер отправителя, точно так же, как уже внедряется контекст цитаты ответа (ChannelBase.ts:318).
  2. Проактивный движок / движок инициирования исходящих (Phase 1). Сегодня нет проактивного пути на границе канала: ChannelBase.sendMessage() абстрактен (ChannelBase.ts:81) и вызывается только из ответа. В DingTalk sendMessage() может отвечать только через короткоживущий sessionWebhook, кэшируемый для каждого conversationId при входящем сообщении (DingtalkAdapter.ts:134-142), поэтому “холодной” группе вообще нельзя написать (DingtalkAdapter.ts:137-141 возвращает управление молча). Phase 1 добавляет планировщик-резидент демона и проактивный путь отправки для DingTalk.
  3. Память-резидент канала + извлечение (Phase 2, половина памяти). Память workspace — это workspace/global, а не для каждой комнаты: POST /workspace/memory принимает только scope: 'workspace' | 'global' (workspace-memory.ts:118-125) и является маршрутом мутации со строгой аутентификацией (deps.mutate({ strict: true }), workspace-memory.ts:114). Tag, который “помнит этот канал”, нуждается в пространстве имен памяти для каждой комнаты.
  4. Мультиплеерное управление + безопасность (Phase 2, половина управления). Политика разрешений, подходящая для групп, ограничители (guardrails) для проактивных действий и судебный аудит, построенные на существующей механике уровня clientId (а не уровня человеческой идентификации).

3. Цели и не-цели

Цели

  • G1 — Задокументировать и поставить конфигурацию “tag” для DingTalk: копируемый рецепт channels.dingtalk (явный sessionScope: 'thread', groupPolicy: 'allowlist' с перечисленным ID группы, requireMention: true, instructions и намеренно выбранный dispatchMode), дающий работающего мультиплеерного агента-резидента, с повторным использованием parseChannelConfig() и существующих гейтов. Рецепт должен подчеркивать различие между областью маршрутизации и областью ACP, а также то, что значение по умолчанию парсера 'user' должно быть переопределено.
  • G2 — Атрибуция отправителя в общих сессиях. Внедрить маркер отправителя для каждого сообщения в promptText, чтобы агент мог различать говорящих в группе с областью 'thread', не нарушая внедрение instructions один раз за сессию, отслеживаемое instructedSessions (ChannelBase.ts:344-346). Маркер для каждого сообщения (говорящий меняется каждый ход) и НЕ должен зависеть от instructedSessions. Это требует одного нового опционального поля Envelope, alreadyPrefixed (types.ts), чтобы синтетический повторный вход в режиме collect не добавлял префикс дважды — см. §6.1. (В v1 это ошибочно описывалось как “только формат, без новых полей”.)
  • G3 — Проактивный движок. Механизм для (a) инициирования вывода в канал, который только что не писал, и (b) срабатывания по расписанию независимо от любой открытой интерактивной сессии, с доставкой через существующий путь уведомлений для каждой сессии, где это возможно, — включая API проактивной отправки DingTalk и сохраняемое хранилище openConversationId с определенным владельцем обновления токена. Должен соблюдать инвариант ACP “один промпт на сессию” (NG6) путем сериализации через ChannelBase.sessionQueues (никогда не отменять ход человека через steer), при обеих топологиях.
  • G4 — Память-резидент канала. Пространство имен памяти для каждой комнаты и путь извлечения, надстроенные над существующей механикой /workspace/memory и механизмом instructions. Дизайн добавляет новую область channel (+channelKey) в writeContextFile.ts и достигает ее из channel-base через callback на уровне CLI, внедряемый через ChannelBaseOptions (без зависимости channel-base → core).
  • G5 — Мультиплеерное управление. Политика разрешений, подходящая для групп, ограничители для проактивных действий и аудит, построенные на MultiClientPermissionMediator и кольце аудита разрешений. Должен учитывать тот факт, что голоса атрибутируются clientId, а не человеческой идентификации, и что в одной общей сессии 'thread' каждый участник группы является одним и тем же клиентом демона.
  • G6 — Паритет Feishu для всего в G1–G5, рассматривается как follow-up. Стабильный tenant_access_token Feishu уже поддерживает проактивные отправки в любой чат просто по chatId (FeishuAdapter.ts:622-651), поэтому Feishu не нужен новый API отправки для G3 — только механизм пробуждения/планирования на уровне демона. Feishu объявляет canColdSend = true.
  • G7 — Повторное использование вместо изобретения. Каждая область разработки расширяет существующий механизм (гейты, маршрутизатор, мост, медиатор, маршруты памяти, путь уведомлений внутри сессии, cron), а не вводит параллельную подсистему.

Нецели

  • NG1 — Не является хостинговым мультитенантным SaaS. Процесс “qwen tag” — это один агентный процесс, привязанный к одному рабочему пространству (serve.ts:165-171; несколько рабочих пространств = один демон на каждое рабочее пространство на отдельных портах). Нет централизованной плоскости управления.
  • NG2 — В этом RFC нет индивидуальной идентификации пользователей, биллинга или бюджетов затрат. Модель идентификации демона — это один глобальный bearer-токен (auth.ts:259-266) и атрибуция на уровне clientId во всей шине событий и аудите разрешений. Мы добавляем маркеры отправителя в промптах (G2), но не вводим аутентифицированные пользовательские субъекты, пользовательские квоты или отслеживание затрат. Маркеры отправителя — это информационный текст в промпте, а не граница аутентификации: каждый участник группы использует единые учетные данные рабочего пространства демона, а в общей сессии 'thread' это один и тот же clientId демона.
  • NG3 — Шлюз с множественной идентификацией для Фазы 3 выходит за рамки данного документа и упоминается только как ориентир на будущее. Этот RFC охватывает Фазы 0–2.
  • NG4 — Feishu является вторичным, а не равнозначным основному. DingTalk выступает в качестве эталонной реализации и источника всех рабочих примеров.
  • NG5 — Slack и другие западные платформы выходят за рамки. Зарегистрированные типы каналов: telegram, weixin, dingtalk, feishu и qq (channel-registry.ts:10-14); адаптер для Slack отсутствует.
  • NG6 — Инвариант ACP «один промпт на сессию» не изменяется. Запланированный/проактивный промпт — это просто еще одна запись в sessionQueues канала; он не может выполняться параллельно с пользовательским ходом в той же сессии и не может его отменить.
  • NG7 — Не создается новый движок хранилища памяти с привязкой к чату. Память, резидентная в канале (G4), добавляет пространство имен поверх существующих файлов QWEN.md/AGENTS.md с файловым хранением; никаких векторных БД или баз данных для каждой комнаты.

4. Оценка текущего состояния

Создано (B), частично (P), отсутствует (M). В колонке «Файл» указан авторитетный символ. «Топология» указывает, существует ли возможность на пути канала AcpBridge (A), пути демона qwen serve (D) или на обоих путях, а также, поскольку Фаза 1+ обязана работать под демоном, содержит пометку «→D», если именно миграция открывает данную возможность.

ВозможностьТекущий qwen-code (файл / символ)ТопологияПробелРазмер
Маршрутизация “один чат — одна сессия”SessionRouter.routingKey() 'thread' (SessionRouter.ts:44-60)A+DОбласть действия по умолчанию — 'user' (config-utils.ts:91-92); оператор должен установить 'thread'Конфиг (S)
Вызов по упоминаниюGroupGate.requireMention по умолчанию true (GroupGate.ts:49-52)A+DОтсутствует — уже корректно
Контроль доступа / онбордингSenderGate allowlist + pairing (ChannelBase.ts:240-252)A+DОтсутствует
Долговременное сопоставление сессийSessionRouter.persist/restoreSessions (SessionRouter.ts:168-244)A+DОтсутствует
Атрибуция отправителя в промптеhandleInbound() собирает promptText без senderName (ChannelBase.ts:316-347)A+DsenderName никогда не внедряется; агент не может определить, кто говорил; требуется новый Envelope.alreadyPrefixedКод (S)
Сериализация промптовChannelBase.sessionQueues/activePrompts (:356-470); демон promptQueue (bridge.ts:2855)A (канал) / D (демон)DaemonChannelBridge.prompt() ВЫБРАСЫВАЕТ ИСКЛЮЧЕНИЕ при перекрытии (:257-261) — проактивный движок должен сериализовать на стороне канала; dispatchMode по умолчанию 'steer' отменяет параллельные (:354,371-379)Конфиг + Код (S)
Инициирование исходящих / проактивная отправкаChannelBase.sendMessage() абстрактный (:81); DingTalk только через webhook (DingtalkAdapter.ts:134-142)A+DНет проактивного шва; в холодной группе DingTalk невозможна отправка сообщений; требуется флаг возможности canColdSendКод (L)
Планировщик на уровне демонаCron привязан к сессии (Session.ts:667-668), завершается при dispose() (:790-812)A+D (шлюз) → D (аудит/повторное использование очереди)Нет эндпоинта планировщика демона в serve/ или channels/; планировщик шлюза является единственным владельцем (OD-8)Код (L)
Примитив push-уведомлений в сессииsetNotificationCallback (Session.ts:2638-2668)A+DДоставляется только в активную сессию; не может разбудить удаленную сессию(повторное использование)
Память для каждого чата/workspace/memory ограничивает области workspace|global (workspace-memory.ts:118-125)Только DНет области действия чата/канала; требуется новая область действия channel + callback на уровне CLI (без зависимости от ядра)Код (M)
Голосование за разрешения от нескольких участниковMultiClientPermissionMediator 4 политики (permissionMediator.ts:621-637)D (унаследовано Фаза 1+)AcpBridge автоматически одобряет (AcpBridge.ts:108-118); голоса учитываются для каждого clientId, один клиент на каналКод (L)
Аудиторский следPermissionAuditRing FIFO 512 (permission-audit.ts)D + кольцо на стороне каналаНет человеческого senderId; в памяти, теряется при перезапуске; дополнение с добавлением только в конец в ~/.qwenКод (M)
Бюджет токенов / затратотсутствует (rate-limit только по количеству запросов, rate-limit.ts)реестр на стороне канала + использование DНет счетчика расходов; оценки v1 (информационные), реальное списание только при хостинге на демонеКод (M)
Область действия инструментов/MCP для каждого каналаcoreTools/allowedTools/excludeTools (config.ts:727-729); MCP allow-filter (:3327-3333)для каждого ConfigНет пути аргумента запуска от канала к дочернему процессу --acp (AcpBridge); Config для каждого демона после размещения на хостингеКод (M)
Проактивная отправка DingTalkне реализовано (только robot/emotion, messageFiles/download)A+DНовый эндпоинт + сохраненный openConversationId + обновление токена (проверенный контракт, §6.2)Код (L)
Проактивная отправка FeishusendMessage() через tenant_access_token (FeishuAdapter.ts:622-676)A+DОтсутствует — canColdSend = true

Ключ к размерам: S = конфигурация/небольшой код, M = изменение модуля + интерфейса, L = изменение в нескольких пакетах или новая подсистема.


5. Архитектура

qwen tag — это не новый рантайм. Это четыре тонких слоя, привитых к существующему стеку адаптеров. Базовый слой уже обеспечивает работу агента с поддержкой многопользовательского режима, выполнения инструментов и MCP, доступного через чат-канал. Четыре новых слоя компенсируют следующие пробелы: (1) кто говорит — идентификация отправителя никогда не попадает в промпт; (2) действия без запроса — нет пути инициации исходящих сообщений, cron внутри сессии завершается вместе с сессией; (3) запоминание канала — память является глобальной для рабочего пространства; (4) управление общим мозгом — аутентификация представляет собой один глобальный токен, нет бюджета для каждого канала.

Каждый слой ниже указывает, какую топологию он предполагает (см. §1). Зафиксированное разделение: Фаза 0 на AcpBridge; Фаза 1+ на демоне qwen serve через DaemonChannelBridge.

Базовый слой (существующий) — топология qwen channel start (Фаза 0)

one host, one workspace ┌──────────────────────────────────────────────────────────────────────────────┐ │ qwen channel start dingtalk │ │ │ │ ┌────────────────────┐ Envelope ┌───────────────────────────────────┐ │ │ │ DingtalkAdapter │ ──────────────▶ │ ChannelBase.handleInbound() │ │ │ │ (stream client, │ │ 1 GroupGate.check (mention/ │ │ │ │ webhooks map by │ ◀────────────── │ policy/allowlist) │ │ │ │ conversationId) │ text/markdown │ 2 SenderGate.check (pairing) │ │ │ │ sendMessage() │ │ 3 slash / "!" commands │ │ │ └────────────────────┘ │ 4 router.resolve(...) │ │ │ ▲ sessionWebhook (expires, │ 5 dispatchMode (steer default) │ │ │ │ per inbound msg only) └───────────────┬───────────────────┘ │ │ │ │ sessionId │ │ │ ┌────────────────▼──────────────────┐ │ │ │ │ SessionRouter │ │ │ │ │ routingKey(): user|thread|single │ │ │ │ │ persist() → JSON (crash recovery) │ │ │ │ └────────────────┬──────────────────┘ │ │ │ textChunk / toolCall events ┌────────────────▼──────────────────┐ │ │ └─────────────────────────────── │ AcpBridge (NOT the HTTP daemon) │ │ │ │ spawns child `node <cli> --acp` │ │ │ │ ClientSideConnection over stdio │ │ │ │ requestPermission AUTO-APPROVES │ │ │ └────────────────┬──────────────────┘ │ └──────────────────────────────────────────────────────────┼─────────────────────┘ │ ACP / NDJSON (stdio) ┌──────────────────▼─────────────────────┐ │ child agent process (`--acp`) │ │ one prompt-in-flight per ACP session │ │ in-session cron (Session.ts) — DISABLED│ │ for tag sessions (OD-8); MCP, tools. │ │ NO promptQueue/eventBus/mediator │ └─────────────────────────────────────────┘

Топология с хостингом на демоне (Фаза 1+) — qwen serve + DaemonChannelBridge

one host, one workspace, ONE daemon ┌──────────────────────────────────────────────────────────────────────────────┐ │ qwen channel start dingtalk (channels hosted IN the daemon) │ │ ┌────────────────────┐ Envelope ┌────────────────────────────────────────┐│ │ │ DingtalkAdapter │ ──────────▶ │ ChannelBase.handleInbound() ││ │ │ pushProactive() │ ◀────────── │ gates → governor.admit → router ││ │ │ canColdSend = false*│ │ → sessionQueues (FIFO, serialization) ││ │ └────────────────────┘ └───────────────┬────────────────────────┘│ │ ▲ proactive group-send │ bridge.prompt() │ │ │ (openConversationId) ┌───────────────▼────────────────────────┐│ │ ┌──────┴────────────┐ │ DaemonChannelBridge ││ │ │ ChannelCronSched │──fire────────▶│ prompt() THROWS on overlap (:257-261) ││ │ │ (gateway-owned, │ dispatchProa- │ → so all prompts MUST arrive serialized││ │ │ sole cron owner) │ ctive via │ via sessionQueues ││ │ └────────────────────┘ sessionQueues └───────────────┬────────────────────────┘│ │ │ in-process Session │ │ ┌────────────────▼────────────────────────┐│ │ │ daemon: acp-bridge FIFO promptQueue, ││ │ │ MultiClientPermissionMediator, eventBus, ││ │ │ /workspace/memory + /channel routes, ││ │ │ rate-limit, bearer auth ││ │ └──────────────────────────────────────────┘│ └──────────────────────────────────────────────────────────────────────────────┘ * DingTalk canColdSend flips true once the proactive-send path ships (§6.2).

Ключевые инварианты, на которые мы опираемся (проверено):

  • Область действия треда — это ключ к многопользовательскому режиму. routingKey() возвращает ${channelName}:${threadId || chatId} в режиме 'thread' (SessionRouter.ts:53); resolve() повторно использует ключ (:79-83). Область действия по умолчанию — 'user' (:25); qwen channel start устанавливает область действия для каждого канала через router.setChannelScope(name, config.sessionScope) (start.ts:361-362) в многоканальном пути, или через конструктор ChannelBase из config.sessionScope (ChannelBase.ts:62-64) в одноканальном пути. Для многопользовательского режима оператор должен установить sessionScope: "thread".
  • Сериализация промптов. В AcpBridge newSession(cwd) принимает только cwd (AcpBridge.ts:131), а AcpBridge.prompt() не имеет защиты от параллелизма — сериализация обеспечивается dispatchMode в ChannelBase: collect буферизует (:361-370,445-463), steer отменяет выполняющийся промпт (:371-379), followup выстраивает в цепочку sessionQueues (:381-383,394-470). Значение по умолчанию для рантайма — 'steer' (:354); в JSDoc types.ts:42 указано 'collect'устарело; v2 исправляет на 'steer' (OD-5). На пути демона DaemonChannelBridge.prompt() выбрасывает исключение при перекрытии (:257-261); демон FIFO promptQueue (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), FIFO promptQueue для каждого SessionEntry (bridge.ts:2855-3082) становятся доступными, когда каналы размещаются под qwen serve (зафиксировано, §1).

Четыре новых слоя

┌───────────── governance (Layer 4) ─────────────┐ │ per-channel turn/cost budget gate │ │ proactive allowlist, quiet hours, kill switch │ └───────────────────────┬─────────────────────────┘ │ wraps all inbound + outbound inbound ┌──────────────────────────▼─────────────────────────┐ outbound ───────▶ │ identity injection (Layer 1) │ ────────▶ │ prefix promptText with speaker + channel context │ └──────────────────────────┬─────────────────────────┘ ┌──────────────────────────▼─────────────────────────┐ │ channel memory (Layer 3) │ │ per-channel fragment, injected at session start; │ │ persisted via CLI-layer callback (core helper) │ └──────────────────────────┬─────────────────────────┘ ┌──────────────────────────▼─────────────────────────┐ │ proactive engine (Layer 2) │ │ gateway scheduler → sessionQueues → bridge.prompt → │ │ channel.pushProactive() w/ cold-group fallback │ └─────────────────────────────────────────────────────┘

Слой 1 — Инъекция идентификации. Топология: обе; не требует демона. handleInbound() никогда не помещает senderName в promptText (ChannelBase.ts:246 читает его только для SenderGate.check(); Envelope.senderName существует в types.ts:69). Дизайн: одна точка инъекции с конфигурационным затвором в handleInbound(), после префикса referencedText (:316-319), управляемая envelope.isGroup, плюс новый флаг Envelope.alreadyPrefixed для повторного входа collect. Подробно описано в §6.1.

Слой 2 — Проактивный движок. Топология: планировщик, принадлежащий шлюзу, нейтральный к миграции; работает под демоном в Фазе 1+. Cron внутри сессии завершается при dispose() (Session.ts:790-803); эндпоинт планировщика демона отсутствует. DingtalkAdapter.sendMessage() не может достичь холодной группы (:137-141). Дизайн: планировщик, резидентный в шлюзе, который инициирует срабатывание через ChannelBase.sessionQueues (никогда не steer) и направляет завершение в channel.pushProactive(). Подробно описано в §6.2.

Слой 3 — Память канала. Топология: путь сохранения через callback на уровне CLI; инъекция на стороне канала. Память является глобальной только для рабочего пространства (workspace-memory.ts:86-303). Дизайн: фрагмент памяти для каждого канала, внедряемый при запуске сессии (повторное использование одноразового затвора instructions для сессии), плюс новая область действия channel на пути записи, достигаемая из channel-base через внедренные callbacks (без зависимости channel-base → core). Подробно описано в §6.3.

Слой 4 — Governance. Топология: обертка-затвор на стороне канала; rate-limiter на стороне демона в Фазе 1+. Демон имеет один глобальный bearer-токен (auth.ts:259-266), ограничение скорости для каждого clientId/IP и не имеет бюджета для каждого канала. Дизайн: ChannelGovernor/BudgetLedger, оборачивающие handleInbound() и планировщик. Подробно описано в §6.4.

Data-flow 1 — входящий @qwen в групповом треде

Этот поток имеет одинаковую структуру в обеих топологиях; единственное отличие заключается в том, где реализованы сериализация и проверка прав. В AcpBridge (Phase 0) сериализация обеспечивается ChannelBase.sessionQueues, а права автоматически одобряются дочерним процессом; в демоне (Phase 1+) сериализация по-прежнему осуществляется через ChannelBase.sessionQueues (защитный механизм демона throw-guard никогда не срабатывает, так как канальный уровень уже выполнил сериализацию), а проверка прав проходит через MultiClientPermissionMediator.

  1. DingTalk → адаптер. Участник отправляет “@qwen summarize today’s incidents”. Стрим-клиент доставляет DingTalkMessageData с conversationId, sessionWebhook, отправителем и isInAtList. DingtalkAdapter кэширует webhooks.set(conversationId, sessionWebhook) (:516-517) и эмитит Envelope с isGroup:true, isMentioned:true, chatId = conversationId.
  2. Governor (L4). ChannelGovernor/BudgetLedger.admit() проверяет бюджет ходов/затрат канала (рекомендательный режим, пока не появятся реальные данные об использовании, §6.4) и аварийный выключатель (kill switch). Жесткое ограничение / явный лимит с реальными числами → отклонение и ответ; превышение порога только по оценкам → WARN, никогда не жесткое отклонение (Fix #6).
  3. Gates. GroupGate.check() проходит (упоминание удовлетворяет значению по умолчанию requireMention:true); SenderGate.check() проходит (:246).
  4. Routing. router.resolve(...) вычисляет dingtalk:<conversationId> в скоупе 'thread' (требуется sessionScope:"thread") и возвращает общий sessionId группы. persist() записывает его.
  5. Memory (L3) + identity (L1). При первом ходе память канала + config.instructions добавляются в начало один раз (instructedSessions, :344-347). Инъекция идентичности добавляет [Alice] в начало каждого сообщения.
  6. Attribution capture. Разрешенные senderId/senderName записываются в элемент очереди, передаваемый в sessionQueues (Fix #7), а не объединяются позже по временной метке.
  7. Dispatch. Профиль тега устанавливает followup (никогда steer); параллельное сообщение Боба выстраивается в цепочку в sessionQueues (:394-470).
  8. Bridge. bridge.prompt(sessionId, promptText, {imageBase64, imageMimeType}) пересылается через stdio ACP (AcpBridge.prompt, AcpBridge.ts:147) или в сессию демона (DaemonChannelBridge.prompt) — это происходит только тогда, когда предыдущий ход исчерпал activePrompts, поэтому защитный механизм демона throw-guard (:257-261) никогда не срабатывает.
  9. Stream back. textChunkonChunk (:416-422); onResponseCompleteDingtalkAdapter.sendMessage() использует кэшированный sessionWebhook (теплая группа).

Data-flow 2 — запланированный проактивный пуш в холодную группу

  1. Срабатывание расписания. ChannelCronScheduler, работающий в шлюзе, пробуждается в 09:00 для daily-standup → dingtalk:<convA>. Это не внутри-сессионный cron (он отключен для сессий с тегами, OD-8/§6.2; и все равно мертв после завершения сессии — dispose() очищает cronQueue, Session.ts:790-803).
  2. Governor (L4). Проверяет проактивный whitelist и тихие часы (явный источник часового пояса). Вне окна / не в whitelist → пропуск + лог. Планировщик проверяет adapter.canColdSend перед попыткой доставки; если false, он fails loud (логирует + записывает lastError), никогда не завершается молча (Fix #4).
  3. Синтетический envelope. senderId:'__cron__', chatId: convA, isGroup:true, isMentioned:true, без messageId. Синтетический промпт несет собственную атрибуцию (createdBy) в элементе очереди.
  4. Сериализация, без вытеснения. dispatchProactive выстраивается в цепочку в ChannelBase.sessionQueues и ожидает завершения любого выполняющегося хода пользователя (activePrompts.get(sessionId)?.done). Он никогда не вызывает steer/cancelSession и никогда не вызывает bridge.prompt(), пока удерживается activePrompts — поэтому исключение демона Prompt already in flight (:257-261) не может сработать (§6.2, Fix #1).
  5. Отправка в холодную группу. pushProactive(convA, text) обнаруживает, что webhooks.get(convA) равен undefined, и переключается на новый проактивный путь: сохраненный openConversationId, свежий токен учетных данных приложения, POST https://api.dingtalk.com/v1.0/robot/groupMessages/send с robotCode = config.clientId, msgKey:'sampleMarkdown', msgParam (JSON-строка). (В Feishu шаг 5 — это существующий sendMessage() через tenant_access_token; canColdSend = true.)
  6. Budget + audit. Проактивный ход потребляет бакет бюджета канала (рекомендательное списание, пока не появится использование на хостинге демона); записывается с createdBy как исходной идентичностью и originatorClientId на транспортном уровне (человеческая идентичность не выдумывается, eventBus.ts:60).

Почему такая структура (переиспользование вместо изобретения)

Каждый новый слой подключается к существующему стыку: идентичность — в месте сборки promptText, проактивность — в sessionQueues + pushProactive(), память — в механизме instructions/writeContextFile, управление — как обертка над цепочкой gates. Единственное структурное требование — переиспользование механизмов демона слоями 2–4 — удовлетворяется за счет запланированной миграции на демон (§1): Phase 0 работает на AcpBridge; Phase 1+ запускается под qwen serve.


6. Детальный дизайн

6.1 Многопользовательский режим и идентичность (Build Area 1)

«Тег qwen» живет в групповом чате. Каждый участник общается с одним и тем же агентом, который должен (a) поддерживать один общий разговор для всего канала, (b) знать, кто говорит в каждом ходе, (c) не позволять сообщению одного участника разрушить выполняющуюся задачу другого и (d) в идеале запрашивать у группы одобрение на рискованные вызовы инструментов. Сегодня qwen-code имеет примитивы для (a)–(c); (d) — это задача для Phase 1+ на хостинге демона (запланированная миграция, §1).

Общая сессия группы: sessionScope: 'thread'

В режиме 'thread' senderId исключается из ключа маршрутизации, поэтому каждый участник резолвится в один sessionId (SessionRouter.ts:53,72-92) — это делает агента общей, резидентной для канала сущностью, а не N приватными ботами.

  • Скоуп для каждого канала, а не глобальный переключатель. Значение по умолчанию для роутера — 'user' (:25), и для конфига канала — 'user' (config-utils.ts:91-92). Личные сообщения (DM) и однопользовательские каналы остаются 'user'. Профиль тега устанавливает sessionScope: 'thread' в settings.json, что применяется для каждого канала через setChannelScope() (мульти-канал, start.ts:361-362) или конструктор ChannelBase (одно-канал, ChannelBase.ts:62-64).
  • Стабильность threadId/chatId в DingTalk. Адаптер DingTalk никогда не устанавливает Envelope.threadId (DingtalkAdapter.ts:541-551), поэтому routingKey() использует фоллбэк threadId || chatId к chatId, схлопывая группу в одну сессию на каждый chatId (как и требуется). Предостережение: chatId = conversationId || sessionWebhook (:534). Для реальных групповых сообщений conversationId присутствует и стабилен; если сообщение когда-либо придет без него, chatId откатится к истекающему URL sessionWebhook, и ключ треда дестабилизируется. Профиль обрабатывает отсутствующий conversationId как жесткую ошибку (отбрасывает сообщение), а не молча использует вебхук в качестве ключа.

Персистентность обеспечивает восстановление после сбоев (SessionRouter.ts:168-244): перезапуск демона повторно подключает группу к той же общей сессии через bridge.loadSession().

Новая опасность: /clear и /status в скоупе треда действуют на весь канал

Общий обработчик /clear вызывает router.removeSession(this.name, senderId, chatId) (ChannelBase.ts:147-152), а /status вызывает router.hasSession(...) (:203-208); оба маршрутизируются через routingKey(), который игнорирует senderId в режиме 'thread'. Таким образом, /clear от любого одного участника стирает общую сессию для всего канала и сбрасывает instructedSessions — это «грабли» для сброса всего одним нажатием.

Решение (OD-4): в общей (тред) группе /clear (и его псевдонимы) требуют явного токена confirm и ограничены config.allowedUsers, если этот список задан; в противном случае они очищают напрямую (в личных сообщениях и группах для каждого пользователя затрагивается только собственная сессия вызывающего, поэтому гейт не нужен). Команда сохраняет имя /clear, потому что слэш-парсер принимает только [a-zA-Z0-9_] (команда с дефисом /clear-channel распарсится как clear + аргумент -channel); явный confirm служит индикатором деструктивного действия. Настоящий гейт владельца для каждого участника (различающий админов и участников независимо от whitelist чата) ожидает внедрения модели идентичности (OD-3/OD-11). /status остается read-only для общей сессии.

Пробел с атрибуцией отправителя и его исправление

handleInbound() собирает promptText из envelope.text, префикса цитаты referencedText, путей вложений и config.instructions (один раз за сессию) (ChannelBase.ts:315-347); envelope.senderName читается только для SenderGate.check() (:246). В группе с режимом 'thread' агент видит недифференцированный поток.

Исправление (OD-6) — префикс [senderName] для групповых ходов, в самом начале сборки промпта (:315-316), каждый ход:

let promptText = envelope.text; // Multiplayer attribution: in a thread-shared session, tag each turn with the // speaker. Skip 1:1 sessions (sender is invariant). Must fire EVERY turn — // not gated by instructedSessions (the speaker changes each message). The // alreadyPrefixed flag lets collect-mode synthetic re-entry skip this step. if (envelope.isGroup && !envelope.alreadyPrefixed) { const who = envelope.senderName || envelope.senderId || 'unknown'; promptText = `[${who}] ${promptText}`; } if (envelope.referencedText) { promptText = `[Replying to: "${envelope.referencedText}"]\n\n${promptText}`; }
  • Гейт по envelope.isGroup (types.ts:75), а не по скоупу.
  • Префикс перед referencedText, чтобы порядок читался как [Alice] [Replying to: "..."] <text>.
  • Использовать senderName, а не senderId. В DingTalk senderName = 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 по умолчанию: steerfollowup

steer (значение по умолчанию в рантайме, :354) отменяет выполняющийся промпт через bridge.cancelSession() (:371-379). В общей группе, если Боб отправит что-либо, пока агент работает над запросом Алисы, steer отменит задачу Алисы — случайный отказ в обслуживании. Профиль тега устанавливает dispatchMode: 'followup', чтобы сообщение Боба встало в очередь за задачей Алисы (FIFO sessionQueues, :381-383,394-470). Установите это в профиле группы (groups["*"].dispatchMode = "followup"), а не меняя глобальное значение по умолчанию — в личных сообщениях сохраняется UX самопрерывания steer. Изменения в коде не требуются, кроме задокументированного значения по умолчанию в профиле; v2 исправляет устаревший JSDoc types.ts:42 на 'steer', чтобы код и комментарий совпадали (OD-5). collect приемлем для групп с очень высоким трафиком (ограничивает глубину очереди) ценой размытия атрибуции.

Поскольку профиль тега для групп всегда followup (никогда steer), проактивный движок наследует чистый инвариант: нет гонки между steer и проактивностью, потому что ни один путь в группе с тегом не отменяет выполняющийся промпт. Этот инвариант повторно формулируется и обеспечивается в §6.2.

Handoff — «продолжить с того места, где остановился предыдущий участник»

С 'thread' + префиксами [senderName] + followup handoff является поведением по умолчанию: сессия хранит полную историю с несколькими спикерами. Два эргономичных дополнения: read-only команда /who (через protected registerCommand(name, handler), :141-143 — не приватную карту commands), сообщающая активный sessionId/cwd/краткое описание задачи; и идемпотентное переподключение при перезапуске (уже покрывается restoreSessions()).

Многопользовательские одобрения — фазирование (OD-3, решено)

Намерение верное: рискованные вызовы инструментов должны иметь возможность группового одобрения, и qwen-code поставляет MultiClientPermissionMediator с четырьмя политиками (permissionMediator.ts:348,621-637). Но ни к чему из этого нельзя обратиться из канала на пути AcpBridge в Phase 0:

  1. qwen channel start подключает AcpBridge, чей requestPermission автоматически одобряет каждый запрос (AcpBridge.ts:108-118). Никакого промпта на одобрение.
  2. Медиатор живет в HTTP-слое serve демона. Единственный способный к работе с правами канальный bridge — это DaemonChannelBridge (respondToPermission, :346-374) — он становится доступен, когда Phase 1 мигрирует хостинг каналов в демон (запланировано, §1).
  3. config.approvalModeмертвое поле — парсится (config-utils.ts:94) и типизируется (types.ts:36), но не читается ни одним адаптером или bridge.

Принятое фазирование:

  • Phase 0: без групповых одобрений. Контроль рисков через whitelist отправителей + requireMention + консервативный набор инструментов агента. Не утверждать, что approvalMode что-то делает.
  • Phase 1: канал работает на пути daemon-bridge (запланированная миграция); вывод permission_request в виде карточки DingTalk; релиз first-responder с одним clientId на уровне канала (нажатие любого разрешенного участника разрешает; атрибуция на уровне канала). Не требует карты senderId → clientId. Автоматический отказ в рискованных инструментах на проактивных ходах (ход, инициированный __cron__, не может ответить на промпт разрешения).
  • Phase 2: добавить consensus/designated для каждого участника, как только появятся маппинг senderId → clientId и жизненный цикл clientId (очистка, границы refcount). Примечание: один синтетический clientId на каждый senderId бесконечно растит карту refcount clientIds и должен очищаться.

Сводка конкретных изменений (Build Area 1)

ИзменениеГдеТип
Профиль группы устанавливает sessionScope: 'thread'settings.json + setChannelScope (start.ts:359-363)Конфиг
Обработка отсутствующего conversationId DingTalk как ошибкиDingtalkAdapter.ts ~:534Код (S)
Префикс [senderName] для групповых ходовChannelBase.handleInbound ~:316Код (S)
Новое опциональное поле Envelope.alreadyPrefixedtypes.ts (Envelope)Код (S)
Установка alreadyPrefixed при синтетическом повторном входе collectChannelBase.ts:449-462Код (S)
/clear confirm + гейт whitelist в общих группах; /status read-onlyобщие команды (:147-217)Код (S)
Профиль группы устанавливает dispatchMode: 'followup'groups["*"] в settings.jsonКонфиг
Исправление устаревшего JSDoc dispatchMode'steer'types.ts:42Исправление комментария
Команда handoff /whoregisterCommand (:141)Код (S)
Миграция на daemon-bridge заменяет авто-одобрение AcpBridgeХостинг DaemonChannelBridge (запланировано)Phase 1 (L)
Голосование за одобрение для каждого участника + карточка DingTalkновая обвязка bridge + respondToPermissionPhase 1/2 (L)

6.2 Проактивный движок: планировщик + исходящий пуш (ЯДРО)

Решение: планировщик, принадлежащий шлюзу, нейтральный к миграции

Принять планировщик, работающий в процессе шлюза qwen channel start. Шлюз владеет SessionRouter (с восстановлением через restoreSessions()start.ts:275,444), содержит каждый экземпляр адаптера и его bridge, и является единственным местом, где может быть вызван ChannelBase.pushProactive() (и базовый абстрактный sendMessage(), :81). Агент (будь то порожденный дочерний процесс --acp в Phase 0 или сессия демона в Phase 1+) остается чистым исполнителем промптов: планировщик срабатывает, ставя задачу в очередь ChannelBase.sessionQueues, который вызывает bridge.prompt() только после завершения предыдущего хода — никаких новых методов bridge, никаких обратных каналов, никаких маршрутов пуша демона.

Примечание по топологии (запланированная архитектура). Планировщик нейтрален к миграции по своей конструкции: он сериализует через ChannelBase.sessionQueues независимо от того, какой bridge находится под ним. В Phase 0 он управляет AcpBridge.prompt() через stdio; в Phase 1+ он управляет DaemonChannelBridge.prompt() (хостинг на демоне). Поскольку аудит eventBus демона и FIFO promptQueue требуются для управления в Phase 1+, канал запускается под qwen serve начиная с Phase 1 — но собственная логика планировщика не меняется на границе миграции.

Почему не альтернативы:

  • In-Session cron: отклонено — cronQueue/cronProcessing живут в внутрипроцессном Session (Session.ts:667-668), срабатывают только пока сессия открыта, и умирают при dispose() во время 30-минутной очистки из-за простоя (:790-812). Именно этот сбой и избегает планировщик шлюза. И планировщик шлюза является ЕДИНСТВЕННЫМ владельцем cron (OD-8): сессия с тегом никогда не запускает свой внутри-сессионный cron (механизм блокировки ниже).
  • Отдельный процесс: отклонено — второй долгоживущий процесс, дублирующий учетные данные DingTalk, неспособный переиспользовать внутрипроцессный SessionRouter и уже подключенный bridge.

Компоненты и их размещение

КомпонентФайлОтветственность
ChannelCronStorepackages/channels/base/src/ChannelCronStore.ts (новый)Персистентная таблица заданий, JSON-сосед sessions.json. atomicWriteJSON (atomicFileWrite.ts:385) + async-mutex Mutex для каждого файла.
ChannelCronSchedulerpackages/channels/base/src/ChannelCronScheduler.ts (новый)Одинокий переставляемый setTimeout (timer-wheel-of-one); следующий запуск через nextFireTime; догоняющий запуск при рестарте; 60-секундный тик согласователя. Один на шлюз; единственный владелец cron.
Примитивы Cronpackages/core/src/utils/cronParser.ts (переиспользование)parseCron/matches/nextFireTime (:104,141,168). Не переписывать.
dispatchProactiveChannelBase.ts (расширение)Вставка запуска через sessionQueues; ожидание activePrompts.get(sessionId)?.done для любого выполняющегося хода пользователя; никогда не steer; никогда не вызывать bridge.prompt(), пока удерживается activePrompts.
pushProactiveChannelBase.ts (расширение; базовое значение по умолчанию = sendMessage) + переопределение DingTalkИсходящая доставка; переопределения DingTalk для холодных групп. Ограничено возможностью canColdSend.
canColdSendСвойство ChannelBase (по умолчанию false)Флаг возможности, который планировщик проверяет перед холодной отправкой; DingTalk переключает в true, как только выходит проактивный путь API; Feishu — true.
Проактивная отправка DingTalkpackages/channels/dingtalk/src/proactive.ts (новый) + DingtalkAdapter.tsМассовая рассылка проактивных сообщений через robotCode + сохраненный openConversationId (контракт ВЕРИФИЦИРОВАН ниже).
Подключениеstart.ts (расширение startSingle/startAll)Создание + запуск планировщика после router.restoreSessions() (:275,444); проброс флага isTagSession в конструкцию сессии (OD-8).
Инструмент /schedule + schedule_taskChannelBase.handleInbound() (расширение, после gates :240-252)Сначала детерминированная команда; инструмент модели во вторую очередь.

Флаг возможности canColdSend (Fix #4)

Критерий кроссплатформенного MVP («одна и та же задача доставляется в DingTalk и Feishu») требует флага возможностей (capability flag), чтобы планировщик мог оценивать доступность, а не выявлять её отсутствие через молчаливые сбои.

  • Объявлен как свойство в ChannelBase: protected readonly canColdSend: boolean = false;. (Размещен в базовом классе, а не в отдельном реестре ChannelPlugin, потому что планировщик уже хранит экземпляр адаптера, а pushProactive/sendMessage являются методами экземпляра — размещение флага рядом с методом, который он защищает, объединяет их в один тип.)
  • DingTalk: canColdSend = false до тех пор, пока не будет внедрен путь проактивной отправки (proactive.ts) и не будет сохранен рабочий openConversationId; переключается на true после реализации pushProactive. Пока значение false, DingTalk все еще может отвечать на «теплые» (webhook) обращения — canColdSend управляет только доставкой в холодные группы.
  • Feishu: canColdSend = true (нативная проактивная отправка через tenant_access_token, FeishuAdapter.ts:622-676).
  • Планировщик сообщает об ошибках явно (fails loud): перед выполнением задачи (fire) планировщик проверяет adapter.canColdSend. Если false, он не пытается вызвать pushProactive; он логирует видимую оператору ошибку, устанавливает job.lastStatus='error' + lastError='adapter cannot cold-send', выводит её в /schedule list и (согласно политике) инкрементирует consecutiveFailures. Он никогда не завершается молча (no-op).

Непересекающиеся хранилища cron + гейт OD-8 (Fix #5)

Существуют два пути сохранения cron-задач, и они находятся на непересекающихся путях файловой системы, поэтому они никогда не могут читать или записывать одни и те же задачи:

  • Хранилище шлюза (новое): path.join(Storage.getGlobalQwenDir(), 'channels', 'cron.json') — глобальное для канала, находится на одном уровне с sessionsPath() (start.ts:56-58), принадлежит пользователю, вне рабочего дерева.
  • Хранилище сессий (существующее): cron для каждой сессии Session использует директорию с хешем для каждого проекта ~/.qwen/tmp/<hash>/scheduled_tasks.json (cronTasksFile.ts:1-9).

Поскольку пути не пересекаются, единственный способ, при котором долговременная задача может выполниться дважды, — это если сессия-тег (tag session) также запускает свой внутри-сессионный cron Session в дополнение к планировщику шлюза. OD-8 закрывает эту лазейку: планировщик шлюза является единственным владельцем cron; сессия, размещенная в канале («сессия-тег»), не запускает свой внутри-сессионный cron.

Механизм блокировки (gating) — как сессия узнает, что она является сессией-тегом. Сессия-тег создается с явным флагом, передаваемым от хоста канала:

  • В пути демона Phase-1+ DaemonChannelSessionFactory уже получает структурированный набор опций ({ workspaceCwd, modelServiceId, sessionScope }, DaemonChannelBridge.ts:226-241). Добавьте isTagSession: true в этот набор; демон Session считывает его при создании и пропускает startCronScheduler() (место вызова, которое в противном случае активировало бы cronQueue, Session.ts:667-668). Удаление (disposal) уже очищает cron при сборке (:790-803), поэтому сессия-тег просто никогда его не активирует.
  • В пути AcpBridge Phase-0 дочерний агент также не должен активировать внутри-сессионный cron для рабочего пространства тега; передайте тот же флаг через опцию запуска --acp (новое поле AcpBridgeOptions, пробрасываемое как флаг в Config). Пока этот механизм передачи флага не реализован, Phase 0 просто не регистрирует никаких внутри-сессионных cron-задач (команда /schedule обращается к хранилищу шлюза), поэтому нечему выполняться дважды.

Это сводит оставшийся риск исключительно к операционному: «не запускать оба планировщика для одних и тех же задач» — и механизм блокировки гарантирует, что сессия-тег никогда не запустит второй планировщик.

Схема долговременного хранилища и восстановление после перезапуска

Схема аналогична DurableCronTask (cronTasksFile.ts:19-26: id/cron/prompt/recurring/createdAt/lastFiredAt — поле называется cron, а не cronExpr):

interface ChannelCronJob { id: string; // randomUUID() channelName: string; target: { // mirrors SessionRouter PersistedEntry (SessionRouter.ts:5-9) channelName: string; senderId: string; // "__cron__" for system jobs chatId: string; // DingTalk openConversationId — the DURABLE cold-group id threadId?: string; }; cwd: string; // validated == bound workspace on load cron: string; // 5-field (parseCron) OR "@once:<epochMs>" prompt: string; label?: string; recurring: boolean; enabled: boolean; createdBy: string; // senderId; advisory under single-token model; carried into the fire's attribution createdAt: number; lastFiredAt: number | null; lastStatus?: 'ok' | 'error' | 'skipped'; lastError?: string; consecutiveFailures: number; // auto-disable after N (e.g. 5) }

Запись выполняется через atomicWriteJSON под Mutex из async-mutex для каждого файла. Восстановление после перезапуска в start.ts после router.restoreSessions() (:275/:444):

  1. bridge.start()restoreSessions() перезагружает sessions.json и вызывает bridge.loadSession() для каждой записи.
  2. store.load(); отбрасывает записи, у которых cwd !== boundWorkspace.
  3. scheduler.start(): вычисляет nextFireTime(job.cron, new Date()) для каждой включенной задачи. Политика пропущенных запусков (решение RFC): периодические задачи, просроченные во время простоя, выполняются один раз немедленно, а затем возобновляют работу — бэклог никогда не проигрывается повторно (лавина бэклога в живую группу — это инцидент со спамом). Одноразовые задачи в прошлом выполняются один раз, а затем удаляются. cronScheduler.ts различает { kind: 'catch-up'; ids } (периодические) и { kind: 'missed'; tasks } (одноразовые, требуют подтверждения) в :81-89,608-707; мы применяем объединение в одно выполнение для периодических.
  4. Устанавливает один setTimeout на ближайшую задачу; переустанавливает после каждого выполнения. Добавляет 60-секундный тик согласователя (прецедент: lockProbeTimer, cronScheduler.ts:229,507-538), пересчитывающий время от Date.now(), чтобы компенсировать рассинхронизацию часов при приостановке/возобновлении — интервалы никогда не накапливаются.

Путь выполнения: инъекция в ОБЩУЮ сессию группы (Fix #1 — самое важное)

Инвариант «один активный промпт на сессию» различается в зависимости от топологии, и dispatchProactive в v1 работал некорректно для пути демона:

  • Phase 0 (AcpBridge): AcpBridge.prompt() (:147-180) не имеет собственного гарда конкурентного доступа; единственная сериализация — это ChannelBase.sessionQueues/activePrompts (:29-35,394,466) и собственная ACP-сессия дочернего процесса --acp.
  • Phase 1+ (DaemonChannelBridge): DaemonChannelBridge.prompt() выбрасывает исключение Prompt already in flight, если activePrompts.has(sessionId) (:257-261) — он не ставит в очередь. FIFO-очередь promptQueue (bridge.ts:2855,3082) находится на стороне демона/acp-bridge, за этим внутрипроцессным гардом с выбрасыванием исключения. Поэтому вызов DaemonChannelBridge.prompt() во время активного обращения человека выбрасывает исключение, а не ждет.

Редизайн (корректный для обеих топологий): никогда не вызывать bridge.prompt(), пока обращение выполняется; сериализовать на уровне канала через sessionQueues, предварительно ожидая activePrompts. Поскольку sessionQueues выстраивает проактивный запуск после завершения предыдущего, к моменту вызова bridge.prompt() activePrompts.get(sessionId) уже очищен — поэтому на пути демона гард с исключением никогда не срабатывает, а на пути AcpBridge незащищенный prompt() также никогда не перекрывается.

// ChannelBase.ts — переиспользует приватные sessionQueues/activePrompts (:29-35). // Работает идентично для AcpBridge (Phase 0) и DaemonChannelBridge (Phase 1+): // цепочка гарантирует, что bridge.prompt() выполняется только после завершения предыдущего обращения, // поэтому исключение `Prompt already in flight` в DaemonChannelBridge (:257-261) не может быть выброшено. async dispatchProactive(sessionId: string, promptText: string): Promise<string> { const prev = this.sessionQueues.get(sessionId) ?? Promise.resolve(); const run = prev.then(async () => { const active = this.activePrompts.get(sessionId); if (active) await active.done; // дождаться завершения обращения человека — никогда не отменять через steer (:371-379) return this.bridge.prompt(sessionId, promptText); // только теперь activePrompts очищен }); this.sessionQueues.set(sessionId, run.then(() => {}, () => {})); return run; }

Инвариант: проактивное обращение никогда не может быть отменено последующим обращением человека и никогда не отменяет обращение человека. Обеспечение, сформулированное для обоих вариантов:

  • Нет отмены проактивного→человеческого: dispatchProactive никогда не вызывает steer/cancelSession. Он только awaitит activePrompts.get(sessionId)?.done, а затем ставится в очередь за ним.
  • Нет отмены человеческого→проактивного: профиль группы-тега — followup (никогда steer) (§6.1). Поскольку steer — это единственный dispatchMode, который вызывает bridge.cancelSession() (:371-379), а группы-теги никогда его не выбирают, входящее обращение человека может только выстроиться в цепочку за выполняющимся проактивным обращением через sessionQueues — оно не может его отменить. (На пути демона к DaemonChannelBridge.cancelSession (:332) можно попасть только из ветки steer, которая исключена для групп-тегов.)
  • Гард с исключением никогда не срабатывает: на обоих путях bridge.prompt() вызывается только в конце цепочки sessionQueues, после завершения предыдущего запуска и (для обращений человека) очистки activePrompts — поэтому исключение при перекрытии в DaemonChannelBridge (:257-261) структурно недостижимо для трафика тегов.

При выполнении (fire):

  1. Разрешение общей сессии через router.resolve(target.channelName, target.senderId, target.chatId, target.threadId, job.cwd) (SessionRouter.ts:72). 'thread' → один sessionId на всю группу, поэтому выполнение попадает в контекст, который видят люди. Если восстановленная сессия была удалена, resolve() создает и сохраняет новую.
  2. Постановка в очередь, без вытеснения (followup через sessionQueues). Намеренно не используется steer.
  3. Маркер + атрибуция (Fix #7). Префикс [Scheduled task "<label>" set by <createdBy>]\n. Идентификатор createdBy передается вместе с запуском в очереди, а не добавляется по временной метке позже, поэтому любой вызов инструмента/запрос разрешения во время этого выполнения атрибутируется этому проактивному обращению (§6.4).
  4. Перехват + отправка. dispatchProactive возвращает текст завершения; планировщик проверяет adapter.canColdSend, затем вызывает channel.pushProactive(target.chatId, text) (явный сбой, если false).

Холодная отправка в группу для DingTalk

Подтвержденное ограничение: DingtalkAdapter.sendMessage() отправляет сообщения только через sessionWebhook, кэшируемый для каждого conversationId (:84,134-142), который заполняется только при входящих сообщениях (:505-517). Холодная группа → молчаливый возврат (:137-141).

Исправление — pushProactive через API DingTalk 主动消息 群发 (контракт теперь ПОДТВЕРЖДЕН, OD-7 решен). Формат вызова также имеет прецедент в репозитории (emotionApi делает POST на api.dingtalk.com/v1.0/robot/... с заголовком x-acs-dingtalk-access-token и телом { robotCode, openConversationId, ... }, :188-197).

Подтвержденные эндпоинт и параметры (полные примечания к источникам см. в §6.5; уверенность указана для каждого пункта):

  • Эндпоинт: POST https://api.dingtalk.com/v1.0/robot/groupMessages/send (высокая уверенность; официальная документация по отправке + aliyun ask/559227).
  • robotCode (ОБЯЗАТЕЛЬНЫЙ, строка): идентификатор робота при установке его в группу; то же пространство значений, что и appKey для внутренних корпоративных роботов → используйте config.clientId (:184,435). Новые учетные данные не требуются. (высокая уверенность)
  • openConversationId (ОБЯЗАТЕЛЬНЫЙ, строка): открытый идентификатор беседы целевой группы с префиксом cid; коды ошибок miss.openConversationId/invalid.openConversationId подтверждают, что он обязателен и валидируется. Сохраняется в ChannelCronJob.target.chatId — стабилен при перезапусках, в отличие от sessionWebhook. (высокая уверенность)
  • msgKey (ОБЯЗАТЕЛЬНЫЙ, строка): ключ шаблона сообщения; 'sampleMarkdown' для markdown ('sampleText' для обычного текста). (высокая уверенность; документация по типам сообщений + aliyun ask/585232)
  • msgParam (ОБЯЗАТЕЛЬНЫЙ, JSON-кодированная строка, а не вложенный объект): для sampleMarkdown строка имеет вид "{\"title\":\"<заголовок превью>\",\"text\":\"<тело markdown, макс. ~5000 символов>\"}". (высокая уверенность; поля title/text для markdown из документации по типам сообщений, пример текста дословно из aliyun ask/585232)
  • coolAppCode (ОПЦИОНАЛЬНЫЙ): только если робот установлен как групповое крутое приложение (群聊酷应用); не требуется для обычного робота внутреннего корпоративного приложения. (средняя уверенность)
  • conversationId == openConversationId? Для стандартного группового @-callback считайте callback conversationId (с префиксом cid) напрямую пригодным в качестве openConversationId — это подтверждается источниками сообщества + совпадающим форматом cid. Отмечено (средняя уверенность): официальная документация не содержит дословного предложения, приравнивающего их для стандартного (не cool-app) робота. Гарантированный документацией путь — это API конвертации chatId → openConversationId (или получение его из API создания группы / chooseChat JSAPI / callback cool-app, который напрямую доставляет openConversationId+coolAppCode). Правило отката: если отправка возвращает invalid.openConversationId, используйте API конвертации chatId → openConversationId.
const GROUP_SEND = 'https://api.dingtalk.com/v1.0/robot/groupMessages/send'; // высокая уверенность async pushProactive(chatId: string, text: string): Promise<void> { // переопределение DingtalkAdapter const token = await this.tokenManager.get(); // обновляется независимо от жизненного цикла подключения SDK const robotCode = this.config.clientId; if (!token || !robotCode) { /* refresh once; else set lastError + return */ return; } for (const chunk of normalizeDingTalkMarkdown(text)) { // переиспользовать чанкер, ЕСЛИ лимит длины шаблона совпадает const msgParam = JSON.stringify({ title: extractTitle(text), text: chunk }); // msgParam — это СТРОКА await sendGroupMessage({ token, robotCode, openConversationId: chatId, msgKey: 'sampleMarkdown', msgParam }); // при invalid.openConversationId → конвертировать через chatId API, повторить } }

sendMessage() становится таким: сначала пытается использовать кэшированный sessionWebhook (дешево, не тратит токен); в противном случае откатывается к pushProactive(). Базовое значение по умолчанию pushProactive = (chatId, text) => this.sendMessage(chatId, text), поэтому Feishu не требует переопределения (FeishuAdapter.sendMessage() уже выполняет проактивные отправки на любой chatId со стабильным tenant_access_token, :622-676; canColdSend = true). DingTalk — единственный расходящийся адаптер — асимметрия DingTalk-first. Флаг canColdSend (выше) позволяет движку явно сообщать об ошибке для адаптера, работающего только в реактивном режиме, вместо молчаливого отбрасывания.

Жесткие ограничения развертывания (не код): корпоративный бот должен быть (a) опубликованным внутренним корпоративным ботом, (b) иметь выданное разрешение на проактивные групповые сообщения, (c) участником целевой группы (установленным через групповое cool app / внутреннее корпоративное приложение / стороннее приложение, с его robotCode) (высокая уверенность, что разрешение должно быть включено; высокая уверенность, что установка бота + robotCode являются обязательными условиями), (d) иметь записанный openConversationId. Мы сохраняем conversationId при первом появлении любого входящего сообщения от бота в группе, поэтому «холодная» = неактивная, а не никогда не виденная; по-настоящему никогда не виденная группа не может получить push, пока её openConversationId не будет получен через API конвертации (жесткое ограничение). Требуемое изменение адаптера: сегодня кэшируется только sessionWebhook (:516-517); мы также должны сохранять conversationId (рекомендуемое хранилище: отдельный ~/.qwen/channels/dingtalk-groups.json, не связанный с временем жизни сессии, чтобы холодные группы и cron без живой сессии были представимы).

ВСЁ ЕЩЁ ОТМЕЧЕНО (низкая уверенность) — оставить видимым согласно OD-7: (1) точный код/отображаемое имя точки разрешения для «проактивной отправки группового сообщения» в консоли 权限管理 приложения DingTalk не зафиксирован в документации — DingTalk показывает его в 权限管理 приложения как разрешение на отправку сообщений роботом (обычно семейство robot-message, например, qyapi_robot_sendmsg / 企业机器人发送消息权限); подтвердите в консоли, не делайте жестких утверждений о коде. (2) Авторитетное единственное официальное предложение, приравнивающее callback conversationId к openConversationId для стандартного (не cool-app) робота, не было найдено дословно в этой сессии — высоковероятное сокращение пути, но гарантированный документацией путь получения — это API конвертации chatId → openConversationId. Страницы открытой платформы DingTalk рендерятся через JS и не могли быть полностью проскраплены в этой сессии; факты об эндпоинте/параметрах/токене были перекрестно подтверждены через зеркало документации apifox и Q&A разработчиков Aliyun, цитирующие официальные примеры запросов.

Авторизация и жизненный цикл токена (подтверждено; критический риск реализации)

Заголовок авторизации (высокая уверенность). Все вызовы v1.0 (включая groupMessages/send) передают токен в заголовке запроса x-acs-dingtalk-access-token: <accessToken> плюс Content-Type: application/json — ровно тот же заголовок, который уже используют emotionApi() (:188-207) и downloadMedia() (media.ts:36-43).

Получение токена (высокая уверенность). Внутреннее корпоративное приложение, стиль v1.0: POST https://api.dingtalk.com/v1.0/oauth2/accessToken с JSON-телом {"appKey":"<appKey>","appSecret":"<appSecret>"}{ "accessToken": "...", "expireIn": 7200 }. (Устаревший эквивалент GET https://oapi.dingtalk.com/gettoken?appkey=..&appsecret=.. возвращает {access_token, expires_in:7200}, но этот устаревший токен предназначен для старых эндпоинтов oapi; для API api.dingtalk.com v1.0 используйте accessToken v1.0 в заголовке x-acs-dingtalk-access-token.)

Срок действия и кэширование (высокая уверенность). Токены истекают через 7200 с (~2 ч) и ДОЛЖНЫ быть повторно получены после истечения срока действия; в пределах окна валидности повторные запросы возвращают тот же токен и обновляют его. Кэшируйте для каждого приложения; не вызывайте эндпоинт токенов при каждом запросе (частые вызовы попадают под троттлинг).

Почему это критический риск. Stream SDK получает access_token один раз при подключении через GET .../gettoken внутри getEndpoint() (client.mjs:85-87) и никогда его не обновляет; getAccessToken() возвращает кэшированное значение (DingtalkAdapter.ts:172-174). autoReconnect повторно запрашивает его только при закрытии сокета (client.mjs:157-163) — стабильный долгоживущий сокет хранит устаревший токен после истечения ~2-часового TTL, и любая проактивная отправка (а также существующие пути emotion/media) молчаливо завершается ошибкой после его истечения. Функция проактивности должна сама управлять обновлением токена: tokenManager, который запрашивает токен через эндпоинт v1.0 oauth2/accessToken по таймеру (до истечения ~2 ч) и/или при получении 401, кэшируя его для каждого приложения независимо от жизненного цикла подключения SDK (OD-7). Это наиболее вероятная причина сбоя по принципу «работает на демо, умирает через 2 часа».

Ограничения частоты (подтверждено, смешанная уверенность — оставить отмеченным): (1) конкурентность серверного API для каждого приложения ~20 QPS в DingTalk Standard, с месячной квотой Open API ~10 000/мес (Professional ~500k, Dedicated ~5M) (средне-высокая). (2) Часто цитируемый лимит 20 сообщений/минуту → ~10-минутный троттлинг для каждого робота задокументирован для кастомных роботов групповых webhook; он обычно применяется как практическое руководство для пути отправки робота orgapp, но не был явно подтвержден на странице groupMessages/send в этой сессии — считайте точную цифру 20/мин для groupMessages/send низкой/средней уверенностью. Также: не вызывайте эндпоинт токенов слишком часто (отдельный троттлинг). Планировщик должен консервативно ограничивать частоту собственных отправок и делать откат при ответах о троттлинге.

Постоянные инструкции (повторяющиеся запросы на NL → хранилище → потребление)

Двухуровневый перехват в handleInbound() после прохождения гейтов (:240-252): явная команда /schedule "0 9 * * 1-5" post the open PR list (парсится с помощью parseCron, без обхода модели) и инструмент модели Phase-2 schedule_task(cron, prompt, recurring, label). Оба вызывают store.add({...}) → сохранение → scheduler.reschedule(job), затем отвечают в канал. /schedule list|cancel <id>|disable <id> читают/пишут в хранилище. Сохранение с отказом при ошибке (fail-closed): отказываться подтверждать /schedule, если запись выбрасывает исключение.

Режимы отказа

  • Шлюз недоступен во время выполнения: восстановление объединяет просроченные периодические выполнения в одно догоняющее; прошедшие одноразовые выполняются один раз, затем удаляются.
  • Сбой агента во время выполнения: bridge.prompt() отклоняется; attachDisconnectHandler (start.ts:241,403) пересоздает (Phase 0) / демон переподключается (Phase 1+). Планировщик устанавливает lastError, не проставляет lastFiredAt для периодических → будет повторено. Гарантия at-least-once; дедупликация по ключу выполнения, округленному до минут + lastFiredAt.
  • Сессия удалена (reaped) / loadSession завершается ошибкой: resolve() создает новую (история группы потеряна; постоянные инструкции должны быть самодостаточными). Память канала (§6.3) — это базовый уровень восстановления.
  • Адаптер не может выполнять холодную отправку (canColdSend=false): планировщик логирует + записывает lastError, выводится в /schedule list; никогда не молчит.
  • Холодная отправка в удаленную группу/группу с отозванными правами: не-2xx → lastError; invalid.openConversationId → попытка конвертации chatId → openConversationId + одна повторная попытка.
  • Истек срок действия токена: tokenManager обновляет один раз + делает backoff; consecutiveFailures ≥ N → автоотключение с видимой оператору записью.
  • Два шлюза в одном рабочем пространстве: checkDuplicateInstance() (start.ts:170-179) защищает от множественных экземпляров; дополнительно записывает токен блокировки в cron.json.

6.3 Память и обучение в контексте канала (Область сборки 3)

Тег должен запоминать группу с течением времени, не просачиваясь в соседние группы. Сегодня память qwen-code является глобальной для рабочего пространства (workspace): в ней нет оси чат/канал/группа/сессия.

Факты о топологии и зависимостях (Fix #3). Архитектуру определяют два жестких ограничения: (1) В топологии AcpBridge по умолчанию нет демона qwen serve и нет маршрута POST /workspace/memory — у дочернего процесса --acp нет HTTP-клиента; даже после миграции на демон в Phase-1+ маршрут памяти остается только для демона и со строгой аутентификацией (deps.mutate({ strict: true }), workspace-memory.ts:114). (2) @qwen-code/channel-base зависит только от @agentclientprotocol/sdk (packages/channels/base/package.json), а не от @qwen-code/qwen-code-core, поэтому ChannelBase не может выполнить import { writeWorkspaceContextFile }. Следовательно, в исправленном дизайне память канала записывается/читается внутри процесса через хелпер core, к которому channel-base обращается через коллбэки, внедряемые слоем CLI (packages/cli, который может зависеть от core) — не по HTTP и без добавления зависимости core в channel-base.

Текущее состояние: две области видимости, ни одна не привязана к разговору

POST /workspace/memory принимает только scope: 'workspace' | 'global' (workspace-memory.ts:118-125), разрешая путь через resolveContextFilePath() (writeContextFile.ts:223-240): workspace → <root>/QWEN.md, global → ~/.qwen/QWEN.md. В режиме добавления (append) данные складываются в раздел ## Qwen Added Memories (MEMORY_SECTION_HEADER, const.ts:29); файловый мьютекс с дедлайном 30 секунд сериализует записи (writeContextFile.ts:48-57,159-162); при добавлении в существующий файл размером > 16 МБ запись отклоняется (MAX_EXISTING_FILE_BYTES, :255). Маршрут использует строгую аутентификацию (deps.mutate({ strict: true }), :114) — он отклоняет запросы даже на loopback без токена. Следствие: все группы в одном рабочем пространстве используют один общий файл QWEN.md.

Дизайн: область памяти channel с ключом (channelName, chatId)

Единицей изоляции является цель маршрутизации (routing target), а не сессия (сессии удаляются при простое, DEFAULT_SESSION_IDLE_TIMEOUT_MS 30 мин, run-qwen-serve.ts:94). Ключ уже существует: SessionTarget { channelName, senderId, chatId, threadId } (types.ts:88-93). Для памяти группы ключом служит (channelName, chatId).

Структура хранилища повторяет существующее дерево ~/.qwen/channels/:

~/.qwen/channels/ sessions.json memory/ <channelName>/ # санитизация: отклонять /, .., NUL <hash(chatId)>/ # sha256(chatId).slice(0,16) — безопасно для путей, без коллизий/выхода за пределы QWEN.md # "обучение с течением времени" в контексте группы meta.json # { channelName, chatId, displayName?, createdAt, lastWriteAt }

Имя файла учитывает getCurrentGeminiMdFilename() (const.ts:49). Это удерживает память канала вне рабочего дерева, вне привязанного рабочего пространства и вне пути иерархического обнаружения QWEN.md (чтобы она никогда не просочилась между группами).

Путь записи (расширяем хелпер core, не создаем его форк)

В packages/core/src/memory/writeContextFile.ts:

  • Расширяем WriteContextFileScope (:80), добавляя 'channel' к 'workspace' | 'global'.
  • Расширяем WriteContextFileOptions (:83-97), добавляя channelKey?: { channelName: string; chatId: string }; проверяем его наличие, когда scope === 'channel' (аналогично защите абсолютного пути :142-146). projectRoot остается обязательным в интерфейсе — передаем config.cwd, даже если он не используется для области channel.
  • В resolveContextFilePath() (:223-240) добавляем ветку channel, возвращающую path.join(Storage.getGlobalQwenDir(), 'channels', 'memory', sanitize(channelName), hash(chatId), getCurrentGeminiMdFilename()). Текущая сигнатура функции — (scope, projectRoot) — в нее нужно добавить параметр channelKey (приватная функция, локальное изменение). Файловый мьютекс использует в качестве ключа разрешенный путь, поэтому две группы могут писать параллельно без конфликтов.

Точное изменение ChannelBaseOptions + кто его внедряет (Fix #3). channel-base не может импортировать core, поэтому слой CLI предоставляет чтение/запись в виде коллбэков. Расширяем объект опций (ChannelBase.ts:9-12 — реальный интерфейс сегодня это просто { router?: SessionRouter; proxy?: string }; config и bridge — это позиционные аргументы конструктора в :40-46, а не члены объекта). Объект уже содержит router:

// packages/channels/base/src/ChannelBase.ts — ChannelBaseOptions (БЕЗ новых зависимостей от core) export interface ChannelBaseOptions { // ...существующие члены: router?: SessionRouter; proxy?: string /** Читает дистиллированную память этого канала; null, если ее еще нет. Внедряется слоем CLI. */ readChannelMemory?: (target: SessionTarget) => Promise<string | null>; /** Добавляет/заменяет память этого канала. Внедряется слоем CLI. */ writeChannelMemory?: ( target: SessionTarget, content: string, mode: 'append' | 'replace', ) => Promise<void>; }

Кто создает и внедряет их: packages/cli/src/commands/channel/start.ts (который зависит от core). Когда start.ts формирует объект опций для каждого адаптера, он замыкает writeWorkspaceContextFile/хелпер чтения из core и разрешает доверенный сервером (channelName, chatId) из router.getTarget(sessionId) (SessionRouter.ts:94) — адаптер никогда не получает chatId из сети:

// packages/cli/src/commands/channel/start.ts — слой CLI (МОЖЕТ зависеть от core) import { writeWorkspaceContextFile, readChannelContextFile, } from '@qwen-code/qwen-code-core'; const baseOpts: ChannelBaseOptions = { router, // config и bridge — позиционные аргументы createChannel(name, config, bridge, baseOpts), а не члены объекта readChannelMemory: (target) => readChannelContextFile({ channelKey: { channelName: target.channelName, chatId: target.chatId }, }), writeChannelMemory: (target, content, mode) => writeWorkspaceContextFile({ scope: 'channel', channelKey: { channelName: target.channelName, chatId: target.chatId }, mode, content, projectRoot: config.cwd, // projectRoot не используется для области channel, но требуется интерфейсом }), }; // адаптер создается позиционно, объект опций передается последним: plugin.createChannel(name, config, bridge, baseOpts)

Адаптер никогда не обращается к файловой системе, а channel-base не получает новых зависимостей. (Альтернатива для демона в Phase-2: маршрутизированный путь POST /channel/:sessionId/memory, который разрешает channelKey на стороне сервера; он не может переиспользовать POST /workspace/memory, который жестко валидирует scope ∈ {workspace, global} и передает фиксированный projectRoot, :118-125,185-190. Откладываем до тех пор, пока проактивному движку не понадобятся поиски sessionId → target на стороне демона.)

Трансляция событий (fan-out). publishWorkspaceEvent находится на стороне демона в AcpSessionBridge (bridge.ts:3610), а не на стороне канала. В AcpBridge (Phase 0) нет события memory_changed (оно и не нужно — один процесс владеет и записью, и чтением). В топологии с демоном publishWorkspaceEvent транслируется в каждую активную шину сессий без разбора (bridge.ts:3649-3675); BridgeEvent.data имеет произвольную форму (eventBus.ts:51), поэтому событие memory_changed может нести { scope:'channel', channelName, chatId }, но требуется фильтрация на стороне подписчика — издатель не может ограничить доставку.

Путь чтения (память → промпт) — одноразовая за сессию инициализация с переиспользованием instructedSessions

Расширяем блок instructions, выполняемый один раз за сессию (ChannelBase.ts:343-347, управляется instructedSessions): при первом сообщении сессии, цель которой имеет (channelName, chatId), вызываем внедренный readChannelMemory(target) и добавляем его результат перед config.instructions, затем помечаем сессию в instructedSessions точно так же, как сегодня. Поскольку область 'thread' использует один sessionId, это загружает память один раз за время жизни сессии (тот же барьер, который уже предотвращает повторное внедрение config.instructions). Зависимость от core не добавляется — чтение идет через внедренный коллбэк. Память канала никогда не находится на пути иерархического обнаружения; она внедряется для каждой сессии через этот хук.

// ChannelBase.handleInbound() — инициализация первого хода (переиспользует instructedSessions) if (!this.instructedSessions.has(sessionId)) { const parts: string[] = []; if (this.options.readChannelMemory) { const mem = await this.options.readChannelMemory(target); // target из router.getTarget(sessionId) if (mem) parts.push(mem); } if (config.instructions) parts.push(config.instructions); if (parts.length) promptText = `${parts.join('\n\n')}\n\n${promptText}`; this.instructedSessions.add(sessionId); }

Связь с сохранением/восстановлением SessionRouter и транскриптом

СлойЧто сохраняетВремя жизниВладелец
Транскрипт сессииХоды разговора ACPДо удаления / /clear confirm / перезапускаSession (агент)
Сохранение SessionRouterkey → { sessionId, target, cwd } (:5-9,224-244)Переживает перезапуск моста через loadSession()SessionRouter (sessions.json)
Память канала (новая)Дистиллированные долговечные факты о группеБессрочно~/.qwen/channels/memory/

Когда restoreSessions() не может перезагрузить сессию (:196), транскрипт теряется, но групповой QWEN.md остается нетронутым — инициализирующее чтение восстанавливает знания агента при следующем сообщении. Память канала — это базовый уровень восстановления для транскрипта. “Обучение с течением времени” — это цикл дистилляции, а не прямое сохранение транскрипта: агент (или запускаемая задача) периодически суммирует важные факты в групповой QWEN.md в режиме добавления.

Изоляция, размер и поэтапность

Изоляция обеспечивается на уровне путей (sales и eng разрешаются в разные директории/файлы/мьютексы с hash(chatId)), пока путь записи всегда несет доверенный сервером chatId. Это изоляция контента, а не граница аутентификации (процесс по-прежнему имеет один глобальный токен, без идентификации на уровне пользователя). Для жесткой изоляции тенантов запускайте по одному процессу на рабочее пространство/тенант (OD-2).

Ограничения размера (переиспользуем существующий механизм): лимит 16 МБ для существующего файла при добавлении наследуется бесплатно (маппим WorkspaceMemoryFileTooLargeError в понятное пользователю “память группы переполнена, запустите проход уплотнения”); маршрут Phase-2 переиспользует лимит 1 МБ на одну запись (MAX_MEMORY_CONTENT_BYTES, workspace-memory.ts:79); уплотнение в режиме замены (writeContextFile.ts:202-211) — это долгосрочное решение проблемы неограниченного роста.

  • Phase 0/1: добавляем область channel + channelKey в writeContextFile.ts; выпускаем ~/.qwen/channels/memory/ + meta.json; связываем коллбэки readChannelMemory/writeChannelMemory слоя CLI через ChannelBaseOptions и инициализирующее чтение, описанное выше. Никаких новых HTTP-маршрутов, никаких зависимостей channel-base → core.
  • Phase 2: добавляем маршрутизированный путь POST /channel/:sessionId/memory (топология с демоном) и memory_changed с фильтрацией на стороне подписчика; добавляем триггер дистилляции и CLI qwen channel memory <name> <chatId>. Ограничение дистилляции: cron привязан к сессии и умирает при dispose() (Session.ts:791,799-803,1056); дистилляция должна срабатывать, пока сессия активна — по завершении хода, по явному /remember или в “разогретой” сессии — никогда из независимого фонового планировщика.

6.4 Управление: бюджеты токенов и журнал аудита (Область сборки 4)

Агенту, работающему в канале, которым может управлять любой участник и который может действовать проактивно, необходимы лимиты расходов, журнал аудита, фиксирующий, кто и что запросил, и изоляция на уровне идентичности. qwen-code предоставляет три из четырех примитивов: rate-limit.ts (бакеты токенов на ключ), кольцевой буфер permission-audit.ts и MultiClientPermissionMediator. Эта область объединяет их и заполняет пробелы (бюджет стоимости отсутствует где-либо; ни одна строка аудита не содержит человеческого отправителя). Руководящий принцип: отклонять, а не обрезать — но, согласно Fix #6, оценочный бюджет никогда жестко не отклоняет пользовательский промпт; он только выдает WARN.

Какой процесс отвечает за управление?

РазвертываниеМостКакая инфраструктура serve/ доступна
Phase 0 — qwen channel start / AcpBridgeзапускает свой собственный дочерний процесс --acp stdio (start.ts:213,356)Никакой. Нет сервера Express, нет rate-limit.ts, нет HTTP-маршрутов, нет кольцевого буфера permission-audit.ts.
Phase 1+ — qwen serve + DaemonChannelBridgeканалы размещаются в демонеВся инфраструктура serve/: реальное использование, медиатор, rate-limit, кольцевой буфер аудита, маршруты.

Решение: допуск по бюджету + отклонение живут в @qwen-code/channel-base (общая точка контроля ChannelBase.handleInbound()), в новом packages/channels/base/src/BudgetLedger.tsне в serve/budget.ts, потому что процесс канала Phase-0 никогда не загружает serve/, а слой канала — это единственное место с контекстом человеческого отправителя. Аудит + атрибуция также берут начало в слое канала. На пути демона Phase-1+ реестр читает реальное использование и дополнительно предоставляется через маршрут; на пути Phase-0 он оценивает и предоставляется через команду канала (/audit).

Где управление подключается сегодня (и пробелы)

ЗадачаСуществующий механизмПробел
Ограничение частоты запросовбакеты токенов на (clientId|ip), 3 уровня (rate-limit.ts)Нет токенов/стоимости, только количество запросов; только в serve/
Журнал решений постфактумограниченный FIFO-кольцевой буфер, 5 типов записей (permission-audit.ts)Нет человеческого senderId, только clientId; нет GET-маршрута; кольцевой буфер удерживается замыканием (:17-25)
Реальное одобрение на действиечетыре политики + кворум консенсуса (permissionMediator.ts:621-637)Голоса привязаны к clientId, а не к человеку; один канал = один клиент
Область инструментов/данных на каналcoreTools/allowedTools/excludeTools (config.ts:727-729); getPermissionsAllow() (:3158); getPermissionsDeny() (:3182); фильтр разрешений MCP (:3327-3333)Область привязана к Config/процессу; нет пути через аргументы spawn в дочерний процесс --acp

Два структурных факта: (1) у демона нет человеческой идентичности (BridgeEvent.originatorClientId, каждый PermissionVote.clientId — это транспортные идентификаторы; senderName сохраняется только до SenderGate.check()), поэтому любая корреляция человек↦clientIdsessionId должна устанавливаться на границе канала; (2) аутентификация и rate-limit глобальны для демона (один bearer-токен auth.ts:259-266; rate-limit с ключом (clientId, ip)), поэтому управление на уровне канала должно начинаться в адаптере.

Бюджеты токенов и стоимости — новый BudgetLedger, рекомендательный до появления реального использования (Fix #6)

Откуда берется использование — оговорка (OD-9). Бюджет токенов может списывать реальные числа только после того, как модель сообщит об использовании. Внутри сессии Session.#recordPromptTokenCount() (Session.ts:2078-2087) сохраняет usageMetadata.promptTokenCount в lastPromptTokenCount, перезаписывая каждый ход — это не накопительный счетчик биллинга. На пути AcpBridge Phase-0 поток ACP session/update не несет usageMetadata, поэтому v1 не может списывать реальные количества токенов там. На пути демона Phase-1+ демон наблюдает использование внутри процесса и может списывать точно.

Правило применения (Fix #6 — критически важное):

  • Оценочные бюджеты носят ТОЛЬКО РЕКОМЕНДАТЕЛЬНЫЙ характер. Когда единственное доступное число — это оценка на стороне канала (количество символов промпта+ответа ÷ константа символов на токен), реестр выдает WARN/алерт при достижении порогов и может прикрепить предупреждение к ответу — он никогда жестко не отклоняет пользовательский промпт. Ложноположительная оценка не должна блокировать реальный запрос пользователя.
  • Жесткое отклонение ТОЛЬКО по реальным числам. Бюджет может отклонить промпт (отклонить, а не обрезать) только тогда, когда источник списания — это реальный путь использования демона (размещенный в демоне Phase-1+). До этого бюджет — это наблюдаемость + алертинг, а не шлюз.

Это делает бюджет v1 честным: он заранее предупреждает везде и применяет жесткие лимиты ровно там, где числа заслуживают доверия.

Модуль BudgetLedger.ts, смоделированный по образу rate-limit.ts (фабрика, Map бакетов с GC, при переполнении — fail-open):

export type BudgetUnit = 'tokens' | 'usd'; // 'usd' = токены × ставка для конкретной модели export type UsageSource = 'estimate' | 'daemon'; // 'estimate' => рекомендательный; 'daemon' => может жестко отклонять export interface BudgetLedger { // allowed=false только когда source==='daemon'; оценки возвращают allowed=true + флаги предупреждения admit(key: string): { allowed: boolean; spent: number; limit: number; advisory: boolean; }; debit( key: string, amount: number, unit: BudgetUnit, source: UsageSource, ): void; // вызывает алерты при достижении порогов snapshot(): Record< string, { spent: number; limit: number; ratio: number; source: UsageSource } >; reset(): void; dispose(): void; }
  • Семантика наследования по умолчанию + сводка org по принципу “строжайший побеждает” (OD-9). admit(key) разрешает эффективное окно с фоллбэком в стиле GroupGate: channel → '*' → built-in. Промпт должен пройти оба окна: для канала и сводку “org” для процесса (строжайший побеждает, списание с обоих). “org” = сводка этого единственного процесса; настоящий межпроцессный лимит org требует общего хранилища (вне скобок). Фиксированное дневное окно.
  • Алерты 75%/95%. debit() вызывает onAlert один раз на порог на окно, используя идиому гистерезиса шины событий (WARN_THRESHOLD_RATIO/WARN_RESET_RATIO, eventBus.ts:101-103). Отправка алерта — это проактивная отправка — жесткая зависимость от Области сборки 2 (оговорка о «холодной» группе DingTalk; Feishu отправляет свободно). Деградирует до “прикрепить предупреждение к следующему ответу”, если проактивный канал отсутствует.
  • Отклонить, а не обрезать (только когда source==='daemon'). Проверяется при допуске, до bridge.prompt() (:425). При реальном использовании и !allowed адаптер вызывает sendMessage(chatId, refusal) и возвращается — он не входит в путь steer/cancel, поэтому промпт в полете завершается, а следующий отклоняется. При оценке allowed всегда true (рекомендательный).
  • Стоимость (usd) умножает токены на таблицу ставок для каждой модели, предоставленную оператором (qwen-code мультимодельный; единой цены нет). Отсутствующая запись → фоллбэк на tokens + одноразовое предупреждение.
  • Конфигурация. ChannelConfig (types.ts:27-51) получает budget?: { unit; limit; windowMs; reset? }, парсится через parseChannelConfig. На пути демона ServeOptions получает --budget-org-daily/--budget-unit, а daemon-status.ts (который уже сообщает rateLimit, :295-297) получает параллельный блок budget.

Audit log — человеческий senderId, передаваемый вместе с turn (Fix #7)

PermissionAuditRing (permission-audit.ts:128-172, FIFO 512) — подходящая основа, но каждая строка привязана к clientId. Дизайн — привязка sender↦turn на стороне канала (RequestAttributionRing.ts, та же FIFO-структура).

Наивное соединение по временной метке некорректно в режиме followup (Fix #7). В v1 предлагалось соединять строку разрешения с «самой свежей строкой атрибуции для данного sessionId, чья recordedAtMs предшествует issuedAtMs разрешения». В режиме followup несколько отправителей встают в очередь для одного sessionId через sessionQueues; отправитель, поставленный в очередь последним, часто не является тем, чей turn выполняется в момент срабатывания tool-call/permission. Поэтому соединение по временной метке систематически присваивает атрибуты неверно.

Исправление: передавать senderId ВМЕСТЕ с промптом в очереди. Когда handleInbound() ставит промпт в очередь sessionQueues (и когда планировщик ставит в очередь проактивное срабатывание), элемент очереди / синтетический контекст turn несет свой собственный { senderId, senderName, requestSeq }. Атрибуция для любого tool-call/permission, возникшего во время turn, считывается из текущего выполняемого turn (головы FIFO), а не из сканирования временных меток. Конкретно: цепочка sessionQueues устанавливает атрибуцию для каждого turn currentTurnAttribution.set(sessionId, {senderId, ...}) в момент, когда выполнение достигает головы (прямо перед bridge.prompt()), и очищает её при завершении выполнения; строки audit читают эту карту. Проактивные срабатывания устанавливают createdBy тем же способом (§6.2 шаг 3). Это точно для выполняемого turn и не зависит от порядка постановки в очередь.

Добавьте шестой тип строки task.requested { sessionId, senderId, channelName, chatId, promptDigest, requestedAtMs } при допуске (admission), чтобы audit отвечал на вопрос «кто начал эту задачу» даже для работы только на чтение. Объединение PermissionAuditEntry (:57-104) закрыто, и потребители переключаются по kind, поэтому его расширение (или добавление соседнего ring) затрагивает каждого потребителя.

Путь запроса. Phase-1+ daemon: добавьте GET /workspace/audit (bearer + строгий createMutationGate, auth.ts:356), предоставляя ring из замыкания bridge (документация в заголовке файла предусматривает это, :22-25). Phase-0 AcpBridge: команда канала /audit через sendMessage. Долговечность: ring содержит 512 записей в памяти и теряется при перезапуске — известное ограничение v1; последующая задача (OD-11) сохраняет append-only объединенный audit в ~/.qwen.

Голосующие в консенсусе — не люди. votersAtIssue — это clientId, проставленные daemon, а один канал = один clientId, поэтому «консенсус» из коробки в группе DingTalk — это консенсус между клиентами daemon. Голосование на уровне людей требует реестра зарегистрированных утверждающих, сопоставляющего senderId → отдельный голос — это требование OD-3 Phase-2, а не реализованная функция.

Изоляция инструментов и данных по идентичности

  1. Разрешение/запрет инструментов по каналам. Config поддерживает coreTools/allowedTools/excludeTools (:727-729), доступные через getPermissionsAllow()/getPermissionsDeny()/getCoreTools(). (Методов getAllowedTools()/getBlockedTools() нет.) В Phase 0 путь AcpBridge создает дочерний процесс для каждого канала, но AcpBridgeOptions несет только { cliEntryPath, cwd, model } (:17-21), а start() передает только --acp+--model (:56-63). Для реализации области действия по каналам требуются НОВЫЕ поля AcpBridgeOptions, НОВЫЕ флаги --acp в Config, а также новые поля ChannelConfig. В пути daemon Phase-1+ на каждый daemon приходится один Config, поэтому область действия ограничена daemon (рабочим пространством, OD-2), а не дочерним процессом канала.
  2. Область действия MCP по каналам. Config.getMcpServers() фильтрует по allowedMcpServers (:3327-3333), заданным при создании. Добавьте allowMcpServers?: string[] в ChannelConfig, передав их по тому же пути аргументов spawn (или в массив mcpServers, который передает AcpBridge.newSession() — жестко заданный [] в :133).
  3. sessionScope как граница данных. 'thread' заставляет группу использовать одно общее рабочее дерево/контекст; межканальная изоляция обеспечивается ключами маршрутизации с пространством имен channelName. Изоляция по отправителям внутри группы 'thread' не предусмотрена дизайном.

Честное ограничение: аутентификация использует единый глобальный для daemon токен без принципов для каждого пользователя, поэтому изоляция работает на уровне канала, а не человека. Для настоящей изоляции инструментов по пользователям требуется Phase-3.

Путь допуска

Входящее сообщение DingTalk → ChannelBase.handleInbound() 1. GroupGate.check() + SenderGate.check() [существующее :240-252] 2. budget.admit('channel:<name>') && budget.admit('org') [НОВОЕ] ↳ source==='daemon' && !allowed: sendMessage(refusal); return (НЕ в steer/cancel) ↳ source==='estimate': allowed всегда true → только WARN (Fix #6) 3. постановка в sessionQueues С {senderId, senderName, requestSeq} [НОВОЕ — Fix #7] + строка task.requested 4. в голове FIFO, установка currentTurnAttribution → bridge.prompt(...) [существующее :425] ↳ tool call → permission (авто-одобрение в AcpBridge Phase 0; медиатор в daemon Phase 1+) ↳ строка audit читает currentTurnAttribution[sessionId] (ВЫПОЛНЯЕМЫЙ turn) 5. по завершении: использование известно (daemon) или оценено (AcpBridge) → budget.debit(..., source) [НОВОЕ] ↳ публикация оповещения 75%/95% проактивна → зависит от Build Area 2

Жесткие зависимости, которые стоит отметить: (1) реальное списание токенов (и, следовательно, жесткий отказ) требует пути использования daemon Phase-1+ — до этого бюджеты носят рекомендательный характер (Fix #6); (2) проактивные оповещения о бюджете требуют Build Area 2; (3) голосование на уровне людей и атрибуция audit на уровне людей требуют реестра зарегистрированных утверждающих OD-3.

6.5 Платформа DingTalk (основная) + доработка Feishu

Примечание по интеграции (утвержденная архитектура). Phase 0: qwen channel start создает AcpBridge (start.ts:213,350; AcpBridge.ts:38), который запускает node <cli> --acp и предоставляет newSession(cwd)/loadSession(sessionId, cwd) (:131,137); область действия сессии контролируется SessionRouter, а не bridge. Phase 1+: каналы размещаются под qwen serve через DaemonChannelBridge (его значения по умолчанию для 'thread' в :229,240; его исключение при перекрытии в :257-261). Миграция утверждена и не является опциональной (§1).

Проблема истечения срока действия sessionWebhook

В режиме DingTalk Stream каждое входящее сообщение доставляется с короткоживущим sessionWebhook; адаптер кэширует его по ключу conversationId (:84, заполняется в onMessage() :517), а sendMessage() (:134-170) ищет его, логируя No webhook for chatId и молча возвращая управление, если его нет (:137-141). Два фатальных факта для проактивного использования: (1) срок действия webhook истекает (тип SDK RobotMessageBase содержит sessionWebhookExpiredTime, constants.d.ts:13, но интерфейс DingTalkMessageData адаптера опускает его и никогда не читает — кэшированный webhook может устареть даже внутри активного окна); (2) карта заполняется только входящим трафиком, поэтому для «холодной» группы записи нет.

Отправка в холодную группу через API проактивных сообщений робота (主动消息) — ПРОВЕРЕНО (OD-7)

Исправление — это API проактивных сообщений бота DingTalk — POST https://api.dingtalk.com/v1.0/robot/groupMessages/send (эндпоинт проверен, высокая уверенность). В отличие от webhook, он адресуется через долговечный openConversationId (проверено, высокая уверенность), аутентифицируется с помощью заголовка x-acs-dingtalk-access-token (проверено, высокая уверенность — уже используется в emotionApi() :188-207 и downloadMedia() media.ts:36-43) и несет robotCode бота (проверено, высокая уверенность; = config.clientId, :184,435). Тело представляет собой пару msgKey/msgParam (проверено, высокая уверенность), где msgParam — это сама по себе JSON-кодированная строка (а не вложенный объект), например, для msgKey:'sampleMarkdown':

{ "robotCode": "ding...", // = config.clientId "openConversationId": "cid6KeBBLov...", // долговечный id группы (из входящего conversationId; конвертировать, если недействителен) "msgKey": "sampleMarkdown", "msgParam": "{\"title\":\"<preview title>\",\"text\":\"# hi\\n...markdown ≤ ~5000 chars\"}", }

Это новый метод наряду с sendMessage(), а не его изменение (набросок в §6.2). ChannelBase.sendMessage() остается абстрактным (:81); проактивному движку требуется новый исходящий шов pushProactive?(target, text) — совершенно новый и центральный результат для платформы. проверено [высокая уверенность] согласно официальной документации send + aliyun ask/559227, ask/585232 + документации по типам сообщений для формы эндпоинта/параметров/msgParam.

Необходимое разрешение: разрешение робота/сообщения «отправка проактивного сообщения в групповой чат» должно быть предоставлено внутреннему корпоративному приложению перед тем, как groupMessages/send заработает (документация send перечисляет это необходимое условие) (проверено, высокая уверенность, что разрешение должно быть включено). ВСЕ ЕЩЕ ОТМЕЧЕНО (низкая уверенность): точное отображаемое имя/код точки разрешения не зафиксировано по документации в этой сессии — консоль DingTalk показывает его в разделе 权限管理 приложения как разрешение на отправку сообщений роботом (обычно семейство robot-message, например, qyapi_robot_sendmsg / 企业机器人发送消息权限); подтвердите в консоли, не делайте жестких утверждений о коде. Адаптер должен логировать resp.status + тело при !resp.ok/throw — текущий пустой catch в emotionApi (:214-216) — это антипаттерн, который скроет неправильную конфигурацию из-за отсутствия разрешения.

Получение и сохранение openConversationId

Два источника: (1) сбор из входящих — каждое сообщение несет conversationId (:506), который передается как openConversationId в emotion API (:197); сохраняйте его в тот момент, когда видите. проверено [средняя уверенность] согласно aliyun ask/559227, ask/585233 + соответствующему формату 'cid', что callback conversationId (с префиксом cid) можно использовать напрямую как openConversationId для стандартного группового @-callback. ВСЕ ЕЩЕ ОТМЕЧЕНО: нет официальной дословной фразы, приравнивающей их для робота не cool-app; гарантированный документацией путь получения — это API конвертации chatId → openConversationId (obtain-group-openconversationid), или получение из API создания группы / chooseChat JSAPI, или callback cool-app (который доставляет openConversationId+coolAppCode напрямую). Резервный вариант: при invalid.openConversationId конвертируйте через API chatId и повторите попытку. (2) события bot-added-to-group через registerAllEventListener (client.mjs:58-61): события текут onEvent → onEventReceived при стандартном topic:'*' (client.mjs:14-19,241-254), тогда как адаптер устанавливает только callback робота (:107), поэтому события org/bot в настоящее время принимаются и попадают в no-op по умолчанию (client.mjs:35-37). Тема события и поле openConversationId во время установки не проверены — не хардкодьте имя события.

Сохранение. Используйте отдельное хранилище ~/.qwen/channels/dingtalk-groups.json, а не цель SessionRouter: ID группы должен переживать любую сессию (проактивная отправка в холодную группу по cron срабатывает без живой сессии), а PersistedEntry существует только после создания сессии для ключа маршрутизации — связывание идентичности группы с временем жизни сессии оставляет холодные группы непредставленными.

Многопользовательская область действия включается явно, а не по умолчанию

Область действия 'thread' (:53) — это то, что дает один общий агент на группу, но parseChannelConfig() по умолчанию устанавливает sessionScope в 'user' (config-utils.ts:91-92), что дает сессии для каждого участника. Оператор должен явно установить sessionScope: 'thread'. При установке применяются два многопользовательских следствия: (a) стандартный dispatchMode: 'steer' отменяет текущую работу, когда любой участник отправляет сообщение (:371-379) — профиль тега устанавливает 'followup' (§6.1); (b) пробел в атрибуции отправителя (§6.1).

Парсинг входящих @

Групповые ограничения работают: GroupGate использует envelope.isMentioned, устанавливаемый из data.isInAtList (:520). Очистка текста удаляет только первый @token (:527-529), позиционно, а не по идентичности — @qwen @alice корректно, но упоминание человека первым удалит упоминание человека. Последующее усиление будет удалять по собственному chatbotUserId бота. Контекст ответа/цитаты извлекается (extractQuotedContext(), :272-298), при этом isReplyToBot вычисляется относительно chatbotUserId (:280,292), а referencedText внедряется как [Replying to: "…"] (ChannelBase.ts:317-319). Атрибуция отправителя закрыта в §6.1 через префикс [senderName].

Рендеринг Markdown / карточек

markdown.ts уже выполняет нормализацию платформы, которую повторно использует проактивный путь: таблицы → pipe-текст (convertTables(), :44-80), разбивка на части по 3800 символов с балансировкой fence (splitChunks(), :84-188; CHUNK_LIMIT=3800, :10), извлечение заголовка, обрезанное до 20 символов, с резервным вариантом 'Reply' (extractTitle(), :190-195). Повторное использование условно тем, что шаблон sampleMarkdown принимает тот же поднабор markdown и тело до ~5000 символов (проверено, высокая уверенность — документация по типам сообщений); сохраняйте CHUNK_LIMIT ≤ этого бюджета. Потоковые интерактивные карточки (путь TOPIC_CARD, constants.d.ts:4) — аналог потоковой карточки Feishu — выходят за рамки основного этапа; проактивность v1 основана на markdown-сообщениях.

Доработка Feishu (кратко)

Feishu опережает именно в том аспекте, который важен: проактивная отправка является нативной (sendMessage(chatId, text) в любой chat_id, :622-676 — нет проблемы холодной группы; canColdSend = true), стабильный tenant_access_token с отслеживаемым по сроку действия обновлением (refreshToken(), :581-620 — то, что еще предстоит сделать DingTalk), гибкая подписка на события (WebSocket или HMAC webhook, :146-176) и первоклассные потоковые карточки (markdown.ts, :742-792). Но общие проблемы ChannelBase/SessionRouter — явное включение области действия 'thread', отмена dispatchMode, отсутствующая атрибуция отправителя, новый исходящий шов — в равной степени применимы к Feishu. Feishu решает доступность, а не кто-что-сказал или один-участник-отменяет-другого. Перенос проактивного движка на Feishu напрямую переиспользует существующий sendMessage() (базовый pushProactive по умолчанию); единственная новая работа для платформы — это сопоставление целевой группы движка с сохраненным chat_id и опциональная маршрутизация через путь потоковых карточек.


7. Поэтапное внедрение (Phase 0–2) и MVP

Каждый этап можно мержить независимо, он завершается демонстрационной версией и ограничивается явными критериями приемки. Phase 0 заставляет существующий стек вести себя как общий резидентный агент — конфигурация плюс несколько небольших изменений в коде, на базе AcpBridge. Phase 1 мигрирует хостинг каналов в qwen serve (утвержденная архитектура) и добавляет проактивный движок и единый замкнутый цикл MVP. Phase 2 добавляет память канала, бюджеты и audit.

Топология: утвержденная миграция daemon (ранее OD-1)

Решение принято, а не ожидается: Phase 0 поставляется на AcpBridge; Phase 1+ запускает каналы под qwen serve (через DaemonChannelBridge или runner каналов daemon), потому что сохранение памяти для каждой комнаты, медиатор разрешений, audit шины событий, FIFO promptQueue и маршруты запросов бюджета/audit требуют daemon. Планировщик, принадлежащий шлюзу (§6.2), нейтрален к миграции — он сериализуется через ChannelBase.sessionQueues независимо от bridge — поэтому он поставляется в Phase 1 и не зависит от переключения. Интеграция Phase 0 добавляет путь подключения DaemonChannelBridge (или флаг --daemon <url>), чтобы миграция была шагом конфигурации на границе Phase-1, а не переписыванием. Обратите внимание на острую грань, вокруг которой спроектирован планировщик: DaemonChannelBridge.prompt() не ставит в очередь — он выбрасывает Prompt already in flight при перекрытии (:257-261); daemon FIFO promptQueue находится на стороне acp-bridge (bridge.ts:2855,3082); сериализация на стороне канала — это ChannelBase.sessionQueues (:394), поэтому проактивный движок никогда не вызывает prompt(), пока turn активен (§6.2, Fix #1).

Phase 0 — Конфигурация + внедрение идентичности (на AcpBridge)

Цель. Группа DingTalk, где любой участник упоминает бота через @, каждый участник использует одну общую сессию, агент знает, кто говорит, и текущая задача не уничтожается последующим сообщением коллеги.

0.1 — Конфигурационный профиль “qwen tag” (в основном settings.json):

// settings.json → channels."team-eng" { "team-eng": { "type": "dingtalk", "clientId": "$DINGTALK_CLIENT_ID", "clientSecret": "$DINGTALK_CLIENT_SECRET", "cwd": "/srv/repos/our-service", // Многопользовательский режим: ВСЯ группа использует ОДИН sessionId. routingKey → ${name}:${threadId||chatId} (:53). // DingTalk НЕ устанавливает threadId (:541-551) → ключ откатывается к chatId = conversationId||sessionWebhook (:534). // Сообщение без conversationId использовало бы БЫСТРОТЕКУЩИЙ webhook — считайте это жесткой ошибкой. "sessionScope": "thread", // groupPolicy по умолчанию "disabled" (GroupGate :13; config-utils :98) — ДОЛЖЕН быть установлен, иначе все групповые сообщения отбрасываются. // В режиме allowlist "*" НЕ является шаблоном членства (GroupGate :42); перечислите каждый chatId. "*" задает только ЗНАЧЕНИЯ ПО УМОЛЧАНИЮ. "groupPolicy": "allowlist", "groups": { "cidXXXXXXXX": { "requireMention": true, "dispatchMode": "followup" }, "*": { "requireMention": true, "dispatchMode": "followup" }, }, "senderPolicy": "open", "instructions": "You are the team's shared engineering agent in this DingTalk group...", }, }

Примечания, привязанные к фактическому состоянию: requireMention по умолчанию true (GroupGate.ts:49); sessionScope по умолчанию 'user' (config-utils.ts:92) — 'thread' это весь многопользовательский механизм; групповое значение по умолчанию для dispatchMode должно быть 'followup' (а не runtime 'steer', :354).

0.2 — Атрибуция отправителя. Префикс [senderName] в сиде promptText (ChannelBase.ts:316), ограниченный isGroup, срабатывает каждый turn (не ограничивается instructedSessions), при этом новый флаг Envelope.alreadyPrefixed защищает от повторного входа в collect. См. §6.1.

0.3 — Согласование dispatchMode. Установите dispatchMode для каждой группы явно; исправьте устаревший JSDoc в types.ts:42 ('collect''steer'), чтобы код и комментарий совпадали (OD-5).

Затронутые файлы (Phase 0). start.ts (добавьте опциональный путь подключения DaemonChannelBridge, чтобы утвержденная миграция Phase 1 была на расстоянии одного флага); ChannelBase.ts (сид senderName + защита alreadyPrefixed + шлюз подтверждения/allowlist для /clear + /who); types.ts (новое поле Envelope.alreadyPrefixed + исправление JSDoc); docs/ (рецепт + подводные камни).

Критерии приемки.

  • Два участника упоминают бота через @; оба разрешаются в один и тот же sessionId (утверждение через карты SessionRouter); ключ маршрутизации — team-eng:<conversationId>, а не URL webhook.
  • Агент использует атрибуцию отправителя ([senderName] присутствует для группы, отсутствует для 1:1); повторный вход в collect не добавляет префикс дважды (утверждение пути alreadyPrefixed).
  • Групповое сообщение без упоминания отбрасывается (причина mention_required); группа не из allowlist отбрасывается (not_allowlisted).
  • При dispatchMode: 'followup' сообщение участника B во время задачи участника A не отменяет A; сообщение B выполняется после A.
  • В общей группе (thread) /clear требует confirm и ограничен config.allowedUsers, если они заданы (не свободный сброс для всех); /status остается только для чтения.
  • Модульные тесты на уровне хуков (без UI-тестов с wait(ms)): равенство ключей маршрутизации для разных отправителей; наличие префикса promptText для isGroup true и false; пропуск alreadyPrefixed.

Phase 1 — Миграция Daemon + Проактивный движок + Замкнутый цикл MVP

Определение MVP. Единый замкнутый цикл с запланированной сводкой: оператор регистрирует cron-подобную задачу для канала; при срабатывании шлюз разрешает сессию канала с областью действия thread, запускает промпт с инструментами и отправляет результат обратно в холодный канал без запроса. Одна задача, один канал, один путь доставки. Более богатое поведение выходит за рамки MVP.

Утвержденная миграция. Phase 1 размещает каналы под qwen serve через DaemonChannelBridge (решение OD-1), наследуя FIFO promptQueue, медиатор, eventBus и маршруты. Проактивный движок описан в §6.2 (принадлежащий шлюзу, нейтральный к миграции планировщик; dispatchProactive сериализуется через sessionQueues; резервный вариант холодной отправки DingTalk через проверенный API groupMessages/send; обновление tokenManager; флаг возможности canColdSend). Три факта делают это нетривиальным: сегодня cron привязан к сессии и умирает при dispose (закрыто шлюзом единственного владельца OD-8); DingTalk не может отправлять сообщения в холодную группу (закрыто проверенным проактивным API + сохраненным openConversationId); и проактивный промпт должен сериализоваться через sessionQueues и никогда не вызывать bridge.prompt(), пока удерживается activePrompts — иначе DaemonChannelBridge выбросит Prompt already in flight (:257-261). Затронутые пакеты. ChannelCronStore.ts/ChannelCronScheduler.ts (новые, channel-base); cronParser.ts (переиспользование); ChannelBase.ts (dispatchProactive, pushProactive, флаг canColdSend, /schedule); DingtalkAdapter.ts + dingtalk/src/proactive.ts (новая холодная отправка + сохраняемый openConversationId + tokenManager); FeishuAdapter.ts (без изменений; эталонный адаптер с поддержкой проактивной отправки, canColdSend = true); start.ts (хостинг под демоном; создание и запуск планировщика после restoreSessions(); проброс isTagSession в создание сессии, чтобы отключить cron внутри сессии — OD-8); создание сессии (пропуск startCronScheduler() для тег-сессий, Session.ts:667-668).

Критерии приемки.

  • Каналы работают под управлением qwen serve (хостятся демоном); вызов инструмента возвращает permission_request (медиатор доступен), что подтверждает миграцию.
  • Оператор регистрирует одну задачу дайджеста; она сохраняется после перезапуска шлюза (перезагружается из ~/.qwen/channels/cron.json).
  • При срабатывании задачи, когда нет открытой сессии, шлюз определяет сессию для треда, выполняет промпт с инструментами и доставляет сообщение в неактивную группу DingTalk через путь холодной отправки — это доказывает работу холодной доставки в группу. Движок генерирует явную ошибку (логирует, записывает lastError, не выполняет silent no-op) при canColdSend = false.
  • Та же задача доставляет сообщение в Feishu через tenant_access_token, подтверждая работу абстракции canColdSend.
  • Срабатывание задачи не нарушает правило “один промпт на сессию”: если участник находится в процессе диалога, проактивный промпт встает в очередь через sessionQueues (await activePrompts.get(sessionId)?.done), никогда не отменяется через steer и никогда не вызывает исключение overlap в DaemonChannelBridge.
  • Проактивный ход не может быть отменен последующим ходом человека (тег-группы используют followup, никогда steer).
  • tokenManager обновляет v1.0 accessToken до истечения срока действия (~2 часа) и при получении 401, поэтому отправка после того, как сокет открыт > 2 часов, все равно проходит успешно.
  • Никаких двойных срабатываний для любых долговременных задач: планировщик шлюза является единственным владельцем; тег-сессия не запускает свой внутри-сессионный cron (OD-8); два хранилища находятся на непересекающихся путях.
  • Удаление задачи останавливает будущие срабатывания.
  • Тесты на уровне хуков/сервисов (планировщик против фейковых часов; холодная отправка против мокнутого HTTP-клиента) — никаких wait(ms).

Фаза 2 — Память каналов + Бюджеты токенов + Журнал аудита

2.1 — Память в скоупе канала (§6.3): добавить скоуп 'channel' + channelKey в writeContextFile.ts (WriteContextFileScope :80, WriteContextFileOptions :83-97, resolveContextFilePath :223-240); поставлять ~/.qwen/channels/memory/<channelName>/<hash(chatId)>/QWEN.md; подключить CLI-коллбэки readChannelMemory/writeChannelMemory через ChannelBaseOptions + бутстрап-чтение с переиспользованием instructedSessions. Daemon-роут Фазы-2 POST /channel/:sessionId/memory только для топологии демона.

2.2 — Бюджеты токенов для каждого канала (§6.4): BudgetLedger.ts с ключом по каналу, рекомендательный (только WARN) для оценки на стороне канала, жесткий отказ только при реальном использовании демона (Fix #6/OD-9); агрегация по организации для каждого процесса + окна для каждого канала, побеждает строжайший лимит, фиксированное дневное окно; алерты на 75%/95% (зависимость от проактивной отправки).

2.3 — Журнал аудита (§6.4): RequestAttributionRing + строка task.requested; атрибуция передается вместе с выполняемым ходом (построчная currentTurnAttribution), а не через джойн по таймстемпу (Fix #7); GET /workspace/audit (демон) или команда канала /audit. In-memory FIFO на 512 записей, теряется при перезапуске (известное ограничение v1; follow-up в виде append-only файла в ~/.qwen, OD-11).

Затронутые файлы. writeContextFile.ts, workspace-memory.ts (валидация скоупа + GET walker, путь демона); BudgetLedger.ts, RequestAttributionRing.ts (channel-base); permission-audit.ts (источник паттерна) / новый channel-audit.ts (демон); ChannelBase.ts (передача senderId/senderName в queued turns + currentTurnAttribution; хуки бюджета); server.ts (монтирование роутов после express.json :2025, блокировка мутаций через mutate({ strict: true })).

Критерии приемки.

  • scope: 'channel' пишет в ~/.qwen/channels/memory/<channel>/<hash(chatId)>/QWEN.md; две группы получают независимые файлы; общий QWEN.md рабочего пространства не затрагивается; запись проходит через внедренный коллбэк (нет зависимости channel-base → core).
  • Добавление в память канала идемпотентно при конкурентном доступе (файловый мьютекс) и эмитит memory_changed только при реальной мутации (путь демона; фильтрация на стороне подписчика).
  • На пути демона, после того как канал превышает лимит окна реального использования, следующий входящий промпт отклоняется (не обрезается), а проактивные задачи ставятся на паузу; счетчики сбрасываются при переходе на новое дневное окно; бюджеты для каждого канала независимы. На пути только с оценкой бюджет выдает WARN, но никогда не делает жесткий отказ (Fix #6).
  • Вызов инструмента/запрос разрешения, возникший во время выполнения queued turn отправителя A, атрибутируется A, даже если B поставил в очередь свой ход позже через followup (Fix #7).
  • Каждое проактивное срабатывание, запись в память канала и событие бюджета попадают в кольцо аудита с best-effort senderId/senderName, доступны через поверхность аудита и не транслируются в SSE-шину.
  • Юнит-тесты Ring/route/resolver (FIFO eviction, разрешение путей скоупа, математика порогов бюджета, атрибуция выполняемого хода) — без UI/тайминг-тестов.

Границы фаз и дальнейшие планы

Фазы 0→1→2 аддитивны: мультиплеер + идентификация (на AcpBridge) → миграция на демона + проактивный MVP → память + бюджеты + аудит. Многоидентификационный шлюз Фазы-3 (различные идентичности/учетные данные ботов для каждого канала, настоящие принципы per-user, токены для каждого канала) не входит в скоуп, это естественный следующий шаг, который снимает ограничения единого глобального токена / одного рабочего пространства на демон. Даже в рамках Фаз 0–2 “qwen tag” требует один процесс агента на рабочее пространство (OD-2); деплоймент, обслуживающий несколько репозиториев, запускает несколько процессов.


8. qwen tag против Claude Tag (компромиссы)

Claude Tag — это хостинговый мульти-тенантный агент: Anthropic управляет рантаймом, идентификацией и поминутным учетом для каждого пользователя; приложение канала является тонким клиентом. qwen tag — это инверсия: он работает на инфраструктуре, контролируемой оператором, поверх адаптеров qwen-code. Эта инверсия — всё ценностное предложение и вся поверхность рисков.

Где qwen выигрывает

  • Открытый / self-hosted, данные остаются внутри. Агент работает локально — через stdio в Фазе 0 (AcpBridge.start() запускает node <cli> --acp), in-process под qwen serve начиная с Фазы 1 — никаких вендорских API. Содержимое репозиториев, трафик моделей и транскрипты остаются на хостах оператора. Claude Tag не может этого гарантировать.
  • MCP / любой инструмент. Строгое надмножество набора инструментов закрытого хостингового агента.
  • Голосование за разрешения для каждого действия — возможность Фазы 1+ после перехода на хостинг демона. qwen-code поставляется с MultiClientPermissionMediator (четыре политики, кворум консенсуса floor(M/2)+1, отдельное кольцо аудита). Это реальное преимущество — недостижимое на пути AcpBridge Фазы 0 (requestPermission автоматически одобряет, :108-118), достижимое, когда Фаза 1 хостит каналы в демоне; даже там голоса ключуются по clientId, а канал является единственным клиентом, пока не появится реестр OD-3. Несуществующее поле ChannelConfig.approvalMode (types.ts:36) подтверждает, что это было запланировано, но отсутствует.
  • Долговечное, инспектируемое состояние. Персистентность SessionRouter, обычные файлы QWEN.md/AGENTS.md и (демон, Фаза 1+) кольцо реплея Last-Event-ID. Ничего непрозрачного.

Где он расходится и должен компенсировать

  1. Одно рабочее пространство + один глобальный токен + нет человеческой идентификации. Один процесс привязывается к одному рабочему пространству; несколько рабочих пространств = N процессов (OD-2). Единый глобальный токен применяется к HTTP-демону; путь канала AcpBridge Фазы 0 не имеет HTTP-поверхности и токена (его граница — SenderGate/GroupGate). Человеческая идентификация отсутствует везде — senderName является только рекомендательным текстом промпта (OD-11). Компенсация: один процесс на рабочее пространство/команду; внедрение атрибуции отправителя на уровне канала; сохранение clientId в качестве границы безопасности; требование --require-auth + токена для любого демона не на loopback (OD-12).
  2. Проактивные / холодные сообщения в каналах неоднородны. Только реактивные ответы в DingTalk (истекающий sessionWebhook); Feishu отправляет свободно через tenant_access_token. Компенсация: проверенная проактивная отправка в группу Фазы 1 по сохраняемому openConversationId (DingTalk, canColdSend становится true); для Feishu ничего не нужно.
  3. Планировщик привязан к сессии, а не к демону. Cron умирает при dispose() во время сбора неактивных сессий через 30 минут. Компенсация: планировщик, принадлежащий шлюзу (§6.2) — долгоживущий, переживает сбор, единственный владелец cron (OD-8).
  4. Память глобальна для рабочего пространства, а не для каждого канала. Компенсация: один процесс на канал (ноль кода) или скоуп channel Фазы 2 (OD-10).
  5. Мульти-идентификация / настоящий мульти-тенантинг не входят в скоуп (Фаза 3). В Фазах 0–2 моделируется как мульти-процесс.

Риски и митигация

#РискСерьезностьМитигация
R1Вызовы инструментов стека канала автоматически одобряются на пути AcpBridge Фазы 0 (AcpBridge.ts:108-118) — скомпрометированный канал запускает любой инструмент без шлюза.ВысокаяЗапланированная миграция на демона Фазы 1 внедряет медиатор; до этого ограничить набор инструментов + доверенный хост.
R2Утечка единого глобального токена демона предоставляет полный доступ к рабочему пространству (путь HTTP-демона; путь AcpBridge не имеет токена).ВысокаяLoopback по умолчанию + bearer-шлюз; --require-auth для не-loopback (OD-12); доверенный хост; ротация через перезапуск; блокировка деструктивных инструментов за consensus после внедрения.
R3dispatchMode по умолчанию 'steer' отменяет текущую работу при любом сообщении участника (в JSDoc было 'collect', теперь исправлено на 'steer', types.ts:42).ВысокаяТег-группы устанавливают 'followup'; JSDoc согласован (OD-5).
R4Отсутствие атрибуции отправителя → агент путает говорящих.ВысокаяВнедрение [senderName] Фазы 0 для групповых ходов (+ alreadyPrefixed, OD-6).
R5Холодная отправка в группу DingTalk / проактивность с истекшим вебхуком тихо падает (:137-141).СредняяПроверенная проактивная отправка в группу Фазы 1 по сохраняемому openConversationId; canColdSend генерирует явную ошибку; отображение деградаций.
R6Cron/уведомления умирают при сборе сессии (30 мин, run-qwen-serve.ts:94); также требует исходящего пути (R5).СредняяПланировщик, принадлежащий шлюзу (§6.2); OD-8 gate единственного владельца.
R7requireMention true → неупомянутые групповые сообщения тихо отбрасываются (GroupGate.ts:51-52).Низкая/СредняяОставить по умолчанию; задокументировать; опциональная подсказка для первого сообщения.
R8Общая память рабочего пространства перекрестно загрязняет соседствующие группы.СредняяОдин процесс на канал или скоуп channel Фазы 2 (OD-10).
R9Rate-limit на clientId/IP, а не на пользователя (путь демона); путь AcpBridge не имеет его.НизкаяПриемлемо для сингл-тенанта; поминутный учет на пользователя — это Фаза 3.
R10Набор голосующих для консенсуса снимается на момент запроса; участники канала сегодня не являются разными clientId.НизкаяOD-3: first-responder в Фазе 1; решить маппинг senderId→голос до консенсуса.
R11DingTalk SDK никогда не обновляет токен доступа ~2 ч, если сокет не закрывается — проактивные/эмоции/медиа тихо падают.ВысокаяtokenManager, принадлежащий проактивной фиче, обновляется через эндпоинт v1.0 oauth2/accessToken (§6.2, проверено).
R12Проактивное срабатывание, вызывающее DaemonChannelBridge.prompt() во время хода человека, выбросит Prompt already in flight (:257-261).ВысокаяdispatchProactive сериализуется через sessionQueues и ждет activePrompts перед bridge.prompt() — throw-guard структурно недостижим (Fix #1, §6.2).
R13Ложноположительное срабатывание оценочного бюджета может отклонить легитимный промпт пользователя.СредняяОценки только WARN; жесткий отказ только при реальном использовании демона (Fix #6, §6.4).
R14Очередь followup неверно атрибутирует вызовы инструментов последнему поставленному в очередь отправителю.СредняяПередача senderId в queued turn; аудит читает выполняемый ход (Fix #7, §6.4).

9. Принятые решения

Все Open Decisions v1 разрешены ниже с выбранными ответами. Единственные по-настоящему открытые вопросы — это детали API DingTalk с низкой степенью уверенности в рамках OD-7, выделенные в последней строке.

IDВопросРешение
OD-1Мигрировать хостинг каналов в qwen serve для Фазы 1+, или остаться на AcpBridge?РЕШЕНО — Мигрировать. Фаза 0 выходит на AcpBridge; Фаза 1+ хостит каналы под qwen serve через DaemonChannelBridge / daemon channel runner, наследуя FIFO promptQueue, MultiClientPermissionMediator, eventBus, /workspace/memory и rate-limit. Фаза 0 добавляет путь подключения (или --daemon <url>), чтобы переключение было шагом конфигурации. Планировщик шлюза (§6.2) нейтрален к миграции. Больше не является gate — закоммиченная архитектура.
OD-2Единица деплоя = один процесс на рабочее пространство/канал?РЕШЕНО — Да. Один процесс на рабочее пространство/канал: изоляция памяти и секретов для каждого канала, ограничение радиуса поражения единого глобального токена. Размещение нескольких каналов вместе — это задача Фазы 3 (требует скоупа channel + governor).
OD-3Политика разрешений для мультиплеерного тега (один канал = один clientId демона)?РЕШЕНО — Фаза 1: first-responder с одним clientId на уровне канала (любой разрешенный участник подтверждает; атрибуция на уровне канала; нет маппинга senderId→clientId). Фаза 2: consensus/designated, когда появится реестр senderId→clientId + жизненный цикл (сбор, границы refcount). Автоматический отказ в высоко-рисковых инструментах на проактивных ходах.
OD-4/clear//status в скоупе треда являются общими для канала.РЕШЕНО — в общей (тред) группе /clear требует confirm и ограничен config.allowedUsers, если они заданы (дефисный /clear-channel не парсится; owner-gate для каждого участника отложен до модели идентификации, OD-3/OD-11); /status остается read-only для общей сессии.
OD-5Несоответствие dispatchMode по умолчанию (JSDoc 'collect' vs runtime 'steer').РЕШЕНО — Исправить JSDoc в types.ts:42 на 'steer' (соответствует runtime); профиль тег-группы явно устанавливает dispatchMode: 'followup'.
OD-6Формат маркера отправителя + двойной префикс в collect.РЕШЕНО — Префикс [senderName] для каждого хода, БЕЗ привязки к instructedSessions, плюс ОДНО новое опциональное поле EnvelopealreadyPrefixed (types.ts), чтобы синтетический повторный вход в режиме collect пропускал повторное добавление префикса. (Исправляет утверждение v1 “никаких новых полей”).
OD-7Проактивная отправка DingTalk: эндпоинт/разрешение, эквивалентность openConversationId, обновление токена.РЕШЕНО с проверенными фактами (§6.2/§6.5): эндпоинт POST https://api.dingtalk.com/v1.0/robot/groupMessages/send (высокая уверенность); тело { robotCode=config.clientId, openConversationId, msgKey:'sampleMarkdown', msgParam:<JSON string {title,text}> } (высокая); заголовок auth x-acs-dingtalk-access-token с токеном v1.0 oauth2/accessToken, TTL ~7200 с, кэшируется и обновляется tokenManager, принадлежащим фиче (высокая); сохранять openConversationId в ~/.qwen/channels/dingtalk-groups.json; callback conversationIdopenConversationId (средняя; fallback на API конвертации chatId→openConversationId при invalid.openConversationId). Осталось открытым (низкая уверенность): точный код/отображаемое имя permission-point; дословная официальная фраза об эквивалентности; применяется ли троттлинг 20/мин к groupMessages/send.
OD-8Двойное срабатывание Cron между планировщиками шлюза и сессии.РЕШЕНО — Планировщик шлюза является ЕДИНСТВЕННЫМ владельцем cron. Сессия, хостируемая в канале (тег), не запускает свой внутри-сессионный Session cron; она узнает, что является тег-сессией через флаг isTagSession, проброшенный из хоста канала при создании сессии (пакет опций DaemonChannelSessionFactory для Фазы 1+; опция спавна --acp для Фазы 0), что пропускает startCronScheduler() (Session.ts:667-668). Два хранилища cron находятся на непересекающихся путях (шлюз ~/.qwen/channels/cron.json против сессии ~/.qwen/tmp/<hash>/scheduled_tasks.json), поэтому единственный риск коллизии — запуск обоих планировщиков для одних и тех же задач — устранен gate.
OD-9Скоуп бюджета токенов, источник истины, окно.РЕШЕНО — Агрегация “org” для каждого процесса + окна для каждого канала, побеждает строжайший, фиксированное дневное окно. v1 оценивает токены на стороне канала (рекомендательно, только WARN — никогда не делает жесткий отказ, Fix #6) и читает путь использования демона для точного списания (и жесткого отказа) после перехода на хостинг демона.
OD-10Неймспейсинг памяти для каждой комнаты + полномочия на запись.РЕШЕНО — Добавить скоуп channel (+channelKey) в writeContextFile.ts; channel-base получает запись/чтение через CLI-коллбэк, внедренный через ChannelBaseOptions (readChannelMemory/writeChannelMemory) — НЕТ зависимости channel-base → core. Глобальное пользовательское расположение ~/.qwen/channels/memory/. Агент добавляет через интент save_memory; бутстрап-чтение переиспользует gate instructedSessions.
OD-11Модель человеческой идентификации + долговечность аудита.РЕШЕНО — senderName только рекомендательный; clientId остается единственным security principal. Best-effort атрибуция передается с выполняемым ходом (Fix #7); in-memory FIFO audit ring на 512 + follow-up append-only файл в ~/.qwen.
OD-12Усиление токена для деплойментов с демоном не на loopback.РЕШЕНО — Требовать --require-auth + токен для любого деплоя с демоном не на loopback. Только loopback — только для разработки; --require-auth — задокументированная позиция по умолчанию (run-qwen-serve.ts уже требует токен для не-loopback).
OPEN (only remaining)Детали API DingTalk с низкой уверенностью в рамках OD-7.ВСЕ ЕЩЕ ОТКРЫТО — проверить в консоли / по живой документации перед кодингом: (1) точный код/отображаемое имя permission-point для “проактивной отправки группового сообщения” (низкая); (2) авторитетная официальная фраза, приравнивающая callback conversationId к openConversationId для стандартного не-cool-app робота (средняя; гарантированный документацией путь — API конвертации chatId→openConversationId); (3) применяется ли лимит “20 сообщений/минуту → ~10-минутный троттлинг” дословно к groupMessages/send (низкая/средняя — задокументировано для кастомных webhook-роботов, не подтверждено на странице отправки orgapp).

10. Риски и меры по их снижению

См. сводную таблицу в §8. Ключевые риски в порядке приоритета:

  1. R1 — автоподтверждение в канальном пути Phase-0. Пока запланированная в Phase-1 миграция демона не внедрит опосредованный транспорт, агент, работающий в канале, выполняет любой инструмент без проверок. Самый важный пробел в безопасности; снижаем риск с помощью консервативного набора инструментов + доверенного хоста до наступления Phase 1.
  2. R12 — исключение при проактивном перекрытии. Вызов DaemonChannelBridge.prompt() во время хода пользователя выбрасывает Prompt already in flight (:257-261). Закрывается сериализацией через sessionQueues (Fix #1) — центральным элементом §6.2.
  3. R11 — истечение срока действия токена DingTalk. Ошибка из разряда «работает в демо, но падает через 2 часа». Проактивная функция использует собственный tokenManager (проверенный эндпоинт v1.0, TTL ~7200 с) до выпуска любой долгоживущей функции.
  4. R5 — тихий сбой в «холодной» группе DingTalk. Проактивный вывод в неактивные группы невозможен без проверенного пути отправки; canColdSend генерирует явную ошибку вместо молчаливого игнорирования.
  5. R3 — отмена steer в группах. Случайный DoS для нескольких пользователей при значении по умолчанию в рантайме; профиль тега устанавливает followup.
  6. R13/R14 — ложные срабатывания бюджета и неверное присвоение. Оценки выдают только WARN (Fix #6); присвоение переносится вместе с выполняемым ходом (Fix #7).
  7. R8 — перекрестное загрязнение общей памяти. Один процесс на канал — это мера снижения риска без изменения кода; скоуп channel решает проблему совместного размещения.

Каждый риск привязан к фазе: R1/R3/R4 относятся к Phase 0–1, R5/R6/R11/R12 — к Phase 1, R8/R13/R14 и риски аудита/бюджета — к Phase 2.


11. Приложение: Индекс файлов и символов

Базовый канал (packages/channels/base/src/)

  • SessionRouter.tsroutingKey() (:44-60, thread :53, single :55, user :58), скоуп по умолчанию 'user' (:25), setChannelScope() (:40-42), resolve() (:72-92), getTarget() (:94), persist()/restoreSessions() (:168-244), PersistedEntry (:5-9).
  • ChannelBase.tshandleInbound() (:238-471), формирование промпта (:316-347), вызов bridge.prompt() (:425), гейты (:240-252), разрешение dispatchMode (:353-354), steer (:371-379), collect (:361-370,445-463), followup (:381-383,394-470), activePrompts (:32-35,356), sessionQueues (:394,466), абстрактный sendMessage() (:81), registerCommand() (:141-143), роутер конструктора (:62-64), ChannelBaseOptions (:9-22,46), /clear//status (:147-217).
  • AcpBridge.ts — spawn --acp (:53-70), newSession(cwd) (:131), prompt() (:147-180), автоподтверждение requestPermission (:108-118), AcpBridgeOptions (:17-21).
  • DaemonChannelBridge.tsnewSession/loadSession sessionScope 'thread' (:229,240), пакет опций фабрики сессий (:226-241), защита activePrompts / выброс Prompt already in flight (:257-261), cancelSession (:332), respondToPermission (:346-374), события разрешений (:557-633).
  • GroupGate.tsrequireMention по умолчанию true (:49), членство (:42), гейтинг упоминаний (:51-52), цепочка fallback (:48), политика по умолчанию 'disabled' (:13).
  • SenderGate.tscheck() + pairing (:42).
  • types.tsGroupConfig (: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 — map webhooks (:84), sendMessage() (:134-170, возврат без webhook :137-141), кэш webhook (:516-517), getAccessToken() (:172-174), emotionApi() (:188-207, robotCode :184, openConversationId :197, антипаттерн empty-catch :214-216), media robotCode (:435), входящий conversationId (:506), удаление упоминания (:527-529), isMentioned (:520), senderName (:544), extractQuotedContext() (:272-298), chatId (:534), отсутствие threadId (:541-551).
  • proactive.ts (новый) — sendGroupMessage() через POST /v1.0/robot/groupMessages/send (robotCode+openConversationId+msgKey:'sampleMarkdown'+msgParam JSON-строка), tokenManager (v1.0 oauth2/accessToken, TTL ~7200 с, таймер + обновление по 401), fallback конвертации chatId→openConversationId.
  • markdown.tsconvertTables() (:44-80), splitChunks() (:84-188), CHUNK_LIMIT=3800 (:10; ≤ бюджета sampleMarkdown ~5000 символов), extractTitle() (:190-195), normalizeDingTalkMarkdown() (:198-201).
  • media.ts — заголовок downloadMedia (:39), тело :42.
  • SDK: client.mjs gettoken (:85-87), reconnect (:157-163), разделение event/callback (:14-19,35-37,58-61,241-257); constants.d.ts sessionWebhookExpiredTime (: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.tsWriteContextFileScope (: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.tsparseCron/matches/nextFireTime (:104,141,168).
  • utils/cronTasksFile.tsDurableCronTask (: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 — FIFO promptQueue для каждого SessionEntry (:232,2855,3082), publishWorkspaceEvent (:3610,3649-3675).
  • eventBus.tsBridgeEvent.data произвольной формы (:51), originatorClientId (:60), пороги гистерезиса (:101-103), кольцевой буфер повтора (:92).
  • permissionMediator.ts — четыре политики + кворум консенсуса (:348,621-637).
  • permission-audit.tsPermissionAuditRing FIFO 512 (:128-172), объединение закрытых записей (:57-104), документация в заголовке, предполагающая реализацию GET-запросов (:22-25).
  • rate-limit.ts — токены-бакеты для каждого (clientId|ip); X-Qwen-Client-Id (:110).
  • auth.ts — глобальный bearer-токен (:259-266), строгий createMutationGate (:356).
  • workspace-memory.ts — скоупы workspace|global (:118-125), мутация со строгой аутентификацией (:114), лимит на запись MAX_MEMORY_CONTENT_BYTES (:79), фиксированная передача projectRoot (:185-190).

Команды CLI для каналов (packages/cli/src/commands/channel/)

  • start.tsstartCommand (:479-499), создание AcpBridge (:213,268,356,435), setChannelScope (:361-362), restoreSessions (:275,444), sessionsPath() (:56-58), checkDuplicateInstance() (:170-179), обработчик отключения (:241,403); путь подключения демона Phase 1+; внедрение readChannelMemory/writeChannelMemory на уровне CLI.
  • config-utils.tsparseChannelConfig() (:81-100, sessionScope по умолчанию :91-92, approvalMode :94, groupPolicy :98), resolveEnvVars() (:6-18).
  • channel-registry.tsensureBuiltins() (:6-32), типы каналов (:10-14).
Last updated on