# 任务收件箱(站内通知中心)技术方案 - 日期:2026-10-02 - 状态:**已实施**(P1 + P2 于 0.0.235 落地;P3 Web Push 不做)。实施偏差两处:① 技术方案原写「P2 的 `system_notify_enabled` 由 P2 迁移 ADD COLUMN,P1 不建死列」,因 P1/P2 同批落地,直接在建表的 V131 中建出该列,避免一次无意义发版持有死列;② 系统通知的「新增条目 diff」最初与「打开抽屉播种」共用 `fetchList()` 写路径,被常驻的 `InboxDrawer` items watcher 抢先播种导致 diff 恒为空、通知零触发(审查 BUG-6),改为 store 侧只读 peek(`peekInboxFirstPage()`,不写 `items`),diff 数据源与播种器解耦。 - 来源提案:`docs/proposals/2026-10-02-task-inbox.md` - 实施范围:P1(收件箱全链路)+ P2(Electron 系统通知);P3(Web Push)不做 ## 1 需求背景 Mao 的异步事件缺乏统一的"结果回来了"入口: - 任务完成/失败:`session/task-terminal.service.ts` 在相位收敛到终态后走 `TaskNotificationDeliveryService.prepare()`,由 webhook 调度器投递任务 webhook 与 IM 卡片;用户 WS 在线时投递被抑制——**在线用户反而没有任何站内提醒**,只能去会话列表翻。 - 提问待答(`ask_user_questions`)与 LOCAL 审批待办只在会话运行时的面板里可见;用户切走或离线后没有聚合入口。 - 后台子代理结果经 `harness/delegate/` 回传主会话,完成提醒只走 IM 渠道。 - 收件箱的地基被提案误判为"遗留空表可直接 ALTER 启用":`notification` 表实际已在 `V058__drop_notification.sql` 中被 DROP,本方案改为**新建表**(见 4.1 与决策记录 #2)。 ## 2 需求描述 ### 2.1 目标 1. 站内收件箱:聚合以下五类事件,一处查看、一键跳转会话,操作仍在会话面板完成: - `TASK_COMPLETED`:会话相位收敛到 COMPLETED(主会话)。 - `TASK_FAILED`:会话相位收敛到 FAILED(主会话)。 - `QUESTION_PENDING`:`ask_user_questions` 产生待答提问。 - `APPROVAL_PENDING`:LOCAL 审批请求创建。 - `SUBAGENT_DONE`:子代理结果回传父会话完成(覆盖 COMPLETED / FAILED / CANCELLED 三种子代理终态,见决策记录 #11)。 2. 实时性:服务端写库后经 WS 向该用户全部在线连接广播 `inbox_updated { unreadCount }`(该事件已列 `CRITICAL_EVENT_TYPES`);断线重连后前端主动拉取兜底,且每次打开抽屉也会强制重拉。 3. Electron 桌面端系统通知(仅窗口未聚焦时弹出,点击聚焦并跳转)。 4. 按 kind 的偏好开关,与现有 IM/webhook 渠道并存、开关独立、互不门控。 ### 2.2 核心交互 - 顶栏铃铛显示权威未读数(服务端为唯一权威,前端不做本地累加);点击展开收件箱抽屉。 - 条目按 kind 显示图标、标题、摘要、相对时间;未读条目有标识。 - 点击条目:标记已读并跳转关联会话(QUESTION / APPROVAL 跳转后会话内对应面板自然呈现;提问超过 900 秒超时或审批已处理时,条目已在此前被自动置为已读,payload 摘要仍可读)。关联会话已被删除(session 为软删除、列表不再展示)时,前端跳转降级为提示并保留条目,不报错白屏。 - 会话「列表未读」与收件箱未读是**两套独立概念**:前者是 `session.unread`(DB 列,终态由 `session.service.ts:1021-1025` 写 1,微信/飞书排除),体现为会话列表圆点;后者只由 `notification.is_read` 决定,体现为顶栏铃铛徽标。二者不得互相推导或换算(红线 #2)。 - 定时任务产生的条目 payload 带 `source: 'SCHEDULED'`,前端显示「定时任务」徽标(来源透传机制见 4.2)。 - 子代理产生的条目(`SUBAGENT_DONE`)payload 带 `status`(COMPLETED / FAILED / CANCELLED),标题/摘要据此区分「已完成 / 执行失败 / 已取消」,避免失败被表述成完成(决策记录 #11)。 - 偏好页新增「站内收件箱」分区:四个 kind 开关(独立保存,不并入 webhook 偏好页的单次保存)+ Electron 系统通知总开关(仅 Electron 显示,P2)。 - 保留 90 天自动清理。 ## 3 明确不做(不做清单) | # | 不做的事 | 理由 | |---|---|---| | 1 | Web Push(VAPID + Service Worker),含安卓可行性探索(P3) | 安卓壳远程加载 Web,Android WebView 的 Push 依赖 Google Play services,国内 ROM 普遍不可用;安卓继续以 IM 通道为兜底,不做原生推送 | | 2 | 收件箱内直接回复 / 审批 | 只读 + 跳转会话,操作仍在会话面板完成,避免两套操作语义 | | 3 | 替代或改动现有 IM/webhook 通知 | 收件箱是站内补充;`TaskNotificationDeliveryService` 的 prepare/suppress/投递链路行为不变 | | 4 | 管理后台收件箱 | 个人数据,admin 不聚合展示 | | 5 | CANCELLED 终态写入收件箱 | 取消多由用户自己发起,无通知价值,保持收件箱低噪 | | 6 | 子代理(`sessionType='SUBAGENT'`)与侧任务(`sessionType='SIDE_TASK'`)会话的 TASK_COMPLETED 记录 | 子代理完成由 SUBAGENT_DONE 单独承载(避免同一完成产生两条通知);侧任务已有侧任务面板未读徽标承载。**排除条件按 `session.sessionType` 判定,不得用 `parentSessionId` 近似**——SIDE_TASK 可被 `promoteSideTaskToMainSession`(`session.service.ts:590`)升级为 `parentSessionId=null, sessionType='NORMAL'` 的主会话,用 parentSessionId 会漏判 | | 7 | 收件箱归档、收件箱内搜索、按 kind 的列表过滤 | 超出本期范围,列表仅支持 `unreadOnly` 过滤 | | 8 | ask_user 离线 IM 提醒(`prepareAskUser`)与收件箱开关联动 | 两渠道并存、开关独立(决策记录 #4) | | 9 | 通知偏好配置化(如保留天数进 system_setting) | 90 天为代码常量,KISS | | 10 | QUESTION / APPROVAL 条目携带 executionId 关联执行 | `tool-dispatcher` / `local-tool-executor` 两处均无 executionId 上下文(WS 帧上的 executionId 由 `streaming-ws-handler.ts` 的 `runningExecutionIds` 附加,工具层拿不到);条目只关联 session,payload 不塞 executionId | | 11 | 同步 delegate(invocationType 非 BACKGROUND/FOLLOWUP)的子代理结果写收件箱 | 只挂 `deliverBackground()`;同步 delegate 已有即时工具结果返回主代理,再入收件箱会构成双重通知(决策记录 #8 同源) | | 12 | `inbox_updated` 事件携带条目内容 | 帧只带权威 `unreadCount`;条目内容由渲染端拉列表后 diff 得出,避免给事件通道加重(见 5.4 P2) | ## 4 技术选型 ### 4.1 数据模型(迁移 V131) 新迁移 `backend-ts/db/migration/V131__inbox_notification.sql`,一次包含两张表。迁移号避让:长期记忆方案占 V129/V130、Agent 资产包方案占 V132(2026-10-02 评审统一定号),本方案由原 V129 顺延为 V131: ```sql CREATE TABLE notification ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, kind VARCHAR(48) NOT NULL COMMENT 'TASK_COMPLETED/TASK_FAILED/QUESTION_PENDING/APPROVAL_PENDING/SUBAGENT_DONE', title VARCHAR(256) NOT NULL, content TEXT NULL COMMENT '摘要正文,对外展示一行', is_read TINYINT NOT NULL DEFAULT 0, read_at DATETIME NULL, session_id BIGINT NULL COMMENT '跳转目标会话;会话被删后降级提示(见 2.2)', payload_json JSON NULL COMMENT '随事件附带的要素:source、status、requestId、executionId、错误原因等', dedup_key VARCHAR(128) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_notification_dedup (dedup_key), KEY idx_notification_user_created (user_id, created_at), KEY idx_notification_user_read (user_id, is_read) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '站内收件箱条目'; CREATE TABLE user_inbox_preference ( user_id BIGINT NOT NULL COMMENT '用户ID', task_completed_enabled TINYINT NOT NULL DEFAULT 1 COMMENT '任务完成', question_pending_enabled TINYINT NOT NULL DEFAULT 1 COMMENT '提问待答', approval_pending_enabled TINYINT NOT NULL DEFAULT 1 COMMENT '审批待办', subagent_done_enabled TINYINT NOT NULL DEFAULT 0 COMMENT '子代理完成(默认关,防并行子代理刷屏)', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (user_id) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '用户收件箱偏好'; ``` - **不保留提案中的 `type` 列**(SYSTEM/TASK/AGENT):全新建表且无任何读写方,保留即永久 NULL 死列,kind 一列足够(决策记录 #2)。 - `user_inbox_preference` 而非"偏好 KV":preference 域不存在通用 KV,惯例是每类偏好独立表 + repository(如 `user_weixin_preference` / `user_task_panel_preference`),本表沿用该模式,免 version 乐观锁(开关写入采用 last-write-wins,同 `user_weixin_preference`)。 - `system_notify_enabled` **不在 V131 建出**:P2 届时 `ALTER TABLE user_inbox_preference ADD COLUMN system_notify_enabled TINYINT NOT NULL DEFAULT 1` 即可。P1 建出即犯本方案自己在决策记录 #2 里反对的"永久 NULL 死列",一次 ADD COLUMN 的 P2 迁移比一个死列更符合 KISS。 - **`related_type` / `related_id` 与 `session_id` 语义重叠,一并去掉**:跳转只依赖 `session_id`(2.2),`requestId` / `executionId` 等执行要素已在 `payload_json` 内,`related_*` 若保留会永久半冗余(无任何读取方按 related_type 过滤的需求)。表形状收敛为 `session_id` + `payload_json`。 - 默认值即决策记录 #5 的打扰度设计:前三类开、`subagent_done` 关。 - `payload_json` 用 `JSON NULL`(NOT NULL 会要求每条都带 payload;`V085__feishu_pending_binding_message.sql` 的 `event_json JSON NOT NULL` 是纯事件表,形态不同)。仓内列注释用中文(`V010__session_activity.sql`),不用英文。 ### 4.2 写入路径与幂等 新增域 `backend-ts/src/inbox/`(`inbox.routes.ts` / `inbox.service.ts` / `inbox.repository.ts` / spec 同目录)。`InboxService` 为统一入口: - **幂等**:`dedup_key = "{userId}:{kind}:{sessionId}:{executionId|requestId}"`,落库用 `INSERT ... ON DUPLICATE KEY UPDATE id = id`,命中唯一键即无操作;并发冲突由唯一键兜底。 - `ON DUPLICATE KEY UPDATE id = id` 是刻意选择(区别于仓内 8 处 `VALUES(col)` 真覆盖先例,如 `feedback.repository.ts:54`、`feishu/message.repository.ts:125`);`id = id` 保证「插入即忽略」:retry / 取消后重发 / 排队消费 / 崩溃恢复不得刷新 `created_at` 排序、不得把已读条目打回未读。 - **key 构造必须单一导出**:`inboxDedupKey(userId, kind, sessionId, tail)` 一个函数,写入侧(task-terminal / tool-dispatcher / local-tool-executor / subagent 四处)与置已读侧(tool-dispatcher / local-tool-executor 两处)**共用同一实现**。禁止两侧各自拼接字符串——这是本方案最典型的漂移点;`inbox.service.spec.ts` 需锁住「写入键 === 置已读读取键」。 - `executionId` 语义须在测试口径中明确:崩溃恢复(`crash-recovery-runner.ts:377`)、重试(`streaming-ws-handler.ts:1089`)、排队消费均为**新 executionId**,产生新条目是**预期行为**,不属于"幽灵通知"。真正的重复收敛风险是**同一 executionId 二次进入终态**(`handleCancel` 立即落 CANCELLED + 执行体 finally 二次收敛),由 `finishCancelledSession:1807` 的终态早退 + `finishExecution:45-51` 的 already-terminal 早退双保险 + dedup 唯一键三重防护。 - **权威未读数**:每次写路径(写入 / 单条已读 / 全部已读 / 删除 / 联动置已读)之后 `SELECT COUNT(*) ... WHERE user_id = ? AND is_read = 0`,并经 `streaming-ws-registry.send(userId, wsEvent('inbox_updated', null, { unreadCount }))` 广播到该用户全部在线连接。 - **WS 事件定位**:`inbox_updated` **加入** `CRITICAL_EVENT_TYPES`(`streaming-ws-registry.ts:54`)。它不是会话状态类关键事件,但普通帧队列满时是**直接丢弃**(`streaming-ws-registry.ts:411`,`dropSlow`),弱网/高频会话下未读数会静默停留在旧值且无主动重拉入口。收件箱事件日均为个位数到数十条,不会挤占关键通道。仍保留"重连后拉取 unread-count 兜底"(见 5.4),双保险。 - **偏好门控**:`record()` 前查 `user_inbox_preference`(无行时按列默认值),kind 关闭则不写入。 - **来源与子会话抑制**:`TASK_COMPLETED/FAILED` 写入点在 `session/task-terminal.service.ts:74`(`this.prepareDelivery(session, phase, executionId, failureReason ?? null)` 调用处,即 `finishExecution` 内 `prepare()` 并列位置;原文档"方法 :25 起、prepare 在 :62"是旧行号,实际方法起于 `:35`、私有封装调用在 `:74`、`deliveryService.prepare()` 本体在 `:139`)。追加条件: - `session.sessionType === 'SUBAGENT' || session.sessionType === 'SIDE_TASK'` 时跳过(决策记录 #8)。 - **必须读 `session.sessionType`,不得用 `parentSessionId != null` 近似**:SIDE_TASK 可被 `promoteSideTaskToMainSession`(`session.service.ts:590`)升级为 `parentSessionId=null, sessionType='NORMAL'` 的独立主会话,用 parentSessionId 判定会漏判。仓内自身一律双条件判(`task-terminal.service.ts:89`)。 - 本排除范围写成"两者",与 `dispatchMemoryExtraction`(`task-terminal.service.ts:107`,排除 SUBAGENT + SIDE_TASK)一致;**注意与 `delivery.service.ts:51`(只排除 SUBAGENT)不同**,实施者易照抄后者而漏掉边路任务。 - 实测边界:`sessionType='NORMAL'` + `parentSessionId=3`(被提升过的边路任务)→ 记 1 条收件箱,`prepare()` 记 2 条——符合预期(两者职责不同)。 - **只记 `COMPLETED` / `FAILED`**:`phase !== 'COMPLETED' && phase !== 'FAILED'` 直接 return(同 `delivery.service.ts:48` 的写法)。 - CANCELLED **会真实到达 `:74`**:`finishCancelledSession`(`streaming-ws-handler.ts:1803-1809`)有 13 个调用方,全部汇聚到同一条 `finishExecution(sessionId, userId, 'CANCELLED', executionId)` 路径。因此 CANCELLED 不能靠调用点天然过滤,**必须显式写死**。 - 微信/飞书通道会话(`weixin-bot` projectKey / feishu workspace)跳过:`task-terminal.service.ts:66` 对这两类会话连 `session.unread` 都不置 1,它们由机器人触发、无"用户等着看站内"的语义。 - **不写入未知用户**:`ownerId = session.userId ?? userId`,`notification.user_id NOT NULL`,为 null 时跳过(对照 `task-terminal.service.ts:75` 的 `ownerId == null` 分支)。 - **来源透传(timing 决策 #1:方案 A)**:`finishExecution` 新增可选第 6 参 `notifySource: 'MANUAL' | 'SCHEDULED' = 'MANUAL'`,写入 `payload_json.source`,前端据此前置「定时任务」徽标。 - 现状:生产环境定时任务走 `create-app.ts:1147` 无条件注入的 `liveExecution`(`wsHandler.executePersistedUserPrompt`),**不会执行** `scheduled-task.service.ts:472`(impl 分支);`liveExecution` 抛异常时才落 `:489`(catch 里的 FAILED,本级 finish 只调 1 次 `markTaskResult`)。 - 实现:`StreamingWsHandler` 侧维护 `scheduledTaskIds: Map`(镜像 `queueScheduledTaskIds`,`streaming-ws-handler.ts:186` 的模式),由 `executePersistedUserPrompt` 入口写入、`runExecution` finally 按归属清理;`runExecution`(`:531`,**私有方法**)加 `scheduled` flag 形参转发给三个 finisher(`:1788 / :1799 / :1807`),再经 `finishExecution` 第 6 参传出。 - busy 入队路径(`scheduled-task.service.ts:414`)也经 liveExecution,同一个入口即被覆盖。 - 由此**窄接口只需扩 2 份**:`streaming-ws-handler.ts:84`(三处调用点需改调用)与 `scheduled-task.service.ts:122`(保 `liveExecution == null` 的遗留/测试路径)。其余 4 份(`harness/deps.ts:274`、`dingtalk/runtime.ts:83`、`weixin/agent-inbound-handler.ts:58`、`harness/delegate/subagent-visibility-service.ts:26`)结构兼容、按默认值 MANUAL 零改动;`create-app.ts:1490`(CANCELLED,不写收件箱)、`create-app.ts:1697`(`onExecutionFinished`,外部触发,无 taskId,算 MANUAL 合理)、`crash-recovery-runner.ts:411`(CANCELLED,同样不写)、`dingtalk/runtime.ts:315/528`、`agent-inbound-handler.ts:211/229/343` 均 MANUAL。 - **TS 窄接口无告警**:给 6 份窄接口消费方加第 6 参时,缺省调用点既不会报错也不会生效,只会静默取默认值。因此"定时任务路径传 SCHEDULED"必须是 spec 断言项(生产路径 `finishCompletedSession` / `finishFailedSession`,而非 `scheduled-task.service.ts:472`)。 - **不新增 `ExecutionSource` 联合类型**:本仓 `phase` / `executionMode` / `sessionType` 一律裸 `string`,造独立联合类型会被 4 份窄接口结构削弱成大段落差。 ### 4.3 待办类条目的生命周期联动 提问与审批 pending 均为内存态(900 秒超时),收件箱记录是持久的,联动规则:**问题被回答/取消/超时、审批被批准/拒绝/超时/断连后,对应条目自动置为已读**(`is_read=1, read_at=NOW()`,条目保留在列表可查,不再计入未读徽标;决策记录 #3)。置已读时 userId / kind / sessionId / 尾段四要素齐全,**直接拼出完整 `dedup_key` 等值匹配**(命中 `uk_notification_dedup`),不做尾段 LIKE 扫描。`markReadByDedupKey` 幂等(第二次 UPDATE 影响 0 行),因此重复触发安全。 **三态/四态判定必须读结构化标记,禁止解析 `resultJson` 文本**(`ask-user-questions-registry.ts:12-20` 已明确禁止凭内容猜语义): | 场景 | 判定 | |---|---| | 提问被正常回答 | `result.answered === true`(`tool-dispatcher.ts:340` 之后) | | 提问随会话取消(stop / 取消) | `result.answered === true && result.cancelled === true`(`failAllForSession` 以 `cancelled:true` 标记 resolve) | | 提问 900s 超时 | `result.answered === false`(registry 内部 `Promise.race` 超时并 delete 登记) | | 审批被批准/拒绝 | `streaming-ws-handler.ts:773 handleToolApproval` 立即 `unregister`(不等命令跑完) | | 审批超时/执行异常/断连 | `local-tool-executor.ts:56` finally 的 `unregister` | **审批置已读只挂 `local-tool-executor.ts:56` 一处**:`handleToolApproval` 的 unregister 不重复挂点,否则「已批准 → 命令仍在跑」窗口内会双触发(幂等兜底没错,但语义上取一个点更清晰)。 ### 4.4 前端技术选型 - Vue3 `