diff --git a/apps/api/src/modules/agents/CONTEXT.md b/apps/api/src/modules/agents/CONTEXT.md index fb4058d2..e8abc84e 100644 --- a/apps/api/src/modules/agents/CONTEXT.md +++ b/apps/api/src/modules/agents/CONTEXT.md @@ -32,7 +32,7 @@ Agent 配置(profile)、运行实例(instance)、决策循环(loop) - **周期循环 scan-sharing(候选 3,2026-09-08)**:monitor `check()` 一轮开头一次 `getIndex()`,快照作 caller-private 传给 6 个 WU 探针(收 `snapshots` 参数、内存 filter,不再各自 getIndex;动作前新鲜度复核 `getIndex({id})` 点读不受影响)——**新 WU 探针一律收快照不自己读**;auditor generateSuggestions 的 4 周事件窗口同样每轮一次读、多消费方共享(原 skill 循环内 N+1 全窗口扫描)。跨 job 不共享(各自的轮各自读,非快照层) - **路由分叉坑(#391 实测)**:`/api/v1/agents` 的 GET / 由 legacy `routes.ts`(agents-registry,响应条目**无 id 字段**)处理;前端 profile 列表走 `/api/v1/agent-profiles`(agent-profile.routes.ts,有 id)。发现 profileId 勿用 `/agents` -- **AgentProfile 持久化**:`~/.studio/data/agents/{id}/profile.json` + `state.json`;原子写+mkdir 锁,仅可显式 DELETE;保留名 `studio` +- **AgentProfile 持久化**:`~/.studio/data/agents/{id}/profile.json` + `state.json`;原子写+mkdir 锁,仅可显式 DELETE;保留名 `studio`。删除时反向收敛引用(agent-profile.service.delete):清各频道 members + #497 起同步把 channel.routing 中指名该 profile 的档归一化为 null(其他档不动;无引用的频道零写入,幂等) - **studio 系统角色语义(2026-09-10 修正)**:AC-1.3「studio 不挂 loop」已废除——trigger `assigneeRole` 指名 = 独占认领(仅该角色 loop 可见),studio 无 loop 期间指名 WU 全部结构性死单(doc-semantic-review 滞留 143h 事故)。现 studio = 系统维护任务执行角色(正常挂 loop,`channels=[]` 只看得见指名给它的单)+ systemExecutor 直调身份(读其 provider)双重用途;保留名保护扩展到**禁止停用**(update status 非 active 拒绝),删除仍拒。存量旧 description 文案由 `ensureStudioProfile` 按 `STUDIO_ROLE_LEGACY_DESCRIPTION` 匹配迁移,用户自定义不覆盖 - **prompt 注入 = index-on-demand**:skills 只注入 name+description+triggers+指针,正文不注入;注入索引候选 = +skill 点名(scope 解析)+ role.skills 显式声明(#462,同权、按名去重)+ 域匹配(acceptedTypes ∪ wu.type 归一化 ∩ skill.agentTypes)三类;知识分层(rule/context 全量、signal 索引、reference 报条数);分段软定额+池内余量共享截断(persona 300/roster 400/skills 600/map 800/memory 300/knowledge 1000/files 400/contract 200/handoff 800);段序 persona->roster->skills->map->memory->knowledge->files->base->contract->handoff->hint - **三层超时**:步墙钟 1800s 兜底+静默看门狗(300s warn/600s 杀进程组)+maxTurns=50 @@ -42,7 +42,7 @@ Agent 配置(profile)、运行实例(instance)、决策循环(loop) - **多实例单活**:`STUDIO_AGENT_LOOP_ENABLED=false` 实例 standby;`AgentLoop.start()` 内置同角色单活守卫 - **SSE 负载含 channelId(2026-08-24 SSE 负载加深,批 1)**:`workunit.execution.step` / `workunit.execution.stream`(含 step-start)负载与 `workunit.tokens` SSE 信封 data 均携带 `channelId`(wu.channelId 透传,无频道 WU 缺省该键)——前端按频道过滤 step/token 事件的数据源;`workunit:tokens` 落盘后顺带经 eventBus.publish 发 SSE(best-effort,不落盘二次) - **派单链**:WorkUnitService.create -> workunit.created -> TriggerScheduler -> AgentLoop.observe(15s 轮询兜底)-> 过滤 -> claim -> agentStep -> LocalExecutor -> spawn CLI -> recordResult -> 回帖(EventBus/SSE)。#445 起「认领即发声」(#175 决策 1)不再是 loop 私有实现:claimAndAnnounce 薄封装委托 workunit/claim-announce.ts 的 claimWorkUnitAndAnnounce 原语,REST claim 端点同路径(契约测试双入口间谍锁定,见 workunit/__tests__/claim-announce.test.ts) -- **observe 读路径优化(#330)**:observe 调 `queryAllMessages` 传 myActive WU 的 channelId 集合做频道预过滤(任一活跃 WU 无 channelId 退全扫;已接受盲区 = WU 换频道后旧频道新回复不扫);loop start 订阅 `channel.message_sent`(eventBus 同进程,human 且 workUnitId ∈ myActive 命中即打断空闲 sleep 立即 observe,stop 退订)——空闲兜底轮询维持 15s 不变(事件 fire-and-forget 无持久,防跨进程写者/重启间隙) +- **observe 读路径优化(#330)**:observe 调 `queryAllMessages` 传 myActive WU 的 channelId 集合做频道预过滤(任一活跃 WU 无 channelId 退全扫;已接受盲区 = WU 换频道后旧频道新回复不扫);loop start 订阅 `channel.message_sent`(eventBus 同进程,human 且 workUnitId ∈ myActive 命中即打断空闲 sleep 立即 observe,stop 退订)——空闲兜底轮询维持 15s 不变(事件 fire-and-forget 无持久,防跨进程写者/重启间隙)。**#493 唤醒加固**:①唤醒闩锁 `pendingWake`——事件到达时 loop 不在 idleSleep(执行中/步骤间,wakeIdle 为 null)则置闩,下一次 idleSleep 入口消费并立即放行重跑 observe,唤醒不再丢(无事件时 15s 调度不变);②新回复检测 `msg.createdAt > wu.updatedAt` 改 `>=`——同毫秒落盘(含重启间隙首扫捞回)不再漏检,消费后 recordResult 簿记推进 updatedAt 不会误循环 - **F4 review 派发**:父 in_review -> 建未指派 review 子 WU 走 claim 涌现;excludeAssignee 禁自领;同父唯一性 flock 锁。#466 起 review-dispatcher 先查频道路由表 review 档(channels/routing.ts):命中则指名评审角色(assigneeId 硬约束取代涌现+排除);路由=实现者本人/角色 inactive/移出频道 → 回池涌现(现状语义不变)+ 频道出声提醒 - **R3 评审契约**:评审子 WU scope = diff-only+`+code-review`;needs-info -> 转人工 - **不派评审类型**:decision/spec/analysis 走人工 in_review diff --git a/apps/api/src/modules/agents/__tests__/agent-loop-wakeup.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop-wakeup.test.ts index b1565224..ee0af61d 100644 --- a/apps/api/src/modules/agents/__tests__/agent-loop-wakeup.test.ts +++ b/apps/api/src/modules/agents/__tests__/agent-loop-wakeup.test.ts @@ -205,3 +205,96 @@ describe('#330: observe 扫描裁剪 + 事件驱动唤醒', () => { await agentLoop.waitForStop(); }); }); + +// #493: 新回复检测同毫秒边界(>=)+ 唤醒闩锁(事件到达时不在 idleSleep 不丢唤醒) +// 均不 start()——纯 seam 直驱(observe / onChannelMessageSent / idleSleep),确定性无竞态 +describe('#493: 新回复同毫秒边界 + 唤醒闩锁', () => { + let testDir: string; + let fileStore: FileStore; + let wuService: WorkUnitService; + let channelId: string; + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-loop-493-')); + fileStore = new FileStore(testDir); + wuService = new WorkUnitService(fileStore); + channelId = `ch-493-${Date.now()}`; + }); + + afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); + }); + + /** seam:私有成员访问(TS private 仅编译期) */ + interface Seam493 { + alive: boolean; + lastActiveWuIds: Set; + pendingWake: boolean; + onChannelMessageSent(payload: { message?: { authorType?: string; workUnitId?: string | null } }): void; + idleSleep(ms: number): Promise; + observe(): Promise<{ newReplies: { id: string }[] }>; + } + const seamOf = (loop: AgentLoop) => loop as unknown as Seam493; + + const humanMsg = (id: string, workUnitId: string, createdAt: string) => ({ + id, channelId, authorType: 'human' as const, agentName: null, + content: `回复-${id}`, replyToId: null, meta: '{}', workUnitId, createdAt, + }); + + it('同毫秒边界:msg.createdAt == wu.updatedAt 的回复被检为新回复(>=)', async () => { + const loop = new AgentLoop(mockRole, fileStore); + const wu = await wuService.create({ + scope: '挂起等回复', channelId, type: 'task', + status: 'blocked', assigneeId: 'some-instance', + metadata: { waitingForInput: true }, + }); + const sameMs = wu.updatedAt.toISOString(); + const olderMs = new Date(wu.updatedAt.getTime() - 1).toISOString(); + await fileStore.appendMessage(channelId, humanMsg('m-same-ms', wu.id, sameMs)); + await fileStore.appendMessage(channelId, humanMsg('m-older', wu.id, olderMs)); + + const obs = await seamOf(loop).observe(); + + expect(obs.newReplies.some(m => m.id === 'm-same-ms')).toBe(true); + expect(obs.newReplies.some(m => m.id === 'm-older')).toBe(false); + }); + + it('唤醒闩锁:事件到达时不在 idleSleep → 置闩,下一次 idleSleep 立即放行并消费', async () => { + const loop = new AgentLoop(mockRole, fileStore); + const seam = seamOf(loop); + seam.alive = true; + seam.lastActiveWuIds = new Set(['wu-1']); + + seam.onChannelMessageSent({ message: { authorType: 'human', workUnitId: 'wu-1' } }); + expect(seam.pendingWake).toBe(true); + + const t0 = Date.now(); + await seam.idleSleep(15_000); // 闩锁命中 → 不睡满 15s + expect(Date.now() - t0).toBeLessThan(1000); + expect(seam.pendingWake).toBe(false); // 已消费,不残留 + }); + + it('闩锁不误置:非 human / 非 myActive / 无 workUnitId 的事件不置闩', () => { + const loop = new AgentLoop(mockRole, fileStore); + const seam = seamOf(loop); + seam.alive = true; + seam.lastActiveWuIds = new Set(['wu-1']); + + seam.onChannelMessageSent({ message: { authorType: 'agent', workUnitId: 'wu-1' } }); + seam.onChannelMessageSent({ message: { authorType: 'human', workUnitId: 'wu-stranger' } }); + seam.onChannelMessageSent({ message: { authorType: 'human', workUnitId: null } }); + seam.onChannelMessageSent({}); + expect(seam.pendingWake).toBe(false); + }); + + it('无闩时 idleSleep 正常睡足(闩锁不改变无事件时的空闲调度)', async () => { + const loop = new AgentLoop(mockRole, fileStore); + const seam = seamOf(loop); + seam.alive = true; + + const t0 = Date.now(); + await seam.idleSleep(60); + expect(Date.now() - t0).toBeGreaterThanOrEqual(50); + expect(seam.pendingWake).toBe(false); + }); +}); diff --git a/apps/api/src/modules/agents/agent-profile.service.ts b/apps/api/src/modules/agents/agent-profile.service.ts index cfbb41b2..a21ff1dc 100644 --- a/apps/api/src/modules/agents/agent-profile.service.ts +++ b/apps/api/src/modules/agents/agent-profile.service.ts @@ -11,7 +11,7 @@ import { randomUUID } from 'crypto'; import fs from 'node:fs'; import path from 'node:path'; import yaml from 'js-yaml'; -import { eventBus, FileStore, parseChannels, stringifyChannels, type AgentProfileData } from '@dommaker/studio-shared'; +import { eventBus, FileStore, parseChannels, stringifyChannels, type AgentProfileData, type ChannelData } from '@dommaker/studio-shared'; import { resolveDefaultProvider } from './default-provider.js'; import { summarizeRoleStates } from './agent-instance.service.js'; @@ -345,14 +345,21 @@ export class AgentProfileService { await this.fileStore.deleteProfile(id); // 清理 channel.members 中的悬空引用(channel.members 是成员关系唯一事实源) + // #497: 同步收敛 channel.routing 中指名该 profile 的档(归一化为 null,其他档不动)—— + // 否则悬空指名此后每次派生单都触发 fallback 提醒,配置漂移无人修。 const channels = await this.fileStore.listChannels(); for (const ch of channels) { const ids = parseChannels(ch.members); - if (ids.includes(id)) { - await this.fileStore.updateChannel(ch.id, { - members: JSON.stringify(ids.filter(m => m !== id)), - }); - } + const memberHit = ids.includes(id); + const routing = ch.routing; + const routingHit = routing != null && Object.values(routing).some(v => v === id); + if (!memberHit && !routingHit) continue; + await this.fileStore.updateChannel(ch.id, { + ...(memberHit ? { members: JSON.stringify(ids.filter(m => m !== id)) } : {}), + ...(routingHit + ? { routing: Object.fromEntries(Object.entries(routing!).map(([k, v]) => [k, v === id ? null : v])) as ChannelData['routing'] } + : {}), + }); } // F1: notify AgentLoopRegistry (unmounts the loop) eventBus.publish('agent-profile.deleted', { profileId: id }); diff --git a/apps/api/src/modules/agents/loop/agent-loop.ts b/apps/api/src/modules/agents/loop/agent-loop.ts index feffc211..fb7938be 100644 --- a/apps/api/src/modules/agents/loop/agent-loop.ts +++ b/apps/api/src/modules/agents/loop/agent-loop.ts @@ -151,6 +151,9 @@ export class AgentLoop { private lastActiveWuIds = new Set(); /** #330:channel.message_sent 订阅句柄(start 挂、stop 退) */ private messageSentHandler: ((payload: { message?: { authorType?: string; workUnitId?: string | null } }) => void) | null = null; + /** #493:唤醒闩锁——事件到达时 loop 不在 idleSleep(执行中/步骤间)则置 true, + * 下一次 idleSleep 入口消费并立即放行重跑 observe,唤醒不再丢失(原 fire-and-forget 丢此类唤醒) */ + private pendingWake = false; private executor: Executor; /** 2026-07 PMO-flow UX(§6-2):最后一次已发布的 instance 状态(SSE 去重——状态不变不发) */ private lastPublishedStatus: string | null = null; @@ -284,13 +287,10 @@ export class AgentLoop { // #330: 事件驱动唤醒——订阅 channel.message_sent(同进程 eventBus,先例 channel-review)。 // 人类消息且 workUnitId 命中当前 myActive 时打断空闲 sleep、立即跑一轮 observe; // 窄过滤天然限流,不去抖。事件 fire-and-forget 无持久,15s 空闲兜底轮询保留 - // (防未来跨进程写者与重启间隙——当前生产写消息路径全部经 channel-message.service 发事件)。 - this.messageSentHandler = (payload) => { - const msg = payload?.message; - if (!msg || msg.authorType !== 'human' || !msg.workUnitId) return; - if (!this.lastActiveWuIds.has(msg.workUnitId)) return; - this.wakeIdle?.(); - }; + // (防未来跨进程写者与重启间隙——当前生产写消息路径全部经 channel-message.service 发事件; + // 重启间隙的回复由启动首轮 observe 经 newReplies >= 口径捞回,#493)。 + // #493:过滤+闩锁逻辑收进 onChannelMessageSent(seam 可测)。 + this.messageSentHandler = (payload) => this.onChannelMessageSent(payload); eventBus.subscribe('channel.message_sent', this.messageSentHandler); // Main loop (non-blocking — fire and forget like original) @@ -658,11 +658,28 @@ export class AgentLoop { } } + /** #330/#493:channel.message_sent 唤醒过滤 + 闩锁(独立方法供测试 seam 直驱) */ + private onChannelMessageSent(payload: { message?: { authorType?: string; workUnitId?: string | null } }): void { + const msg = payload?.message; + if (!msg || msg.authorType !== 'human' || !msg.workUnitId) return; + if (!this.lastActiveWuIds.has(msg.workUnitId)) return; + // #493:闩锁先行——不在 idleSleep(执行中/步骤间 sleep)时 wakeIdle 为 null, + // 唤醒原样会丢;置闩后下一次 idleSleep 入口消费并立即放行重跑 observe + this.pendingWake = true; + this.wakeIdle?.(); + } + /** #330: 可中断的空闲 sleep——channel.message_sent 命中 myActive(或 stop)时提前返回 */ private idleSleep(ms: number): Promise { if (!this.alive) return Promise.resolve(); // stop 与进入 sleep 的竞态:已停则立即返回 + // #493:消费唤醒闩锁——挂起期间有唤醒意图未送达,不睡直接重跑一轮 observe + if (this.pendingWake) { + this.pendingWake = false; + return Promise.resolve(); + } return new Promise(resolve => { const wake = () => { + this.pendingWake = false; // #493:打断即消费——紧随的 observe 已覆盖本次唤醒意图 clearTimeout(timer); if (this.wakeIdle === wake) this.wakeIdle = null; resolve(); @@ -769,7 +786,9 @@ export class AgentLoop { channelIds: channelFilter, })).filter(msg => { const wu = myActive.find(w => w.id === msg.workUnitId); - return wu && new Date(msg.createdAt).getTime() > wu.updatedAt.getTime(); + // #493:>= 同毫秒边界——回复与 WU 簿记同毫秒落盘(含重启间隙首扫捞回)不再漏检; + // 不误循环:回复被消费后 recordResult 簿记推进 updatedAt,下一轮自然越界 + return wu && new Date(msg.createdAt).getTime() >= wu.updatedAt.getTime(); }) : []; diff --git a/apps/api/src/modules/agents/loop/review-dispatcher.ts b/apps/api/src/modules/agents/loop/review-dispatcher.ts index 1eb75977..8dd4845c 100644 --- a/apps/api/src/modules/agents/loop/review-dispatcher.ts +++ b/apps/api/src/modules/agents/loop/review-dispatcher.ts @@ -26,7 +26,7 @@ import { MANUAL_GATE_TYPES } from '../../workunit/workunit.types.js'; import { readCollab } from '../../workunit/delegation-gate.js'; import { postWuSystemMessage } from '../../workunit/wu-messenger.js'; import { parseWuMetadata, clearSessionBookkeeping } from '../../workunit/wu-metadata.js'; -import { resolveStageRouting, routingFallbackText } from '../../channels/routing.js'; +import { resolveStageRouting, routingFallbackText, shouldEmitFallbackReminder } from '../../channels/routing.js'; import type { ParsedReviewReport } from './review-contract.js'; export class ReviewDispatcher { @@ -169,7 +169,10 @@ export class ReviewDispatcher { pinnedReviewer = routing.profileId; } } else if (routing.fallback) { - routingNotice = routingFallbackText('review', routing); + // #497: 同频道同档同原因冷却窗内不重复出声(建单回池不受影响) + routingNotice = shouldEmitFallbackReminder(parent.channelId!, 'review', routing) + ? routingFallbackText('review', routing) + : null; } const eligible = members?.filter(p => p.id !== implementerId) ?? null; diff --git a/apps/api/src/modules/channels/CONTEXT.md b/apps/api/src/modules/channels/CONTEXT.md index e56c6397..b8d5fd79 100644 --- a/apps/api/src/modules/channels/CONTEXT.md +++ b/apps/api/src/modules/channels/CONTEXT.md @@ -14,7 +14,7 @@ Channel 域:频道 CRUD/成员/路由表、消息创建与路由(replyTo 线 | message-routing.ts | `routeMessage()` / `detectMention()` | 消息路由:replyTo 线程回复 → @mention 派单 → 决策12 默认角色 → 纯文本存储 | | channel.routes.ts | Express router | Channel API 端点(消息/members/routing/suggestions/current-pmo/file-vocabulary 等) | | suggestions.ts | `deriveChannelSuggestions()` / `SUGGESTION_TIMING` | 情境引导片推导(status/action/prompt 三形态,fail-closed) | -| routing.ts | `resolveStageRouting()` / `validateRouting()` / `routingFallbackText()` | #466 频道级「阶段→角色」路由表(plan/implement/review)解析与 PATCH 校验单一事实源 | +| routing.ts | `resolveStageRouting()` / `validateRouting()` / `routingFallbackText()` / `shouldEmitFallbackReminder()` / `buildMemberRemovalWarning()` | #466 频道级「阶段→角色」路由表(plan/implement/review)解析与 PATCH 校验单一事实源;#497 漂移收敛(冷却去重 + 移出 warning) | | migrate-routing.ts | `migrateDefaultPipelineToRouting()` | #466 启动幂等迁移:存量 defaultPipeline 吞并入 routing.implement | | migrate-members.ts | `migrateProfileChannelsToMembers()` | 启动幂等迁移:AgentProfile.channels → channel.members 唯一事实源 | | current-pmo.ts | `deriveChannelCurrentPmo()` | 「当前 PMO」chip 派生(不落库现算) | @@ -41,8 +41,8 @@ Channel 域:频道 CRUD/成员/路由表、消息创建与路由(replyTo 线 - **outputFile 唯一性**:Claude 通过 Write tool 写文件,stdout 只有 "DONE"。文件丢失 = 数据丢失 - **鉴权分层(2026-07-24 收紧,姿态 A)**:`/api/v1/channels` 在 PUBLIC_API —— GET(`/`、`/:id`、`/:id/messages`)保持**匿名公开**(Lurk Wall 围观本体,不要再给 GET 加中间件);9 条写端点(建频道/发消息/删频道/archive/restore/PATCH/members/convert-to-task×2)= `requireAuth()+requireNotGuest()`(card-decision 端点 #356 起随 auditor_suggestion 卡接线 review-proposal 正本而删)。注意 `POST /:id/messages` 经 @mention 派单/恢复挂起 WU 可直接触发 agent 执行与 LLM 消耗,是收紧前最危险的匿名入口。requirements-docs PUT 同为 requireNotGuest - **apiCache 写后失效(#448,2026-09-02)**:`GET /` 挂 30s apiCache,频道记录写端点(POST `/`、PATCH `/:id`、PATCH `/:id/members`、DELETE `/:id`、PUT archive/restore)成功后 `clearCache(req.baseUrl)` 失效列表缓存——缓存 key 含 baseUrl(api-cache.ts `generateCacheKey`),按挂载点前缀精确清除;`POST /:id/messages` 等不改频道记录的端点不清(高频消息保持缓存有效) -- **消息路由优先级**(`message-routing.ts` routeMessage):`replyToId` 线程回复(继承父消息 workUnitId;**#327 起父消息不在热层——已归档/不存在不可区分——降级放行**:帖子成立、replyToId 保留、workUnitId 继承失效落 null、不整帖抛错)→ `@mention` 派单(建 WorkUnit,`metadata.creationMode='mention'`;§9.5 只匹配本频道 members,members 为空回退全量 active profile)→ 纯文本仅存储。mention = 纯文本 `@name`(无结构化 id),检测与 scope 剥离用 Unicode 正则 `[\p{L}\p{N}_-]+/u`;手打中文连写无空格(`@开发你好`)匹配不到——前端补全插入带尾随空格,主路径不受影响。归属解析出 PMO 项目时创建期落 `metadata.pmoId`(2026-08 归因统一 canonical key;原 `ownershipProjectId` 废弃不再写入,读取侧同级兼容)。**#464 出声**:@mention 未匹配(角色不存在/非本频道成员;@studio 无默认入口)→ 线程回「未找到角色/未配置入口,已转自动认领」系统消息(此前 WU 照建但频道零提示);无归属挂起提问带 milestone(atHuman 响铃 + 行动中心通知) -- **频道工单路由表(#466,2026-09-09)**:`routing.ts` 是唯一解析/校验事实源——`ChannelData.routing = { plan?, implement?, review? }`(profile id,某档空 = 回池涌现);`resolveStageRouting` 判定 = 配置了且角色 active 且频道成员(members 空回退 profile.channels,agent-loop 同口径)才指名,否则回池涌现 + 调用方用 `routingFallbackText` 发频道提醒;`validateRouting` 供 PATCH `/channels/:id`(值须 active profile id,'' /null = 清档,不强制成员——成员边界路由时判定,defaultProfileId 先例),PATCH 与存量合并(单档更新不清其他档)。三消费点:publish 建 plan 单(plan 档,显式 assigneeId 优先)、analysis-handoff TASK 拆派工 + workunit feature 展开(implement 档)、review-dispatcher 建评审单(review 档,路由=实现者本人时回退涌现——不许自评指名)。`migrate-routing.ts` 启动幂等吞并:存量 `defaultPipeline`(AC-6.1 name 数组,已退役)首跳 name→id 落 `routing.implement` 并删旧字段,无法解析按死配置移除 +- **消息路由优先级**(`message-routing.ts` routeMessage):`replyToId` 线程回复(继承父消息 workUnitId;**#327 起父消息不在热层——已归档/不存在不可区分——降级放行**:帖子成立、replyToId 保留、workUnitId 继承失效落 null、不整帖抛错)→ `@mention` 派单(建 WorkUnit,`metadata.creationMode='mention'`;§9.5 只匹配本频道 members,members 为空回退全量 active profile)→ 纯文本仅存储。mention = 纯文本 `@name`(无结构化 id),检测与 scope 剥离用 Unicode 正则 `[\p{L}\p{N}_-]+/u`;手打中文连写无空格(`@开发你好`)由 **#496 前缀兜底**兜住——token 精确匹配失败时对成员名做最长前缀匹配(互为前缀取最长,等长歧义/同名 profile 并列回退未匹配现状),命中后 scope 只剥 `@成员名` 保留连写剩余文本,`metadata.mentionName` 落解析后正名;studio 系统角色不参与前缀兜底。归属解析出 PMO 项目时创建期落 `metadata.pmoId`(2026-08 归因统一 canonical key;原 `ownershipProjectId` 废弃不再写入,读取侧同级兼容)。**#464 出声**:@mention 未匹配(角色不存在/非本频道成员;@studio 无默认入口)→ 线程回「未找到角色/未配置入口,已转自动认领」系统消息(此前 WU 照建但频道零提示);无归属挂起提问带 milestone(atHuman 响铃 + 行动中心通知)。**#494 派单原子性**:mention/决策12 两派单路径先落派发消息再建 WU,`metadata.anchorMessageId` 显式携带派发消息 id(认领播报优先锚它,消 created 事件先于派发消息落库的 findAnchorMessage 竞态),建单后 `linkWorkUnit` 回填消息 ↔ WU 关联(best-effort,同 createFromMessage 路径)。**#495 决策12 合并窗口(方案 a)**:defaultProfileId 派单前先查合并目标——同频道最近一条携带 workUnitId 的人类消息在窗口内(默认 5 分钟,`STUDIO_CHANNEL_MERGE_WINDOW_MINUTES` 覆盖)且 WU 在途(pending/unassigned/active/blocked)→ 消息并入该 WU 线程(replyToId=anchorMessageId,workUnitId=该 WU)不新建;blocked 经 resumeWaitingWorkUnit 复活注入,其余状态锁内追加 `metadata.pendingReplies`(loop 下一步 prompt-composer 注入)。窗口外/终态(done/closed/in_review)/@mention 照章新建。合并消息自身携带 workUnitId → 滑动窗口(连发 N 条 = 1 张单)。合并目标 metadata 解析走 `parseWuMetadata` 容错口径(畸形 JSON 落 {}、anchorMessageId 缺省走首根回退),不抛错拖垮整道路由 +- **频道工单路由表(#466,2026-09-09)**:`routing.ts` 是唯一解析/校验事实源——`ChannelData.routing = { plan?, implement?, review? }`(profile id,某档空 = 回池涌现);`resolveStageRouting` 判定 = 配置了且角色 active 且频道成员(members 空回退 profile.channels,agent-loop 同口径)才指名,否则回池涌现 + 调用方用 `routingFallbackText` 发频道提醒;`validateRouting` 供 PATCH `/channels/:id`(值须 active profile id,'' /null = 清档,不强制成员——成员边界路由时判定,defaultProfileId 先例),PATCH 与存量合并(单档更新不清其他档)。三消费点:publish 建 plan 单(plan 档,显式 assigneeId 优先)、analysis-handoff TASK 拆派工 + workunit feature 展开(implement 档)、review-dispatcher 建评审单(review 档,路由=实现者本人时回退涌现——不许自评指名)。`migrate-routing.ts` 启动幂等吞并:存量 `defaultPipeline`(AC-6.1 name 数组,已退役)首跳 name→id 落 `routing.implement` 并删旧字段,无法解析按死配置移除。**#497 漂移收敛(2026-09-11)**:删 profile 同步清各频道 routing 悬空档(agent-profile.service.delete,与清 members 同一循环,幂等);PATCH `/:id/members` 移出被 routing 档/defaultProfileId 指名的角色 → 响应 `data.warning` 提示(不阻断);fallback 提醒过 `shouldEmitFallbackReminder` 冷却闸(进程内 Map,键 = 频道+档+原因,窗 1h `FALLBACK_REMINDER_COOLDOWN_MS`,重启重置),四个消费点(publish/analysis-handoff/workunit 展开/review-dispatcher)均已接线——冷却只压提醒,不影响路由回退本身 - **成员绑定**:`channel.members`(config.json 内 JSON 字符串数组)是成员关系唯一事实源,`AgentProfile.channels` 已废弃(启动时幂等迁移);PATCH `/:id/members` 合并 add/remove 后整体回写;删除 profile 时由 AgentProfileService 反向清理各频道 members 悬空引用 - **系统消息唯一发布路径(2026-08-06)**:`channelMessageService.createAgentMessage` 是唯一会发 `channel.message_sent`(eventBus + SSE)的入口;WU 线程系统消息(anchor + pmoId + atHuman)一律经 `workunit/wu-messenger.postWuSystemMessage` 委托到此——禁止再裸 `fileStore.appendMessage` 发系统消息(不发事件 = 通知铃不响、频道页不实时)。`message-routing` 的 @studio 改派/归属提问两处已迁移;`MessageRecord` 类型自本模块导出供 messenger 返回类型使用 - **@文件引用前半链路(#281,决策 #249/#257)**:`file-ref-vocabulary.ts` —— 候选集 = 频道相关工程(频道默认工程 ∪ 本频道 REQ 挂接 PMO 的 gitRepo+deliveries 多腿 ∪ 杂务 PMO,尾斜杠归一去重,频道内最近 WU metadata.workspaceRoot 优先;UX 划界非安全边界,实时计算不动 project-discovery),词表 = 各仓 `git ls-files` + 进程内存缓存(60s TTL,单仓失败空词表不拖垮整体)。只读端点 `GET /:id/file-vocabulary`(随 GET 族匿名公开)。发送带 `files:[{repo,path}]`(repo 绝对路径同 PMO gitRepos 形,path 原样相对路径,无行范围)→ routeMessage 路由时存在性校验:有效引用写消息 `meta.files`(mention 纯文本不动),失效引用剔除 + Studio 系统播报 + `channel:file_refs_dropped` 事件(reason=not-found/not-in-candidate-set,dropped 封顶前 5 条 + droppedCount 全量,D9 尺寸纪律)。composer(web ChannelInput)@弹框统一分组(上 Agents 下 Files),文件候选 = 词表后缀精确匹配,选中插入纯路径文本(不带 @ 防 mention 正则误吃),发送仅上送正文仍含其路径的引用。后半链路 API 侧已落地(#285):kept refs 写 WU `metadata.fileRefs`(@mention 与决策 12 channel-default 两条建 WU 路径,仅在有有效引用时写字段;线程回复不建 WU 不动);归属链新 rung(ownership-resolver.ts)= 显式 workspaceId > REQ 继承 > 文件引用(全部引用尾斜杠归一后同仓 → `source=file-refs`,workspaceRoot=归一 repo;跨多仓不参与、落下一 rung;空数组等同无引用)> 频道默认 > none;prompt 注入段「## 引用文件」(prompt-composer.ts files 段:稳定前缀最末、knowledge 段之后,定额 400 参与池内余量共享;内容 = 每引用一条块(绝对路径 repo/path + 本工程内相对路径/「位于本工程之外」标注)+ 段尾固定只读约束行;块级截断保注入序前缀 + 段尾「另有 N 条引用未注入」,病态超预算空段不注入;`prompt:section_trimmed` payload 增 keptCount/droppedPaths(≤5)/droppedCount;截断时首步(stepCount 缺失或 0)向 WU 频道播报「引用文件较多,已注入前 N 条」一次,best-effort);agent 侧 chip 见本票 web 侧 @@ -52,10 +52,11 @@ Channel 域:频道 CRUD/成员/路由表、消息创建与路由(replyTo 线 - **channel.message_updated 负载带全量本体(#311,ADR 2026-08-24 D1/D2)**:`updateMessageMeta`/`updateMessage` 两发射点(eventBus + SSE 同负载)additive 增 `message` 字段 = 落库后完整 shaped message(与 `channel.message_sent` 同源 `shapeMessageData()`);顶层既有字段语义不动——注意 `updateMessage` 顶层 `content`/`meta` 仍是**增量**(缺省 undefined),合并后全量真值读 `message.meta`。消费端迁移到读 `message` 已落地(#315);`createdAt` = 不可变诞生时刻(#317,ADR 2026-08-24 message-createdat-immutable)——两更新方法不再 bump,SSE 原位替换与 REST 刷新归位恒一致;#332 起 `createFromMessage`/convert-to-task 两条关联 WU 的直写 append 路径同口径(不 bump,否则 agent-loop `msg.createdAt > wu.updatedAt` 新回复检测把已转换消息误判为新回复);#333 起这两条路径迁移到 `channelMessageService.linkWorkUnit(messageId, workUnitId)`(append 保留 createdAt + eventBus/SSE 双发,负载顶层附 `workUnitId` 增量字段)——消息更新(content/meta/workUnitId)统一收敛到本服务,构造器注入口径同 convert-to-task.service - **人审卡决策端点(#278,决策 #250 D2)——已删(#356)**:`card-decision.service.ts` + `POST /:id/messages/:messageId/card-decision` 随 auditor_suggestion 卡接线 review-proposal 正本删除,审批走通用端点 `/api/v1/review-proposals/auditor/:proposalId/{approve,reject,status}`(采纳建工单语义搬入 agents/auditor/review-adapter.onApprove,工单 metadata.creationMode 仍落 `'card-decision'` 保持历史口径)。状态不再回写 meta.status——已审态由前端查提案状态派生(ADR 2026-08-25 决策 6「打开时查一次」)。requirements_doc / knowledge_confirm 历史产卡链已删,前端已只读化(按钮区隐藏 + 「该确认入口已下线」淡注),无对应端点。 - **消息分页端点(#319,2026-08-24;#327 穿透,2026-08-25)**:`GET /:id/messages` 的 `before` 游标 = 锚点**消息 id**(不再是 timestamp——同毫秒多条会漏/重),分页下沉到 `FileStore.queryMessagesPage`(存储层切片);锚点 id 不存在时返回空页 + hasMore=false。前端 `useChannelEvents.loadMore` 传 `oldest.id`。#327 起穿透冷热边界:首页热层不足 limit 即从冷层补满(热全空也直接出冷,历史永远在),热层见底后游标链入 `archive/messages-YYYY-MM.jsonl` 冷文件继续往回翻(新→旧逐月),跨冷热同毫秒不漏不重、同 id 新→旧先见为准;前端无限滚动无感续历史,「往上翻」= 查看历史的唯一入口。消息存储压实/归档机制与 id 新鲜度契约见 studio-shared CONTEXT.md -- **消息热层/冷层术语(#327,2026-08-25)**:热层 = `channels/{id}/messages.jsonl`(在跑的活);冷层 = `channels/{id}/archive/messages-YYYY-MM.jsonl`(超龄归档,纯可读永不删除)。**归档** = 定期 sweep 把超龄活消息热→冷(WU closedAt + 30 天 / 闲聊 createdAt + 30 天);**穿透** = `queryMessagesPage` 分页跨冷热链式翻页(其余查询面 queryAllMessages/getMessageById/listByWorkUnitId/observe/metrics 全部热只读);**解冻** = WU reopen(closed→unassigned)自动把该 WU 冷消息搬回热层。已归档消息只读:编辑/删除/convert-to-task 走既有 not-found 路径(明确 4xx),replyTo 父在冷层引用降级不崩 -- **频道建议派生端点(#443,spec #441 情境引导 02)**:`suggestions.ts deriveChannelSuggestions` + `GET /:id/suggestions`(匿名只读,仿 current-pmo)——不落库按当前事实现算,fail-closed(前置不满足/拿不准/读取失败 → 空结果不抛出)。输入 = 频道 WU 快照(getIndex:review 子单存在性/租约活性、updatedAt)+ loop 活跃度(复用 agents `summarizeRoleStates` 心跳聚合,5min 窗口单源 INSTANCE_ALIVE_TIMEOUT_MS 不另设)+ 频道 members;状态列口径 = shared deriveDisplayState;「当前工单」拣选后端化(**排除 review 子单**——自动化内部执行单元不作引导对象,否则父单 in_review 时子单 updatedAt 恒新、状态说明永不触发;只剩子单时回退全集)。宽限期集中 `SUGGESTION_TIMING`(reviewDispatchInFlightGraceMs=5min,对齐对账扫描档)。返回 `{ currentWuId, suggestions: [{ id, kind, params }] }`——只出结构化数据,文案由前端模板渲染。status 形态(#443,`auto-review-in-flight`:有未完结 review 子单且(租约活或有在线 loop),或无子单但宽限内有在线 loop);action 形态第一种 = #444(见下条);prompt 形态 = #446(见末条) +- **消息热层/冷层术语(#327,2026-08-25)**:热层 = `channels/{id}/messages.jsonl`(在跑的活);冷层 = `channels/{id}/archive/messages-YYYY-MM.jsonl`(超龄归档,纯可读永不删除)。**归档** = 定期 sweep 把超龄活消息热→冷(WU closedAt + 30 天 / 闲聊 createdAt + 30 天);**穿透** = `queryMessagesPage` 分页跨冷热链式翻页(其余查询面 queryAllMessages/getMessageById/listByWorkUnitId/observe/metrics 全部热只读);**解冻** = WU reopen(closed→unassigned)自动把该 WU 冷消息搬回热层。已归档消息只读:编辑/删除/convert-to-task 走既有 not-found 路径(明确 4xx),replyTo 父在冷层引用降级不崩——#492 起降级放行后频道补发 Studio 系统提示「该话题已归档,回复不会触达任务」(挂该回复线程,best-effort 不阻断,message-routing.ts `!found` 分支) +- **频道建议派生端点(#443,spec #441 情境引导 02)**:`suggestions.ts deriveChannelSuggestions` + `GET /:id/suggestions`(匿名只读,仿 current-pmo)——不落库按当前事实现算,fail-closed(前置不满足/拿不准/读取失败 → 空结果不抛出)。输入 = 频道 WU 快照(getIndex:review 子单存在性/租约活性、updatedAt)+ loop 活跃度(复用 agents `summarizeRoleStates` 心跳聚合,5min 窗口单源 INSTANCE_ALIVE_TIMEOUT_MS 不另设)+ 频道 members;状态列口径 = shared deriveDisplayState;「当前工单」拣选后端化(**排除 review 子单**——自动化内部执行单元不作引导对象,否则父单 in_review 时子单 updatedAt 恒新、状态说明永不触发;只剩子单时回退全集)。宽限期集中 `SUGGESTION_TIMING`(reviewDispatchInFlightGraceMs=5min,对齐对账扫描档)。返回 `{ currentWuId, suggestions: [{ id, kind, params }], degraded }`——只出结构化数据,文案由前端模板渲染。#490:`degraded` = fail-closed 可观测标志(推导内部读取失败被吞 → true + 空 suggestions,语义不变;正常路径含「确实无建议」→ false;非粘性每次现算),前端仅 console 记录且不落「已返回」台账(工作条占位保持加载态不误显空闲),不出任何 UI。status 形态(#443,`auto-review-in-flight`:有未完结 review 子单且(租约活或有在线 loop),或无子单但宽限内有在线 loop);action 形态第一种 = #444(见下条);prompt 形态 = #446(见末条) - **动作片「补派评审」(#444,spec #441 情境引导 03)**:断链判定 = 派生列 in_review 且可自动评审 + 无未完结 review 子单 + 转入超宽限(SUGGESTION_TIMING 同档)→ 产出 `{ id: 'redispatch-review', kind: 'action', params: { wuId, wuTitle } }`,前端点击经一次确认后直调既有 `POST /workunits/:id/dispatch-review`(与 ReviewDispatcher 自动派发同原语 dispatchReviewNow,不经消息路由)。fail-closed 两条与端点守卫同口径:l2 已达成不出(端点必拒)、子单存在但僵死(租约过期无接管)不出(补派会被同父唯一性 409,非本动作可修);子单已终态(done/closed)不挡——可重派。生效后子单建出、前置条件转假,片随下次拉取消失。契约测试 = 推导用例内真跑 `dispatchReviewNow` 断言同原语建单 + 片消失(wu-messenger 间谍包装发声出口) - **动作片「认领」(#445,spec #441 情境引导 04)**:当前工单裸 status === 'unassigned'(与 claim 端点唯一确定性拒绝守卫同口径)且(频道无在线成员 loop = 无人接管,或已超 `SUGGESTION_TIMING.unassignedClaimGraceMs`=5min 宽限 = loop 迟迟未认领)→ 产出 `{ id: 'claim-wu', kind: 'action' }`,前端点击经一次确认直调 `POST /workunits/:id/claim`(认领即发声原语,见 workunit CONTEXT);有在线 loop 且宽限内不出(涌现认领大概率在途,不催人工)。认领生效 → active,前置条件转假片消失。出片分支置于 in_review 门槛之前(unassigned 不走派生列判定) - **prompt 形态片(#446,spec #441 情境引导 05)**:本质是发给 agent 的自然语言任务——预填指令本体由 `ChannelSuggestion.text?` 承载(prompt 专用可选字段,单一正本在后端,契约测试断言 `detectMention(text)` 解析出目标角色 developer/reviewer;展示文案 label/hint 仍由前端模板注册表渲染),点击预填进输入框、人可编辑后发送,走既有 @mention 消息路由(不建确定性接口)。两条产出:① `diagnose-blocked`——派生列 blocked 且无 waitingForInput(人闸由 NeedInputOptions 覆盖)且本 WU 租约不活(「loop 在处理」只看本 WU 租约:blocked 不会被 loop 自动拾起,不看频道在线 loop);params.blockReason = summarizeBlockReason 截断后剥机器类型前缀(timeout:/verify-failed: 等,片上文案说人话);② `transcribe-review-checklist`(可选)——前置门禁窗口 = in_review 可自动评审 + 无未完结 review 子单 + 未超宽限(评审未派发),有在线 loop 时与状态片并存、无在线 loop 时单独出(@mention 建单后 loop 上线即认领);评审在途(有子单)/ 超宽限断链(#444 动作片位置)/ l2 已达成(评审结论已存在)三向不出 - **空成员提示片(#465,首用路径断点)**:`deriveChannelSuggestions` 在「无当前工单 + members 为空」时出只读 status 片 `channel-no-members`(currentWuId=null)——新装三默认频道(channel-init members `[]`)正是此态,角色不进频道等于不存在(mention/loop 认领均以 members 为界)。**只挂在空转频道**:有当前工单在跑的频道由既有片型(claim-wu 等)覆盖不叠加,避免与「空 = 所有 Agent 可见」存量口径正面冲突。文案在前端 suggestionCopy 模板注册表(快照锁定) - **收尾(#447,spec #441 情境引导 06)**:前端静态建议映射(`wuSuggestions`)与 pickCurrentWu 前端副本已删——引导片与阶段条「当前工单」唯一来源 = 本端点(`currentWuId` 同供前端阶段条拣选,口径单源);「出片 ⟺ 前置条件成立」双向不变量矩阵在 suggestions.test.ts 表驱动覆盖 7 状态列(unassigned/active/in_review/blocked/pending/done/closed)× 自动化在途/断链;文案快照全量锁定三形态全模板(含 diagnose-blocked 无 blockReason 变体);paper trail = docs/sdd/channel-contextual-guidance/ +- **currentWu 拣选粘性(#487)**:`pickCurrentWuSnapshot` 在「非终态中 updatedAt 最新者」口径上加进程内 per-channel 现任记忆(`currentWuIncumbent` Map,不落库,重启即按事实重选)——loop 每步 metadata 簿记都 bump updatedAt,多单并行交替簿写时无粘性会让阶段条/引导片来回跳。规则:现任非终态且在候选集时,挑战者 updatedAt 须领先现任超 `SUGGESTION_TIMING.currentWuStickyMs`(=5min,对账扫描档)才切换(= 现任长时间静默粘性解除);现任终态/消失立即让位。无状态阈值规则无法区分「现任自己簿写 bump」与「挑战者超车」(两者同形),故粘性必须记忆现任 diff --git a/apps/api/src/modules/channels/__tests__/message-routing-merge-window.test.ts b/apps/api/src/modules/channels/__tests__/message-routing-merge-window.test.ts new file mode 100644 index 00000000..8a8742ae --- /dev/null +++ b/apps/api/src/modules/channels/__tests__/message-routing-merge-window.test.ts @@ -0,0 +1,202 @@ +// #495: 决策12 默认角色派单合并窗口(方案 a,票内预授权 2026-09-11) +// 短时间窗内同频道连续无 @ 消息合并进在途 WU 线程(继续对话 = 继续任务), +// 不再每条闲聊建一张新 WU;窗口外 / WU 已终态 / @mention 仍正常建单。 +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { v4 as uuidv4 } from 'uuid'; +import { FileStore, type ChannelMessageData } from '@dommaker/studio-shared'; +import { routeMessage, getMergeWindowMs } from '../message-routing.js'; +import { channelMessageService } from '../channel-message.service.js'; +import { WorkUnitService } from '../../workunit/workunit.service.js'; + +let channelId: string; +let tmpDir: string; +let fileStore: FileStore; +let workUnitService: WorkUnitService; + +async function findWu(id: string) { + const snapshots = await fileStore.getIndex(); + return snapshots.find(s => s.id === id) ?? null; +} + +async function countWu(channelId: string): Promise { + return (await fileStore.getIndex()).filter(s => s.channelId === channelId).length; +} + +function parseMeta(raw: unknown): Record { + if (!raw) return {}; + return typeof raw === 'string' ? JSON.parse(raw) : (raw as Record); +} + +describe('#495: 决策12 派单合并窗口(方案 a)', () => { + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msg-routing-merge-test-')); + fileStore = new FileStore(tmpDir); + channelId = `test-merge-${Date.now()}`; + }); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + beforeEach(async () => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + fs.mkdirSync(tmpDir, { recursive: true }); + fileStore = new FileStore(tmpDir); + workUnitService = new WorkUnitService(fileStore); + await fileStore.createChannel({ + id: channelId, name: `#test-merge`, type: 'rnd', + defaultProfileId: 'default-agent-1', + defaultWorkspaceId: null, defaultPath: null, + discordChannelId: null, discordWebhookUrl: null, + members: '[]', + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }); + channelMessageService.setFileStore(fileStore); + }); + + afterEach(() => { + delete process.env.STUDIO_CHANNEL_MERGE_WINDOW_MINUTES; + }); + + it('窗口内第二条无 @ 消息 → 合并进在途 WU,不建新单', async () => { + const first = await routeMessage(channelId, '帮我看下这个报错', undefined, fileStore); + const wuCount = await countWu(channelId); + expect(wuCount).toBe(1); + + const second = await routeMessage(channelId, '日志在这里', undefined, fileStore); + + expect(await countWu(channelId)).toBe(1); // 没有第二张单 + expect(second.workUnitId).toBe(first.workUnitId); + // 合并消息挂在在途 WU 的派发线程下(anchor = 首条派发消息) + expect(second.replyToId).toBe(first.id); + // 内容注入 pendingReplies(unassigned WU 首轮 step 即注入 prompt) + const wu = await findWu(first.workUnitId!); + expect(parseMeta(wu!.metadata).pendingReplies).toEqual(['日志在这里']); + }); + + it('连发 3 条闲聊 → 仍只 1 张 WU(滑动窗口:合并消息刷新窗口锚点)', async () => { + const first = await routeMessage(channelId, '第一条', undefined, fileStore); + await routeMessage(channelId, '第二条', undefined, fileStore); + const third = await routeMessage(channelId, '第三条', undefined, fileStore); + + expect(await countWu(channelId)).toBe(1); + expect(third.workUnitId).toBe(first.workUnitId); + const wu = await findWu(first.workUnitId!); + expect(parseMeta(wu!.metadata).pendingReplies).toEqual(['第二条', '第三条']); + }); + + it('在途 WU 为 blocked(挂起等输入)→ 窗口内合并 = 复活 + 回复注入', async () => { + // 直接造一张挂起 WU + 窗口内的人类消息(模拟刚发生的派单线程回复场景) + const wu = await workUnitService.create({ + scope: '卡住的任务', channelId, type: 'task', status: 'active', assigneeId: 'instance-1', + }); + await workUnitService.transitionStatus(wu.id, 'blocked'); + await workUnitService.update(wu.id, { + metadata: { waitingForInput: true, waitingQuestion: '继续吗?', waitingSince: new Date().toISOString() }, + }); + const lastHuman: ChannelMessageData = { + id: uuidv4(), channelId, authorType: 'human', agentName: null, + content: '刚才的指令', replyToId: null, meta: '{}', workUnitId: wu.id, + createdAt: new Date().toISOString(), + }; + await fileStore.appendMessage(channelId, lastHuman); + + const merged = await routeMessage(channelId, '继续,用方案 B', undefined, fileStore); + + expect(await countWu(channelId)).toBe(1); + expect(merged.workUnitId).toBe(wu.id); + const after = await findWu(wu.id); + expect(after!.status).toBe('active'); + expect(parseMeta(after!.metadata).pendingReplies).toEqual(['继续,用方案 B']); + }); + + it('窗口外(上轮人类消息超过阈值)→ 正常新建 WU', async () => { + const wu = await workUnitService.create({ + scope: '旧任务', channelId, type: 'task', status: 'active', assigneeId: 'instance-1', + }); + // 上轮消息在 10 分钟前(默认窗口 5 分钟之外) + const stale: ChannelMessageData = { + id: uuidv4(), channelId, authorType: 'human', agentName: null, + content: '十分钟前的消息', replyToId: null, meta: '{}', workUnitId: wu.id, + createdAt: new Date(Date.now() - 10 * 60_000).toISOString(), + }; + await fileStore.appendMessage(channelId, stale); + + const result = await routeMessage(channelId, '新话题:帮我部署一下', undefined, fileStore); + + expect(await countWu(channelId)).toBe(2); + expect(result.workUnitId).not.toBe(wu.id); + expect(result.workUnitId).toBeTruthy(); + }); + + it('最近 WU 已终态(done)→ 不合并,新建 WU', async () => { + const wu = await workUnitService.create({ + scope: '已完成任务', channelId, type: 'task', status: 'active', assigneeId: 'instance-1', + }); + await workUnitService.transitionStatus(wu.id, 'in_review'); + await workUnitService.transitionStatus(wu.id, 'done'); + const lastHuman: ChannelMessageData = { + id: uuidv4(), channelId, authorType: 'human', agentName: null, + content: '刚派发的消息', replyToId: null, meta: '{}', workUnitId: wu.id, + createdAt: new Date().toISOString(), + }; + await fileStore.appendMessage(channelId, lastHuman); + + const result = await routeMessage(channelId, '新任务来了', undefined, fileStore); + + expect(await countWu(channelId)).toBe(2); + expect(result.workUnitId).not.toBe(wu.id); + }); + + it('@mention 路径行为不变:窗口内 @ 消息仍建新 WU', async () => { + await routeMessage(channelId, '先聊一句', undefined, fileStore); + expect(await countWu(channelId)).toBe(1); + + const result = await routeMessage(channelId, '@Somebody 显式派单', undefined, fileStore); + + expect(await countWu(channelId)).toBe(2); + const wu = await findWu(result.workUnitId!); + expect(parseMeta(wu!.metadata).creationMode).toBe('mention'); + }); + + it('未配置默认角色的频道不受影响(维持纯存储)', async () => { + await fileStore.updateChannel(channelId, { defaultProfileId: null }); + + const result = await routeMessage(channelId, '纯闲聊', undefined, fileStore); + + expect(result.workUnitId).toBeNull(); + expect(await countWu(channelId)).toBe(0); + }); + + it('在途 WU metadata 为畸形 JSON → 合并不抛错,照章并入(parseWuMetadata 容错口径)', async () => { + const wu = await workUnitService.create({ + scope: '畸形元数据任务', channelId, type: 'task', status: 'active', assigneeId: 'instance-1', + }); + // 直接把快照 metadata 写成坏 JSON(绕过 service 序列化),模拟脏数据 + const snap = await findWu(wu.id); + await fileStore.upsertSnapshot({ ...snap!, metadata: '{corrupted-json' }); + const lastHuman: ChannelMessageData = { + id: uuidv4(), channelId, authorType: 'human', agentName: null, + content: '刚才的指令', replyToId: null, meta: '{}', workUnitId: wu.id, + createdAt: new Date().toISOString(), + }; + await fileStore.appendMessage(channelId, lastHuman); + + const merged = await routeMessage(channelId, '继续补充', undefined, fileStore); + + expect(await countWu(channelId)).toBe(1); + expect(merged.workUnitId).toBe(wu.id); + }); + + it('窗口阈值可由 STUDIO_CHANNEL_MERGE_WINDOW_MINUTES 覆盖', async () => { + process.env.STUDIO_CHANNEL_MERGE_WINDOW_MINUTES = '30'; + expect(getMergeWindowMs()).toBe(30 * 60_000); + delete process.env.STUDIO_CHANNEL_MERGE_WINDOW_MINUTES; + expect(getMergeWindowMs()).toBe(5 * 60_000); // 默认 5 分钟 + process.env.STUDIO_CHANNEL_MERGE_WINDOW_MINUTES = 'abc'; + expect(getMergeWindowMs()).toBe(5 * 60_000); // 非法值回落默认 + }); +}); diff --git a/apps/api/src/modules/channels/__tests__/message-routing.test.ts b/apps/api/src/modules/channels/__tests__/message-routing.test.ts index c4a16668..1226aed5 100644 --- a/apps/api/src/modules/channels/__tests__/message-routing.test.ts +++ b/apps/api/src/modules/channels/__tests__/message-routing.test.ts @@ -268,6 +268,41 @@ describe('Message Routing (AC-B1-B4)', () => { }); }); + // ── #492: 回复冷层父消息 → 频道系统提示(方案 a,不再静默失效)── + + describe('#492: reply to cold-tier parent posts archive notice', () => { + it('父消息在冷层(热层不可见)→ 帖子成立 + Studio 系统提示挂在回复线程', async () => { + const reply = await routeMessage(channelId, '回复已归档话题', 'cold-parent-id', fileStore); + + // 降级放行行为不变(#327):帖子成立、workUnitId 落 null、不抛错 + expect(reply.replyToId).toBe('cold-parent-id'); + expect(reply.workUnitId ?? null).toBeNull(); + // #492:频道给出明确反馈,Studio 系统提示挂在该回复线程 + const msgs = await fileStore.queryMessages(channelId, {}); + const notice = msgs.find(m => + m.authorType === 'agent' && m.agentName === 'Studio' && m.content.includes('该话题已归档'), + ); + expect(notice).toBeTruthy(); + expect(notice!.content).toContain('回复不会触达任务'); + expect(notice!.replyToId).toBe(reply.id); + }); + + it('热层父消息(正常回复路径)→ 不发归档提示', async () => { + const now = new Date().toISOString(); + const original: ChannelMessageData = { + id: uuidv4(), channelId, authorType: 'human', agentName: null, + content: 'original', replyToId: null, meta: '{}', workUnitId: null, createdAt: now, + }; + await fileStore.appendMessage(channelId, original); + + const reply = await routeMessage(channelId, 'follow up', original.id, fileStore); + + expect(reply.replyToId).toBe(original.id); + const msgs = await fileStore.queryMessages(channelId, {}); + expect(msgs.find(m => m.content.includes('该话题已归档'))).toBeUndefined(); + }); + }); + // ── AC-B3: Thread @mention = feedback, no new WorkUnit ── describe('AC-B3: Thread @mention does not create new WorkUnit', () => { @@ -532,4 +567,152 @@ describe('Message Routing (AC-B1-B4)', () => { expect(meta.mentionName).toBe('开发'); }); }); + + // ── #496: mention 手打中文连写——成员名最长前缀匹配兜底 ── + + describe('#496: 手打连写 mention 的成员名前缀匹配兜底', () => { + function activeProfile(id: string, name: string): AgentProfileData { + return { + id, name, description: `test agent ${name}`, + channels: '[]', status: 'active', provider: null, + createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + }; + } + + it('成员名前缀连写可命中(中文名):@开发你好 → 成员「开发」,scope 保留剩余文本', async () => { + const dev = activeProfile('dev-agent-1', '开发'); + await fileStore.createProfile(dev); + await fileStore.updateChannel(channelId, { members: JSON.stringify([dev.id]) }); + + const result = await routeMessage(channelId, '@开发你好', undefined, fileStore); + + const wu = await findWu(result.workUnitId!); + expect(wu!.assigneeId).toBe(dev.id); + expect(wu!.scope).toBe('你好'); + const meta = wu!.metadata ? JSON.parse(wu!.metadata) : {}; + expect(meta.matched).toBe(true); + expect(meta.mentionName).toBe('开发'); + // 命中即无 #464 未匹配提示 + const msgs = await fileStore.queryMessages(channelId, { workUnitId: result.workUnitId! }); + expect(msgs.find(m => m.content.includes('未找到角色'))).toBeUndefined(); + }); + + it('多成员互为前缀取最长:成员「开发」「开发组长」,@开发组长看下 → 命中「开发组长」', async () => { + const dev = activeProfile('dev-agent-1', '开发'); + const lead = activeProfile('lead-agent-1', '开发组长'); + await fileStore.createProfile(dev); + await fileStore.createProfile(lead); + await fileStore.updateChannel(channelId, { members: JSON.stringify([dev.id, lead.id]) }); + + const result = await routeMessage(channelId, '@开发组长看下这个问题', undefined, fileStore); + + const wu = await findWu(result.workUnitId!); + expect(wu!.assigneeId).toBe(lead.id); + expect(wu!.scope).toBe('看下这个问题'); + }); + + it('等长歧义回退现状:两个同名 profile 并列最长 → 未匹配(转自动认领提示)', async () => { + const a = activeProfile('dup-agent-a', '开发'); + const b = activeProfile('dup-agent-b', '开发'); + await fileStore.createProfile(a); + await fileStore.createProfile(b); + await fileStore.updateChannel(channelId, { members: JSON.stringify([a.id, b.id]) }); + + // 精确匹配本身也歧义(find 取第一个)——用连写构造纯前缀歧义场景 + const result = await routeMessage(channelId, '@开发你好', undefined, fileStore); + + const wu = await findWu(result.workUnitId!); + expect(wu!.assigneeId).toBeNull(); + const meta = wu!.metadata ? JSON.parse(wu!.metadata) : {}; + expect(meta.matched).toBe(false); + const msgs = await fileStore.queryMessages(channelId, { workUnitId: result.workUnitId! }); + const notice = msgs.find(m => m.authorType === 'agent' && m.content.includes('未找到角色')); + expect(notice).toBeTruthy(); + }); + + it('非成员文本不误命中:成员为界——「开发」在册但非本频道成员 → 行为同现状', async () => { + const outsider = activeProfile('outsider-dev', '开发'); + await fileStore.createProfile(outsider); + await fileStore.updateChannel(channelId, { members: JSON.stringify(['some-other-profile']) }); + + const result = await routeMessage(channelId, '@开发团队 看一下', undefined, fileStore); + + const wu = await findWu(result.workUnitId!); + expect(wu!.assigneeId).toBeNull(); + const meta = wu!.metadata ? JSON.parse(wu!.metadata) : {}; + expect(meta.matched).toBe(false); + }); + + it('无此成员时行为同现状:@开发团队 无成员「开发」→ matched=false + 未找到角色提示', async () => { + const result = await routeMessage(channelId, '@开发团队 看一下', undefined, fileStore); + + const wu = await findWu(result.workUnitId!); + expect(wu!.assigneeId).toBeNull(); + const meta = wu!.metadata ? JSON.parse(wu!.metadata) : {}; + expect(meta.matched).toBe(false); + const msgs = await fileStore.queryMessages(channelId, { workUnitId: result.workUnitId! }); + const notice = msgs.find(m => m.authorType === 'agent' && m.content.includes('未找到角色')); + expect(notice).toBeTruthy(); + expect(notice!.content).toContain('开发团队'); + }); + }); + + // ── #494: 派单建 WU 与派发消息非原子 → anchorMessageId 显式传递消竞态 ── + + describe('#494 派单线程单根(anchorMessageId 显式传递)', () => { + it('@mention 派单:WU metadata.anchorMessageId = 派发消息 id,派发消息回填 workUnitId', async () => { + await createTestAgent(fileStore, 'AnchorAgent'); + + const result = await routeMessage(channelId, '@AnchorAgent 处理这个任务', undefined, fileStore); + + const wu = await findWu(result.workUnitId!); + expect(wu).not.toBeNull(); + const meta = wu!.metadata ? JSON.parse(wu!.metadata) : {}; + expect(meta.anchorMessageId).toBe(result.id); + const stored = await fileStore.getMessageById(result.id); + expect(stored!.message.workUnitId).toBe(wu!.id); + }); + + it('竞态时序:created 处理器内同步抢跑认领 → 认领播报锚在派发消息下,线程单根', async () => { + await createTestAgent(fileStore, 'RaceAgent'); + // 构造竞态:eventBus.publish 同步派发、不等待订阅侧(event-bus.ts), + // 在 created 处理器内立即认领并发声——复刻 agent-loop observe→claim→announce + // 与派发消息落库的抢跑(#494 票体时序)。修复后 anchor 取自 WU metadata + // (建单时已落档),不依赖 findAnchorMessage 的落库先后。 + const { claimWorkUnitAndAnnounce } = await import('../../workunit/claim-announce.js'); + let claimed: Promise | null = null; + const handler = (payload: { workunit: { id: string } }) => { + claimed = claimWorkUnitAndAnnounce(payload.workunit.id, 'instance-race', 'RaceAgent', { + wuService: workUnitService, fileStore, + }); + }; + eventBus.subscribe('workunit.created', handler); + try { + // 显式 workspaceId:跳过 B3a 无归属挂起(blocked 不可认领),聚焦 anchor 竞态本身 + const result = await routeMessage(channelId, '@RaceAgent 抢跑认领', undefined, fileStore, { workspaceId: 'ws-race' }); + await claimed; + + const msgs = await fileStore.queryMessages(channelId, { workUnitId: result.workUnitId! }); + const roots = msgs.filter(m => !m.replyToId); + expect(roots.map(m => m.id)).toEqual([result.id]); + const announce = msgs.find(m => m.content.includes('已认领任务')); + expect(announce).toBeDefined(); + expect(announce!.replyToId).toBe(result.id); + } finally { + eventBus.unsubscribe('workunit.created', handler); + } + }); + + it('决策 12 频道默认角色派单:同样落 anchorMessageId + 回填 workUnitId', async () => { + await fileStore.updateChannel(channelId, { defaultProfileId: 'default-agent-1' }); + + const result = await routeMessage(channelId, '没有点名的消息', undefined, fileStore); + + const wu = await findWu(result.workUnitId!); + const meta = wu!.metadata ? JSON.parse(wu!.metadata) : {}; + expect(meta.anchorMessageId).toBe(result.id); + const stored = await fileStore.getMessageById(result.id); + expect(stored!.message.workUnitId).toBe(wu!.id); + }); + }); }); diff --git a/apps/api/src/modules/channels/__tests__/routing-drift.test.ts b/apps/api/src/modules/channels/__tests__/routing-drift.test.ts new file mode 100644 index 00000000..31ae62cb --- /dev/null +++ b/apps/api/src/modules/channels/__tests__/routing-drift.test.ts @@ -0,0 +1,175 @@ +/** + * #497: routing/members 配置漂移收敛 + * + * AC1: 删除 profile 后各频道 routing 无悬空引用(幂等,其他档/其他频道不动) + * AC2: members 移出被指名角色 → buildMemberRemovalWarning 产出 warning(不阻断) + * AC3: 同频道同档同原因 fallback 提醒冷却窗内不重复(shouldEmitFallbackReminder) + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { FileStore, stringifyChannels } from '@dommaker/studio-shared'; +import { AgentProfileService } from '../../agents/agent-profile.service.js'; +import { + buildMemberRemovalWarning, + shouldEmitFallbackReminder, + resetFallbackReminderCooldown, + FALLBACK_REMINDER_COOLDOWN_MS, +} from '../routing.js'; + +let tmpDir: string; +let fileStore: FileStore; +let service: AgentProfileService; + +const now = '2026-09-11T00:00:00.000Z'; + +async function seedProfile(id: string, name: string) { + await fileStore.createProfile({ + id, name, description: null, + channels: '[]', status: 'active', createdAt: now, updatedAt: now, + }); +} + +async function seedChannel(id: string, opts: { + members?: string[]; + routing?: Record; + defaultProfileId?: string | null; +} = {}) { + await fileStore.createChannel({ + id, name: `#${id}`, type: 'rnd', + defaultWorkspaceId: null, defaultPath: null, + discordChannelId: null, discordWebhookUrl: null, + members: stringifyChannels(opts.members ?? []), + ...(opts.routing !== undefined ? { routing: opts.routing } : {}), + ...(opts.defaultProfileId !== undefined ? { defaultProfileId: opts.defaultProfileId } : {}), + createdAt: now, updatedAt: now, + }); +} + +beforeEach(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'routing-drift-test-')); + fileStore = new FileStore(tmpDir); + service = new AgentProfileService(fileStore); + resetFallbackReminderCooldown(); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('#497 AC1: 删除 profile 收敛各频道 routing', () => { + it('删除被指名 profile → 引用它的 routing 档归一化为 null,其他档/其他频道不动', async () => { + await seedProfile('p-1', 'planner'); + await seedProfile('p-2', 'exec'); + await seedChannel('ch-a', { members: ['p-1', 'p-2'], routing: { plan: 'p-1', implement: 'p-2' } }); + await seedChannel('ch-b', { members: ['p-1'], routing: { review: 'p-1' } }); + await seedChannel('ch-c', { members: ['p-2'], routing: { plan: 'p-2' } }); + + await service.delete('p-1'); + + const a = await fileStore.getChannel('ch-a'); + expect(a!.routing).toEqual({ plan: null, implement: 'p-2' }); + expect(JSON.parse(a!.members)).toEqual(['p-2']); + + const b = await fileStore.getChannel('ch-b'); + expect(b!.routing).toEqual({ review: null }); + expect(JSON.parse(b!.members)).toEqual([]); + + // 不引用 p-1 的频道完全不动 + const c = await fileStore.getChannel('ch-c'); + expect(c!.routing).toEqual({ plan: 'p-2' }); + expect(JSON.parse(c!.members)).toEqual(['p-2']); + }); + + it('幂等:删除未被任何 routing/members 引用的 profile → 频道配置零改动', async () => { + await seedProfile('p-free', 'free-agent'); + await seedProfile('p-kept', 'kept'); + await seedChannel('ch-x', { members: ['p-kept'], routing: { plan: 'p-kept' } }); + + await service.delete('p-free'); + + const x = await fileStore.getChannel('ch-x'); + expect(x!.routing).toEqual({ plan: 'p-kept' }); + expect(JSON.parse(x!.members)).toEqual(['p-kept']); + }); + + it('members 有引用但 routing 无引用 → 只清 members,不无中生有 routing 字段', async () => { + await seedProfile('p-m', 'member-only'); + await seedChannel('ch-m', { members: ['p-m'] }); + + await service.delete('p-m'); + + const ch = await fileStore.getChannel('ch-m'); + expect(JSON.parse(ch!.members)).toEqual([]); + expect(ch!.routing).toBeUndefined(); + }); +}); + +describe('#497 AC2: members 移出被指名角色 → warning', () => { + it('移出角色被 routing 档指名 → warning 含阶段标签', async () => { + await seedChannel('ch-w', { routing: { plan: 'p-1', review: 'p-1' } }); + const ch = await fileStore.getChannel('ch-w'); + const warning = buildMemberRemovalWarning(ch!, ['p-1']); + expect(warning).toBeTruthy(); + expect(warning).toContain('规划'); + expect(warning).toContain('评审'); + }); + + it('移出角色被指名为入口角色 defaultProfileId → warning 含入口角色', async () => { + await seedChannel('ch-w2', { defaultProfileId: 'p-1' }); + const ch = await fileStore.getChannel('ch-w2'); + const warning = buildMemberRemovalWarning(ch!, ['p-1']); + expect(warning).toBeTruthy(); + expect(warning).toContain('入口角色'); + }); + + it('移出角色未被任何指名引用 → 无 warning', async () => { + await seedChannel('ch-w3', { routing: { plan: 'p-1' }, defaultProfileId: 'p-2' }); + const ch = await fileStore.getChannel('ch-w3'); + expect(buildMemberRemovalWarning(ch!, ['p-other'])).toBeUndefined(); + }); + + it('routing 档值为 null(已清除)→ 不算指名,无 warning', async () => { + await seedChannel('ch-w4', { routing: { plan: null } }); + const ch = await fileStore.getChannel('ch-w4'); + expect(buildMemberRemovalWarning(ch!, ['p-1'])).toBeUndefined(); + }); + + it('remove 为空 → 无 warning', async () => { + await seedChannel('ch-w5', { routing: { plan: 'p-1' } }); + const ch = await fileStore.getChannel('ch-w5'); + expect(buildMemberRemovalWarning(ch!, [])).toBeUndefined(); + }); +}); + +describe('#497 AC3: fallback 提醒冷却去重', () => { + const resolution = { profileId: null, fallback: 'not-member' as const, profileName: 'outsider' }; + + it('同频道同档同原因:冷却窗内第二次 → false', () => { + expect(shouldEmitFallbackReminder('ch-1', 'implement', resolution)).toBe(true); + expect(shouldEmitFallbackReminder('ch-1', 'implement', resolution)).toBe(false); + }); + + it('不同档 → 各自计时', () => { + expect(shouldEmitFallbackReminder('ch-1', 'implement', resolution)).toBe(true); + expect(shouldEmitFallbackReminder('ch-1', 'review', resolution)).toBe(true); + }); + + it('不同原因 → 各自计时', () => { + expect(shouldEmitFallbackReminder('ch-1', 'implement', resolution)).toBe(true); + expect(shouldEmitFallbackReminder('ch-1', 'implement', { ...resolution, fallback: 'inactive' })).toBe(true); + }); + + it('不同频道 → 各自计时', () => { + expect(shouldEmitFallbackReminder('ch-1', 'implement', resolution)).toBe(true); + expect(shouldEmitFallbackReminder('ch-2', 'implement', resolution)).toBe(true); + }); + + it('冷却窗过后 → 再次放行', () => { + const t0 = 1_000_000; + expect(shouldEmitFallbackReminder('ch-1', 'implement', resolution, t0)).toBe(true); + expect(shouldEmitFallbackReminder('ch-1', 'implement', resolution, t0 + FALLBACK_REMINDER_COOLDOWN_MS - 1)).toBe(false); + expect(shouldEmitFallbackReminder('ch-1', 'implement', resolution, t0 + FALLBACK_REMINDER_COOLDOWN_MS)).toBe(true); + }); +}); diff --git a/apps/api/src/modules/channels/__tests__/suggestions.test.ts b/apps/api/src/modules/channels/__tests__/suggestions.test.ts index c584400f..a45a43ba 100644 --- a/apps/api/src/modules/channels/__tests__/suggestions.test.ts +++ b/apps/api/src/modules/channels/__tests__/suggestions.test.ts @@ -117,7 +117,7 @@ describe('deriveChannelSuggestions(推导骨架)', () => { it('无 WU + 有成员 → currentWuId=null,不出片', async () => { await setMembers(['profile-exec']); const r = await deriveChannelSuggestions(CHANNEL_ID, { fileStore }); - expect(r).toEqual({ currentWuId: null, suggestions: [] }); + expect(r).toEqual({ currentWuId: null, suggestions: [], degraded: false }); }); // #465(首用路径断点):空转频道(无当前工单)+ 成员为空 → 出只读提示片 @@ -127,6 +127,7 @@ describe('deriveChannelSuggestions(推导骨架)', () => { expect(r).toEqual({ currentWuId: null, suggestions: [{ id: 'channel-no-members', kind: 'status', params: {} }], + degraded: false, }); }); @@ -466,9 +467,79 @@ describe('deriveChannelSuggestions(推导骨架)', () => { expect(r.currentWuId).toBe(parent.id); }); + // ─── #487:currentWu 拣选粘性(多单并行不抖动) ─── + + /** 直接改快照 updatedAt(模拟 loop 每步簿记 bump,不经状态流转) */ + async function bumpUpdatedAt(wuId: string, iso: string) { + const s = (await fileStore.getIndex()).find(x => x.id === wuId)!; + await fileStore.upsertSnapshot({ ...s, updatedAt: iso }); + } + + it('#487:双活跃 WU 交替簿写 → currentWuId 不抖动(领先未超粘性窗口不切换)', async () => { + const a = await createParent({ status: 'active', title: '工单A' }); + const b = await createParent({ status: 'active', title: '工单B' }); // 后建,updatedAt 最新 + + const first = await deriveChannelSuggestions(CHANNEL_ID, { fileStore }); + expect(first.currentWuId).toBe(b.id); + + // 交替簿写(loop 每步 metadata 簿记都 bump updatedAt):A 短暂领先 → 不切换; + // B 再簿写 → 仍 B;A 再领先 → 仍 B。现任未终态且挑战者领先未超窗口 → 保持现任。 + const t0 = Date.now(); + await bumpUpdatedAt(a.id, new Date(t0 + 1_000).toISOString()); + expect((await deriveChannelSuggestions(CHANNEL_ID, { fileStore })).currentWuId).toBe(b.id); + await bumpUpdatedAt(b.id, new Date(t0 + 2_000).toISOString()); + expect((await deriveChannelSuggestions(CHANNEL_ID, { fileStore })).currentWuId).toBe(b.id); + await bumpUpdatedAt(a.id, new Date(t0 + 3_000).toISOString()); + expect((await deriveChannelSuggestions(CHANNEL_ID, { fileStore })).currentWuId).toBe(b.id); + }); + + it('#487:现任转入终态 → 立即切换到下一候选(不等粘性窗口)', async () => { + const a = await createParent({ status: 'active', title: '工单A' }); + const b = await createParent({ status: 'active', title: '工单B' }); + expect((await deriveChannelSuggestions(CHANNEL_ID, { fileStore })).currentWuId).toBe(b.id); + + // b 关闭(终态)——updatedAt 仍最新,但终态现任立即让位 + await bumpUpdatedAt(b.id, nowIso()); + const s = (await fileStore.getIndex()).find(x => x.id === b.id)!; + await fileStore.upsertSnapshot({ ...s, status: 'closed' }); + + const r = await deriveChannelSuggestions(CHANNEL_ID, { fileStore }); + expect(r.currentWuId).toBe(a.id); + }); + + it('#487:现任长时间静默(挑战者 updatedAt 领先超粘性窗口)→ 切换', async () => { + const stickyMs = SUGGESTION_TIMING.currentWuStickyMs; + const a = await createParent({ status: 'active', title: '工单A' }); + const b = await createParent({ status: 'active', title: '工单B' }); + expect((await deriveChannelSuggestions(CHANNEL_ID, { fileStore })).currentWuId).toBe(b.id); + + // b 静默不动,a 持续活跃至领先超过窗口 → 切换(现任已长时间静默,粘性解除) + const bSnap = (await fileStore.getIndex()).find(x => x.id === b.id)!; + await bumpUpdatedAt(a.id, new Date(Date.parse(bSnap.updatedAt) + stickyMs + 60_000).toISOString()); + + const r = await deriveChannelSuggestions(CHANNEL_ID, { fileStore }); + expect(r.currentWuId).toBe(a.id); + }); + it('频道不存在 → 空结果不抛出(fail-closed)', async () => { const r = await deriveChannelSuggestions('ch-not-exist', { fileStore }); - expect(r).toEqual({ currentWuId: null, suggestions: [] }); + expect(r).toEqual({ currentWuId: null, suggestions: [], degraded: false }); + }); + + // #490:fail-closed 可观测——推导内部读取失败被吞时 degraded=true(仍空 suggestions, + // 语义不变),正常路径 degraded=false;前端据此区分「真无建议」与「推导失败被吞」 + it('#490:内部读取失败(getIndex 抛错)→ degraded=true + 空 suggestions(fail-closed 语义不变)', async () => { + vi.spyOn(fileStore, 'getIndex').mockRejectedValueOnce(new Error('index corrupted')); + const r = await deriveChannelSuggestions(CHANNEL_ID, { fileStore }); + expect(r).toEqual({ currentWuId: null, suggestions: [], degraded: true }); + }); + + it('#490:失败恢复后正常推导 → degraded=false(degraded 不是粘性状态)', async () => { + vi.spyOn(fileStore, 'getIndex').mockRejectedValueOnce(new Error('index corrupted')); + const failed = await deriveChannelSuggestions(CHANNEL_ID, { fileStore }); + expect(failed.degraded).toBe(true); + const ok = await deriveChannelSuggestions(CHANNEL_ID, { fileStore }); + expect(ok.degraded).toBe(false); }); // ─── #447:「出片 ⟺ 前置条件成立」双向不变量全状态覆盖 ─── @@ -671,9 +742,30 @@ describe('channel routes(#443):GET /:id/suggestions', () => { expect(body.data).toEqual({ currentWuId: null, suggestions: [{ id: 'channel-no-members', kind: 'status', params: {} }], + degraded: false, }); }); + // #490:推导失败被吞 → 契约 degraded=true 透传到 HTTP 层(仍 200 + 空 suggestions,fail-closed 语义不变) + it('#490:推导内部读取失败 → data.degraded=true + 空 suggestions(200 不 5xx)', async () => { + const res = await fetch(baseUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'ch-suggestions-degraded', type: 'rnd' }), + }); + const channel = (await res.json()).data; + + const spy = vi.spyOn(FileStore.prototype, 'getIndex').mockRejectedValueOnce(new Error('index corrupted')); + try { + const r = await fetch(`${baseUrl}/${channel.id}/suggestions`); + expect(r.status).toBe(200); + const body = await r.json(); + expect(body.data).toEqual({ currentWuId: null, suggestions: [], degraded: true }); + } finally { + spy.mockRestore(); + } + }); + it('in_review + 活跃 review 子单 → data 携带 status 形态建议(结构化 params,无自由文案)', async () => { const res = await fetch(baseUrl, { method: 'POST', diff --git a/apps/api/src/modules/channels/channel.routes.ts b/apps/api/src/modules/channels/channel.routes.ts index 583807ee..97d5b5c2 100644 --- a/apps/api/src/modules/channels/channel.routes.ts +++ b/apps/api/src/modules/channels/channel.routes.ts @@ -17,7 +17,7 @@ import { getChannelFileVocabulary } from './file-ref-vocabulary.js'; import { deriveChannelCurrentPmo } from './current-pmo.js'; import { deriveChannelSuggestions } from './suggestions.js'; import { getErrorMessage } from '../../utils/errors.js'; -import { validateRouting } from './routing.js'; +import { validateRouting, buildMemberRemovalWarning } from './routing.js'; const router = Router(); const fileStore = new FileStore(); @@ -358,9 +358,17 @@ router.patch('/:id/members', requireAuth(), requireNotGuest(), async (req, res) const { add, remove } = req.body; try { const members = await updateChannelMembers(req.params.id, { add, remove }); + // #497: 移出被指名角色(routing 档/入口角色)→ 响应附 warning 提示漂移(不阻断, + // 与现有校验严格度对齐——指名静默退化为涌现前给人一次知情机会) + const removed: string[] = Array.isArray(remove) ? remove.filter((x): x is string => typeof x === 'string') : []; + let warning: string | undefined; + if (removed.length > 0) { + const channel = await fileStore.getChannel(req.params.id); + if (channel) warning = buildMemberRemovalWarning(channel, removed); + } // #448 问题1:members 在列表载荷中,写后失效 channels 列表缓存(30s apiCache) await clearCache(req.baseUrl); - res.json({ success: true, data: { members } }); + res.json({ success: true, data: { members, ...(warning ? { warning } : {}) } }); } catch (e: unknown) { const msg = getErrorMessage(e); if (msg.includes('not found')) { diff --git a/apps/api/src/modules/channels/message-routing.ts b/apps/api/src/modules/channels/message-routing.ts index 022d7fde..54edb1e2 100644 --- a/apps/api/src/modules/channels/message-routing.ts +++ b/apps/api/src/modules/channels/message-routing.ts @@ -5,6 +5,7 @@ * 1. replyToId present → thread reply (inherit workUnitId from parent) * 2. @mention detected → create WorkUnit * 3. 决策 12: 频道配置了 defaultProfileId → 无 @ 消息派给默认角色建 WorkUnit + * (#495:合并窗口内并入在途 WU 线程,不新建) * 4. plain text → store only * * 决策 11: 路由层不认识 skill——`+skill名` token 保留在 scope 原文, @@ -14,6 +15,7 @@ import { logger, FileStore, parseChannels } from '@dommaker/studio-shared'; import { channelMessageService, type MessageMeta, type MessageRecord } from './channel-message.service.js'; import { WorkUnitService } from '../workunit/workunit.service.js'; import { resumeWaitingWorkUnit } from '../workunit/waiting-input.js'; +import { parseWuMetadata } from '../workunit/wu-metadata.js'; import { postWuSystemMessage } from '../workunit/wu-messenger.js'; import { resolveReqIdForDispatch } from '../requirements/req-binding.js'; import { OWNERSHIP_WAITING_QUESTION, resolveWorkspaceForWU } from '../requirements/ownership-resolver.js'; @@ -39,6 +41,47 @@ export function detectMention(content: string): string | null { return match ? match[1] : null; } +/** + * #495(方案 a,票内预授权 2026-09-11):合并窗口内在途状态——终态(done/closed) + * 与 in_review(等人工验收)不吸收新消息,窗口内来新消息照章新建 WU。 + */ +const MERGE_IN_FLIGHT_STATUSES = new Set(['pending', 'unassigned', 'active', 'blocked']); + +/** + * #495:决策 12 派单合并窗口(毫秒)。窗口内同频道连续无 @ 消息合并进在途 WU 线程 + * (继续对话 = 继续任务),替代「每条闲聊建一张 WU」。默认 5 分钟, + * STUDIO_CHANNEL_MERGE_WINDOW_MINUTES 覆盖(仿 getReminderThresholdMs 口径)。 + */ +export function getMergeWindowMs(env: NodeJS.ProcessEnv = process.env): number { + const minutes = Number(env.STUDIO_CHANNEL_MERGE_WINDOW_MINUTES); + return (Number.isFinite(minutes) && minutes > 0 ? minutes : 5) * 60_000; +} + +/** + * #495:找合并目标——同频道最近一条携带 workUnitId 的人类消息(上轮派单/合并/线程回复 + * 的落点),其 createdAt 在窗口内且 WU 仍在途 → 返回该 WU;否则 null(照章新建)。 + * 锚点为消息时间而非 WU.updatedAt:每次合并落新消息即刷新窗口(滑动窗口语义)。 + */ +async function findMergeTargetWorkUnit( + channelId: string, + fs: FileStore, + now: Date = new Date(), +): Promise<{ id: string; anchorMessageId?: string } | null> { + const recentHuman = await fs.queryMessages(channelId, { authorType: 'human', limit: 20 }); + const last = [...recentHuman].reverse().find(m => m.workUnitId); + if (!last?.workUnitId) return null; + if (now.getTime() - new Date(last.createdAt).getTime() > getMergeWindowMs()) return null; + const wu = await new WorkUnitService(fs).getById(last.workUnitId); + if (!wu || wu.channelId !== channelId) return null; + if (!MERGE_IN_FLIGHT_STATUSES.has(wu.status)) return null; + // parseWuMetadata 容错口径:畸形 metadata 落 {}(anchorMessageId 缺省走首根回退),不抛错拖垮整道路由 + const meta = parseWuMetadata(wu.metadata); + return { + id: wu.id, + anchorMessageId: typeof meta.anchorMessageId === 'string' ? meta.anchorMessageId : undefined, + }; +} + /** * Route a message based on its content and context. * @@ -46,6 +89,7 @@ export function detectMention(content: string): string | null { * 1. replyToId → thread reply: inherit workUnitId from parent message * 2. @mention → create WorkUnit, associate with message * 3. 决策 12: 频道配置了 defaultProfileId → 无 @ 消息派给默认角色建 WorkUnit + * (#495:合并窗口内已有在途 WU → 并入该 WU 线程,不新建) * 4. plain text → store without workUnitId(未配置默认角色 = 维持纯存储) * * F6 → B3a 工程归属链(决策 D2):创建 WorkUnit 时解析工程归属 — @@ -146,6 +190,7 @@ export async function routeMessage( // #327:父消息不在热层(已归档 = getMessageById 热只读不可见;与「彻底不存在」不可区分) // → 引用降级放行:帖子成立、replyToId 保留(前端引用预览自然缺失)、 // workUnitId 继承失效落 null、不触发挂起复活——不整帖抛错 + // #492:降级放行后补频道 Studio 系统提示「该话题已归档」(见下方 !found 分支) if (!found) { logger.warn('[MessageRouting] Replied message not in hot tier, degrading reply (no workUnitId inheritance)', { channelId, replyToId, @@ -168,6 +213,20 @@ export async function routeMessage( }) ); } + // #492(方案 a):父消息在冷层 → 降级放行的回复不会触达任何任务, + // 频道发 Studio 系统提示(挂在该回复线程),用户不再静默失效;提示本身 best-effort。 + if (!found) { + await channelMessageService.createAgentMessage( + channelId, + 'Studio', + '该话题已归档,回复不会触达任务', + { replyToId: message.id }, + ).catch(err => + logger.warn('[MessageRouting] Post cold-tier-reply notice failed (non-blocking)', { + channelId, replyToId, error: String(err), + }) + ); + } await reportDroppedRefs(message); return message; } @@ -182,6 +241,23 @@ export async function routeMessage( const memberIds = parseChannels(channel?.members); const inScope = (p: (typeof allProfiles)[number]) => memberIds.length === 0 || memberIds.includes(p.id); let agent = allProfiles.find(p => p.name === mentionName && inScope(p)) ?? null; + // #496: 手打中文连写兜底——token 整体精确匹配失败时,对成员名做最长前缀匹配 + // (@开发你好 → 命中成员「开发」)。多成员互为前缀取最长;等长歧义(同名 profile + // 并列最长)回退现状(未匹配 → #464 转自动认领提示)。studio 系统角色不参与前缀兜底。 + let prefixMatchName: string | null = null; + if (!agent && mentionName !== STUDIO_ROLE_NAME) { + const candidates = allProfiles.filter( + p => inScope(p) && p.name !== STUDIO_ROLE_NAME && p.name.length > 0 && mentionName.startsWith(p.name), + ); + if (candidates.length > 0) { + const maxLen = Math.max(...candidates.map(p => p.name.length)); + const longest = candidates.filter(p => p.name.length === maxLen); + if (longest.length === 1) { + agent = longest[0]; + prefixMatchName = longest[0].name; + } + } + } // F5(2026-07-28 分析文档决策 6): @studio 特殊路由 — studio 是系统角色不执行任务; // 转派目标 = 频道 defaultProfileId 入口角色(与决策 12 同字段),未配置/inactive/越界 // → 未指派(assigneeId=null),走 claim 涌现(agent-loop observe 未指派过滤)。 @@ -198,7 +274,11 @@ export async function routeMessage( reroutedToName = entry.name; } } - const scope = content.replace(/@[\p{L}\p{N}_-]+\s*/u, ''); + // #496: 前缀兜底命中时只剥 `@成员名`(保留连写剩余文本作为 scope,如 @开发你好 → 你好); + // 精确匹配/未匹配维持剥整个 @token。成员名字符集 ⊂ [\p{L}\p{N}_-],内联进正则安全。 + const scope = prefixMatchName + ? content.replace(new RegExp(`@${prefixMatchName}\\s*`, 'u'), '') + : content.replace(/@[\p{L}\p{N}_-]+\s*/u, ''); // REQ 需求编号(vision §5.3):显式 > #REQ-XXXX token > 自动新建。 // best-effort:绑定失败不阻断 WorkUnit 创建(log + 不带 reqId 继续)。 const reqId = await resolveReqIdForDispatch({ @@ -230,6 +310,16 @@ export async function routeMessage( // 仅解析故障(null)回退旧绑定规则(不挂起,保可用性)。 const workspaceId = ownership ? ownership.workspaceId : (options?.workspaceId ?? channel?.defaultWorkspaceId ?? null); const parked = ownership?.source === 'none'; + // #494(方案 c,票内预授权):先落派发消息再建 WU——WU metadata.anchorMessageId 显式携带 + // 派发消息 id(认领播报优先锚它,消 created 事件先于派发消息落库的 findAnchorMessage 竞态); + // 建单后 linkWorkUnit 回填消息 ↔ WU 关联(同 createFromMessage 既有路径)。 + const dispatchMessage = await channelMessageService.createHumanMessage( + channelId, + content, + undefined, + undefined, + filesMeta, + ); const workUnit = await wuService.create({ scope, channelId, @@ -240,9 +330,12 @@ export async function routeMessage( workspaceId, reqId, metadata: { - mentionName, + // #496: 前缀兜底命中时落解析后的成员名(展示/认领播报以角色正名为准) + mentionName: prefixMatchName ?? mentionName, matched: !!agent, creationMode: 'mention', + // #494: 认领播报的显式线程锚点(见上方时序说明) + anchorMessageId: dispatchMessage.id, // B4a: @studio 改派标记(WU 实际派给 pm,非 studio 本身) ...(reroutedFrom ? { reroutedFrom } : {}), // P0 修复 6: traceId 贯穿(audit requestId → WU metadata → agent-loop 日志) @@ -277,13 +370,14 @@ export async function routeMessage( reroutedFrom, traceId: options?.traceId ?? undefined, }); - const message = await channelMessageService.createHumanMessage( - channelId, - content, - undefined, - workUnit.id, - filesMeta, - ); + // #494: 回填派发消息 ↔ WU 关联(best-effort:失败仅缺 back-link,线程锚定已由 anchorMessageId 承载)。 + // 返回值替换派发消息记录,保持 routeMessage 返回值的 workUnitId 契约不变。 + const message = await channelMessageService.linkWorkUnit(dispatchMessage.id, workUnit.id).catch(err => { + logger.warn('[MessageRouting] Link dispatch message to WorkUnit failed (non-blocking)', { + workUnitId: workUnit.id, messageId: dispatchMessage.id, error: String(err), + }); + return dispatchMessage; + }); await reportDroppedRefs(message); // F5: @studio 改派 → 频道发 Studio 系统消息说明(best-effort,挂在派发消息线程) if (reroutedFrom) { @@ -334,6 +428,51 @@ export async function routeMessage( // 决策 12: 无 @ 兜底 —— 频道配置了默认角色 → 派给它建 WorkUnit(消息关联到该 WU) const channel = await resolvedFs.getChannel(channelId); if (channel?.defaultProfileId) { + // #495(方案 a):合并窗口内已有在途 WU → 消息并入该 WU 线程,不再新建 WU + // (连发闲聊不产生 WU 风暴;窗口外/终态后正常新建)。 + const mergeTarget = await findMergeTargetWorkUnit(channelId, resolvedFs); + if (mergeTarget) { + const message = await channelMessageService.createHumanMessage( + channelId, + content, + mergeTarget.anchorMessageId, // 挂到在途 WU 的派发线程(缺 anchor 时退为根消息) + mergeTarget.id, + filesMeta, + ); + // 回复注入:blocked → resumeWaitingWorkUnit 复活 + pendingReplies; + // active 且已有 pendingReplies → 其锁内追加分支已覆盖;其余(unassigned/active 无 + // pendingReplies)此处锁内补齐,保证 loop 下一步经 prompt-composer 注入。 + const consumed = await resumeWaitingWorkUnit(mergeTarget.id, content, resolvedFs).catch(err => { + logger.warn('[MessageRouting] Resume merge-target WorkUnit failed (non-blocking)', { + workUnitId: mergeTarget.id, error: String(err), + }); + return false; + }); + if (!consumed) { + await resolvedFs.updateMetadata(mergeTarget.id, latest => ({ + ...latest, + pendingReplies: [...(Array.isArray(latest.pendingReplies) ? latest.pendingReplies : []), content], + })).catch(err => + logger.warn('[MessageRouting] Append pendingReplies for merge target failed (non-blocking)', { + workUnitId: mergeTarget.id, error: String(err), + }) + ); + } + logger.info('[MessageRouting] Message merged into in-flight WorkUnit (merge window)', { + channelId, + workUnitId: mergeTarget.id, + }); + await reportDroppedRefs(message); + return message; + } + // #494:同 mention 路径——先落派发消息再建 WU,anchorMessageId 显式传递消认领播报竞态 + const dispatchMessage = await channelMessageService.createHumanMessage( + channelId, + content, + undefined, + undefined, + filesMeta, + ); const workUnit = await wuService.create({ scope: content, channelId, @@ -342,6 +481,8 @@ export async function routeMessage( assigneeId: channel.defaultProfileId, metadata: { creationMode: 'channel-default', + // #494: 认领播报的显式线程锚点 + anchorMessageId: dispatchMessage.id, // #285: @文件引用落档(本路径不做归属解析,仅落档供 prompt-composer files 段消费) ...(filesMeta?.files ? { fileRefs: filesMeta.files } : {}), }, @@ -351,13 +492,12 @@ export async function routeMessage( workUnitId: workUnit.id, defaultProfileId: channel.defaultProfileId, }); - const message = await channelMessageService.createHumanMessage( - channelId, - content, - undefined, - workUnit.id, - filesMeta, - ); + const message = await channelMessageService.linkWorkUnit(dispatchMessage.id, workUnit.id).catch(err => { + logger.warn('[MessageRouting] Link dispatch message to WorkUnit failed (non-blocking)', { + workUnitId: workUnit.id, messageId: dispatchMessage.id, error: String(err), + }); + return dispatchMessage; + }); await reportDroppedRefs(message); return message; } diff --git a/apps/api/src/modules/channels/routing.ts b/apps/api/src/modules/channels/routing.ts index 5adeca03..efa11418 100644 --- a/apps/api/src/modules/channels/routing.ts +++ b/apps/api/src/modules/channels/routing.ts @@ -16,7 +16,7 @@ * 消费方:publish 建 plan 单(plan 档)、analysis-handoff TASK 拆派工与 * workunit feature 展开(implement 档)、review-dispatcher 建评审单(review 档)。 */ -import { parseChannels, type FileStore } from '@dommaker/studio-shared'; +import { parseChannels, type ChannelData, type FileStore } from '@dommaker/studio-shared'; export const ROUTING_STAGES = ['plan', 'implement', 'review'] as const; export type RoutingStage = (typeof ROUTING_STAGES)[number]; @@ -79,6 +79,58 @@ export function routingFallbackText(stage: RoutingStage, resolution: StageRoutin return `工单路由提醒:本频道「${ROUTING_STAGE_LABELS[stage]}」阶段路由到 @${name},但${reason},本单已回池涌现(频道成员自动认领)——请到频道设置调整路由表`; } +/** + * #497: fallback 提醒冷却窗——同频道同档同原因在窗内只出声一次。 + * 悬空指名在配置修复前会持续触发派生,不去重则提醒无限重复刷屏。 + * 进程内 Map(重启即重置):提醒是配置修复信号,重启后补一条可接受,不漏路由回退本身。 + */ +export const FALLBACK_REMINDER_COOLDOWN_MS = 60 * 60 * 1000; + +const fallbackReminderSentAt = new Map(); + +/** + * #497: fallback 提醒冷却闸。返回 true = 放行并登记本次发送时刻(调用方随后 + * best-effort 发送,发送失败不补偿——漏一条优于刷屏);false = 冷却窗内,跳过出声。 + */ +export function shouldEmitFallbackReminder( + channelId: string, + stage: RoutingStage, + resolution: StageRoutingResolution, + now: number = Date.now(), +): boolean { + const key = `${channelId}:${stage}:${resolution.fallback ?? 'none'}`; + const last = fallbackReminderSentAt.get(key); + if (last !== undefined && now - last < FALLBACK_REMINDER_COOLDOWN_MS) return false; + fallbackReminderSentAt.set(key, now); + return true; +} + +/** 测试钩子:清空冷却表 */ +export function resetFallbackReminderCooldown(): void { + fallbackReminderSentAt.clear(); +} + +/** + * #497: members 移出的指名漂移检查——被移出角色仍被 routing 档/入口角色指名时 + * 产出人读 warning(响应附带,不阻断;与 defaultProfileId/routing 校验只查存在性的严格度对齐)。 + */ +export function buildMemberRemovalWarning( + channel: Pick, + removedIds: string[], +): string | undefined { + if (removedIds.length === 0) return undefined; + const stages = ROUTING_STAGES.filter(s => { + const v = channel.routing?.[s]; + return v != null && removedIds.includes(v); + }); + const namedDefault = channel.defaultProfileId != null && removedIds.includes(channel.defaultProfileId); + if (stages.length === 0 && !namedDefault) return undefined; + const parts: string[] = []; + if (stages.length > 0) parts.push(`「${stages.map(s => ROUTING_STAGE_LABELS[s]).join('、')}」阶段路由`); + if (namedDefault) parts.push('入口角色'); + return `成员移出提醒:被移出的角色仍被指名为本频道${parts.join('与')},相关派单将回池涌现——请到频道设置调整配置`; +} + /** * PATCH/POST 入口校验(defaultProfileId 先例:只校验 active profile 存在性, * 不强制频道成员——成员边界在路由时判定)。 diff --git a/apps/api/src/modules/channels/suggestions.ts b/apps/api/src/modules/channels/suggestions.ts index 7703dca1..fac9a8b0 100644 --- a/apps/api/src/modules/channels/suggestions.ts +++ b/apps/api/src/modules/channels/suggestions.ts @@ -16,7 +16,8 @@ * waitingForInput 且无 loop 在处理)与「转写审查清单」(前置门禁窗口:评审未派发), * 预填指令本体由 text 字段承载,点击预填进输入框、人可编辑后发送,走既有 @mention * 消息路由(不建确定性接口)。 - * 顶层容错:任何一步失败 → 空结果 + warn,派生绝不抛出(同 current-pmo 原则)。 + * 顶层容错:任何一步失败 → 空结果 + warn + degraded=true(#490:失败被吞 ≠ 无建议, + * 标志透出给前端做可观测区分,fail-closed 语义不变),派生绝不抛出(同 current-pmo 原则)。 */ import { logger, parseChannels, deriveDisplayState, FileStore, type WorkUnitSnapshot } from '@dommaker/studio-shared'; import { summarizeRoleStates } from '../agents/agent-instance.service.js'; @@ -35,6 +36,10 @@ export const SUGGESTION_TIMING = { /** 无人认领宽限(#445):WU unassigned 且有在线成员 loop 时,该窗口内不出认领片 * (claim 轮询 15s 节奏下在线 loop 大概率已涌现认领;对齐对账扫描 5min 档,不发明新量级) */ unassignedClaimGraceMs: 5 * 60 * 1000, + /** currentWu 拣选粘性窗口(#487):现任非终态时,挑战者 updatedAt 须领先现任超过该窗口 + * 才切换——loop 每步 metadata 簿记都 bump updatedAt,多单并行交替簿写不应让 + * 阶段条/引导片来回跳。对齐对账扫描 5min 档,不发明新量级。 */ + currentWuStickyMs: 5 * 60 * 1000, } as const; /** 建议三形态(#441):status 只读说明 / action 确定性动作 / prompt 预填建议 */ @@ -57,6 +62,12 @@ export interface ChannelSuggestion { export interface ChannelSuggestionsResult { currentWuId: string | null; suggestions: ChannelSuggestion[]; + /** + * #490:fail-closed 可观测标志——推导内部读取失败被吞时为 true(仍空 suggestions, + * fail-closed 语义不变);正常路径(含「确实无建议」)为 false。非粘性状态, + * 每次调用按本次推导现算。前端仅 console 记录,不打扰用户。 + */ + degraded: boolean; } export interface ChannelSuggestionsDeps { @@ -65,13 +76,25 @@ export interface ChannelSuggestionsDeps { now?: Date; } -const EMPTY: ChannelSuggestionsResult = { currentWuId: null, suggestions: [] }; +/** 推导内部结果(不含 degraded——该标志由外层按「本次推导是否抛错」现算,非粘性) */ +type DeriveInner = { currentWuId: string | null; suggestions: ChannelSuggestion[] }; + +const EMPTY: DeriveInner = { currentWuId: null, suggestions: [] }; /** 不可自动评审的 WU 类型(同 ReviewDispatcher 路径 A / dispatch-reconciliation 口径;#471 含 plan) */ function isAutoReviewable(wu: WorkUnitSnapshot): boolean { return wu.type !== 'review' && !MANUAL_GATE_TYPES.has(wu.type); } +/** + * currentWu 现任记忆(#487):进程内 per-channel 粘性,不落库——派生仍是纯推导, + * 进程重启即按当前事实重选(与「不落库每次现算」语义一致,粘性只是抗抖动的滞后)。 + * 无状态的阈值规则无法区分「现任自己簿写 bump」与「挑战者超车」(对称规则两者同形), + * 故粘性必须记忆现任;进程内 Map 是最小实现。key = channelId,value = 现任 wuId; + * 现任从候选集消失(换频道数据/终态让位)时自然被覆盖,无需清理。 + */ +const currentWuIncumbent = new Map(); + /** * 频道当前 WU = 非终态(派生列非 done/closed)中 updatedAt 最新者;全终态回退最新者;空 → null。 * 终态判定走 deriveDisplayState 派生列(done 缺 l3 = in_review 仍算非终态),不读裸 status。 @@ -81,8 +104,12 @@ function isAutoReviewable(wu: WorkUnitSnapshot): boolean { * 否则父单一进 in_review 子单即建、updatedAt 恒新,当前工单永远是子单, * 「等待自动评审」状态说明永远不触发(前端静态版即有此盲区)。故拣选时排除 * review 子单;频道内只剩 review 子单时回退全集口径(不编造)。 + * + * 粘性(#487):现任非终态且仍在候选集时,仅当挑战者 updatedAt 领先现任超过 + * SUGGESTION_TIMING.currentWuStickyMs 才切换(现任长时间静默 = 粘性解除); + * 现任终态/消失立即让位给最新候选。 */ -function pickCurrentWuSnapshot(wus: WorkUnitSnapshot[]): WorkUnitSnapshot | null { +function pickCurrentWuSnapshot(wus: WorkUnitSnapshot[], channelId: string): WorkUnitSnapshot | null { if (wus.length === 0) return null; const byUpdatedDesc = (list: WorkUnitSnapshot[]) => [...list].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)); @@ -94,7 +121,19 @@ function pickCurrentWuSnapshot(wus: WorkUnitSnapshot[]): WorkUnitSnapshot | null const mainline = wus.filter(w => !isReviewChild(w)); const candidates = mainline.length > 0 ? mainline : wus; const byUpdated = byUpdatedDesc(candidates); - return byUpdated.find(w => !terminal(w)) ?? byUpdated[0]; + const fresh = byUpdated.find(w => !terminal(w)) ?? byUpdated[0]; + + const incumbentId = currentWuIncumbent.get(channelId); + const incumbent = incumbentId ? candidates.find(w => w.id === incumbentId) : undefined; + let picked = fresh; + if (incumbent && !terminal(incumbent)) { + // 现任仍活跃:挑战者(fresh = 非终态最新者)领先未超窗口 → 保持现任抗抖动; + // 领先超窗口 = 现任已长时间静默 → 切换。fresh 即现任时 leadMs=0,天然保持。 + const leadMs = Date.parse(fresh.updatedAt) - Date.parse(incumbent.updatedAt); + if (leadMs <= SUGGESTION_TIMING.currentWuStickyMs) picked = incumbent; + } + currentWuIncumbent.set(channelId, picked.id); + return picked; } /** 未完结 review 子单(同 dispatch-reconciliation / #442 判据:parentId 命中 + type=review + 非 done/closed) */ @@ -130,19 +169,34 @@ async function hasOnlineMemberLoop(fileStore: FileStore, memberIds: string[]): P /** * 推导频道建议。fail-closed:前置条件不满足/事实缺失/读取失败 → 空结果。 + * #490:读取失败被吞 ≠ 「确实无建议」——失败路径 degraded=true(仍空 suggestions), + * 正常路径 degraded=false,前端据此区分两态(仅记录,不打扰用户)。 */ export async function deriveChannelSuggestions( channelId: string, deps: ChannelSuggestionsDeps = {}, ): Promise { + try { + return { ...(await deriveChannelSuggestionsInner(channelId, deps)), degraded: false }; + } catch (err) { + logger.warn('[ChannelSuggestions] derive failed (fail-closed → empty)', { channelId, error: String(err) }); + return { ...EMPTY, degraded: true }; + } +} + +/** 推导本体(抛错由外层兜底转 degraded=true;自身只表达「事实推导结果」) */ +async function deriveChannelSuggestionsInner( + channelId: string, + deps: ChannelSuggestionsDeps, +): Promise { const fileStore = deps.fileStore ?? new FileStore(); const now = deps.now ?? new Date(); - try { + { const channel = await fileStore.getChannel(channelId); if (!channel) return EMPTY; const channelWus = (await fileStore.getIndex()).filter(s => s.channelId === channelId); - const current = pickCurrentWuSnapshot(channelWus); + const current = pickCurrentWuSnapshot(channelWus, channelId); const memberIds = parseChannels(channel.members); if (!current) { // #465(首用路径断点):空转频道(无当前工单)且成员为空 → 出只读提示片。 @@ -265,8 +319,5 @@ export async function deriveChannelSuggestions( params: { wuId: current.id, wuTitle }, }], }; - } catch (err) { - logger.warn('[ChannelSuggestions] derive failed (fail-closed → empty)', { channelId, error: String(err) }); - return EMPTY; } } diff --git a/apps/api/src/modules/events/CONTEXT.md b/apps/api/src/modules/events/CONTEXT.md index 46444f8b..325fb5fc 100644 --- a/apps/api/src/modules/events/CONTEXT.md +++ b/apps/api/src/modules/events/CONTEXT.md @@ -11,8 +11,9 @@ | event.routes.ts | POST /api/v1/events | 创建 StudioEvent | | event.routes.ts | GET /api/v1/events | 查询 StudioEvent(requireAuth;#180 起:type/since/until/level/keyword/workUnitId 过滤 + 尾部倒读游标分页 `cursor`→`nextCursor`,替代全文件线性扫 + 200 硬顶;level 缺省 ≥info,level=debug 看全部;倒读实现 `../../utils/studio-events-tail.ts`) | | event.routes.ts | POST /api/v1/events/agent-events | 批量写入 AgentEvent[] | -| sse.routes.ts | GET /api/v1/events/stream | SSE 实时事件流 | +| sse.routes.ts | GET /api/v1/events/stream | SSE 实时事件流(#491 起消费 Last-Event-ID:重连补发 replay buffer 窗口内遗漏事件) | | sse.routes.ts | GET /api/v1/events/clients | SSE 客户端列表 (debug) | +| sse-replay-buffer.ts | SseReplayBuffer | #491:SSE 短窗口内存 replay buffer(环形,容量 500,id = 服务端单调 seq) | | workunit-events-bridge.ts | initWorkunitEventsBridge() | eventBus 的 workunit.created/status_changed + requirement.created/updated(2026-08-24 SSE 负载加深,REQ chips SSE 驱动)→ 'events' 频道(前端 WU 列表/抽屉/REQ chips 实时刷新);index.ts 启动时调用,幂等 | | lock-events-bridge.ts | initLockEventsBridge() | #169: eventBus 的 lock.stale_reclaimed/lock.acquire_timeout → 结构化字段落统一事件流 + dispatchMonitorAlerts 全管线(warning 级,不设 critical);index.ts 启动时调用,幂等 | | (agent-loop 直发) | workunit.execution.step | WU 执行步事件(思考/工具/skill/用量):agent-loop 每步结束经 eventBus.publish 直发(不经过桥),`workunit.` 前缀自动落 workunits topic;落盘形态 `workunit:execution_step` 供 GET /events 回放 | @@ -30,14 +31,15 @@ ### 测试 -五个测试文件,53+ 个用例: +六个测试文件,70+ 个用例: | 文件 | 用例数 | 覆盖内容 | |------|--------|---------| | `__tests__/event.routes.test.ts` | 30 | POST/GET/agent-events: 创建/查询/验证/空 payload 拒收(D18)/错误路径;#180 起 GET 用真临时文件 + STUDIO_EVENTS_FILE 缝(过滤/游标/鉴权栈) | | `__tests__/session-summary-generator.test.ts` | 17 | classifyPattern 13种模式 + generateSessionSummary 边界情况 | | `__tests__/workunit-events-bridge.test.ts` | 2 | workunit.* + requirement.* 事件转发 'events' 频道(信封形状);桥 started 幂等是模块态,同文件后续用例 init 为 no-op 靠订阅残留生效 | -| `__tests__/sse-routes.test.ts` | 3 | getTopicFromEventType 映射表锁定(requirement.→requirements、workunit.*→workunits、既有前缀不变) | +| `__tests__/sse-routes.test.ts` | 9 | getTopicFromEventType 映射表锁定;#324 背压断开慢客户端;#491 重连 replay(按序补发/topics 过滤/不可解析游标/有洞不补发) | +| `__tests__/sse-replay-buffer.test.ts` | 7 | #491 replay buffer:seq 单调递增、环形淘汰、replay 窗口语义(有洞 → null) | | `__tests__/lock-events-bridge.test.ts` | 1 | #169: lock.* 事件 → 结构化事件流 + dispatchMonitorAlerts 全管线(warning + notifyAlert)、init 幂等 | ### 注意事项 @@ -47,6 +49,7 @@ - SSE 使用 EventBus pub/sub (B0-002),不依赖数据库 - **SSE 帧格式(2026-07-29 修复)**:只写 `id:` + `data:` 匿名事件(不写 `event:` 命名行——EventSource.onmessage 只收匿名事件),且 data 是完整信封 `{event_type, event_id, timestamp, data}`(此前只发内层 payload,客户端按 event_type 分发恒失败,全站 SSE 实际不通)。topic 映射(`getTopicFromEventType` 纯前缀,已导出供单测锁定):execution./runtime.→executions、node.→nodes、task.→tasks、goal.→goals、knowledge.→knowledge、workunit.→workunits(含 workunit.tokens / workunit.execution.*)、channel.→channels、requirement.→requirements(2026-08-24 新增)、其余→all(客户端默认订阅 all 全收) - session:summary 在 session:end 时触发,fire-and-forget +- **SSE replay(#491,2026-09-11)**:`id:` 行自本票起为服务端单调 seq(不再是信封 event_id;event_id 仍在 data 信封内供前端幂等去重)。事件广播时先入 `sseReplayBuffer`(sse-replay-buffer.ts,环形 500 条,进程级内存、不落盘、重启即清),重连带 `Last-Event-ID` 时按 seq 升序补发窗口内遗漏事件(按订阅 topics 过滤,补发先于 connection.established——后者以 currentSeq 为 id,先发会让游标越过补发事件);游标早于 buffer 最老事件(有洞)或无法解析(旧版 uuid)则不补发,由前端 onReconnect 全量 refetch 兜底(决策 9) - **SSE 与全局 compression(#263 / 根因 #259,2026-08-19)**:app.ts 全局 compression 中间件必须带 `filter: shouldCompress`(`apps/api/src/middleware/compression-filter.ts`)——默认 compressible 对 `text/event-stream` 经 `^text/` fallback 返回 true 会缓冲 SSE 流,频道实时推送全灭。/events/stream 与 /mcp/sse 均经此中间件覆盖;新增 SSE 端点只要走同一 app 即自动生效 - patternType 分类规则:纯 deterministic,不调 LLM - **鉴权(2026-07-24 收紧)**:event.routes 的 POST /、/agent-events 已收 requireAuth+requireNotGuest;GET /stream 保持公开(Lurk 设计有意放行,会广播内部事件总线)。#180(#60 决策 Q3a)起 GET / 也收 requireAuth。 diff --git a/apps/api/src/modules/events/__tests__/sse-replay-buffer.test.ts b/apps/api/src/modules/events/__tests__/sse-replay-buffer.test.ts new file mode 100644 index 00000000..a2d426bd --- /dev/null +++ b/apps/api/src/modules/events/__tests__/sse-replay-buffer.test.ts @@ -0,0 +1,68 @@ +/** + * #491:SSE 短窗口内存 replay buffer 单测 + * - seq 单调递增(SSE id 行来源,重连 Last-Event-ID 游标) + * - 环形容量:超限淘汰最老 + * - replay 语义:窗口内按序补发 / 有洞返回 null 不补发 / 无法解析当新连接 + */ +import { describe, it, expect } from 'vitest'; +import { SseReplayBuffer } from '../sse-replay-buffer.js'; + +describe('SseReplayBuffer', () => { + it('push 分配的 seq 从 1 开始单调递增', () => { + const buf = new SseReplayBuffer(10); + expect(buf.push('channels', 'channel.message_created', { id: 'm1' })).toBe(1); + expect(buf.push('channels', 'channel.message_updated', { id: 'm1' })).toBe(2); + expect(buf.push('tasks', 'task.updated', { id: 't1' })).toBe(3); + expect(buf.currentSeq).toBe(3); + expect(buf.size).toBe(3); + }); + + it('容量超限淘汰最老事件(环形)', () => { + const buf = new SseReplayBuffer(3); + for (let i = 0; i < 5; i++) buf.push('all', 'task.updated', { i }); + expect(buf.size).toBe(3); + expect(buf.oldestSeq).toBe(3); // seq 1/2 已淘汰 + expect(buf.currentSeq).toBe(5); + }); + + it('replay:lastEventId 之后的事件按 seq 升序返回(含 message_updated 类)', () => { + const buf = new SseReplayBuffer(10); + buf.push('channels', 'channel.message_created', { id: 'm1' }); + buf.push('channels', 'channel.message_updated', { id: 'm1' }); + buf.push('channels', 'channel.message_created', { id: 'm2' }); + const entries = buf.replay('1'); + expect(entries).not.toBeNull(); + expect(entries!.map(e => e.seq)).toEqual([2, 3]); + expect(entries!.map(e => e.eventType)).toEqual(['channel.message_updated', 'channel.message_created']); + }); + + it('replay:lastEventId 不落后(等于或晚于最新 seq)→ 空数组', () => { + const buf = new SseReplayBuffer(10); + buf.push('all', 'task.updated', {}); + expect(buf.replay('1')).toEqual([]); + expect(buf.replay('999')).toEqual([]); + }); + + it('replay:lastEventId 无法解析(空串 / 旧版 uuid)→ 空数组,当新连接处理', () => { + const buf = new SseReplayBuffer(10); + buf.push('all', 'task.updated', {}); + expect(buf.replay('')).toEqual([]); + expect(buf.replay('a1b2c3d4-e5f6-uuid')).toEqual([]); + }); + + it('replay:lastEventId 早于 buffer 最老事件(有洞)→ null 不补发;恰好贴边则全覆盖', () => { + const buf = new SseReplayBuffer(3); + for (let i = 0; i < 5; i++) buf.push('all', 'task.updated', { i }); + // 最老 seq=3:since=1 → 洞(seq2 已淘汰);since=2 → 恰好覆盖 + expect(buf.replay('1')).toBeNull(); + const edge = buf.replay('2'); + expect(edge).not.toBeNull(); + expect(edge!.map(e => e.seq)).toEqual([3, 4, 5]); + }); + + it('replay:空 buffer 任何游标都不补发', () => { + const buf = new SseReplayBuffer(10); + expect(buf.replay('0')).toEqual([]); + expect(buf.replay('5')).toEqual([]); + }); +}); diff --git a/apps/api/src/modules/events/__tests__/sse-routes.test.ts b/apps/api/src/modules/events/__tests__/sse-routes.test.ts index 9bced1f6..9ebd5b8a 100644 --- a/apps/api/src/modules/events/__tests__/sse-routes.test.ts +++ b/apps/api/src/modules/events/__tests__/sse-routes.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { EventEmitter } from 'node:events'; import { eventBus } from '@dommaker/studio-shared'; -import sseRouter, { getTopicFromEventType } from '../sse.routes.js'; +import sseRouter, { getTopicFromEventType, sseReplayBuffer } from '../sse.routes.js'; describe('getTopicFromEventType', () => { it('requirement.created / requirement.updated → requirements', () => { @@ -32,47 +32,59 @@ describe('getTopicFromEventType', () => { }); }); -// ── #324:SSE 直订 eventBus + 背压断开慢客户端 ── -// 驱动方式:express Router.handle + 假 req/res(同 outbound-notify 测试的轻量思路, -// 但不起真 HTTP 服务——背压由 res.write 返回值模拟,真 socket 不可靠复现)。 -describe('SSE /stream 背压(#324)', () => { - interface FakeClient { - req: EventEmitter & Record; - res: { writeHead: ReturnType; write: ReturnType; end: ReturnType }; - writes: string[]; - /** 置 true 后 write 返回 false(模拟慢客户端缓冲区满) */ - slow: { value: boolean }; - } +// ── 共享脚手架:express Router.handle + 假 req/res(不起真 HTTP 服务—— +// 背压由 res.write 返回值模拟,真 socket 不可靠复现)── +interface FakeClient { + req: EventEmitter & Record; + res: { writeHead: ReturnType; write: ReturnType; end: ReturnType }; + writes: string[]; + /** 置 true 后 write 返回 false(模拟慢客户端缓冲区满) */ + slow: { value: boolean }; +} - const connected: FakeClient[] = []; - - function connectClient(): FakeClient { - const req = new EventEmitter() as FakeClient['req']; - req.method = 'GET'; - req.url = '/stream?topics=all'; - req.query = { topics: 'all' }; - req.headers = {}; - req.app = {}; - const client: FakeClient = { - req, - writes: [], - slow: { value: false }, - res: { - writeHead: vi.fn(), - write: vi.fn((chunk: string) => { client.writes.push(chunk); return !client.slow.value; }), - end: vi.fn(), - }, - }; - (sseRouter as unknown as { handle: (q: unknown, s: unknown, n: () => void) => void }).handle(req, client.res, () => {}); - connected.push(client); - return client; - } +const connected: FakeClient[] = []; - afterEach(() => { - // 触发 req close 清理 heartbeat interval 与 clients Map(防句柄泄漏) - for (const c of connected.splice(0)) c.req.emit('close'); - }); +function connectClient(headers: Record = {}, topics = 'all'): FakeClient { + const req = new EventEmitter() as FakeClient['req']; + req.method = 'GET'; + req.url = `/stream?topics=${topics}`; + req.query = { topics }; + req.headers = headers; + req.app = {}; + const client: FakeClient = { + req, + writes: [], + slow: { value: false }, + res: { + writeHead: vi.fn(), + write: vi.fn((chunk: string) => { client.writes.push(chunk); return !client.slow.value; }), + end: vi.fn(), + }, + }; + (sseRouter as unknown as { handle: (q: unknown, s: unknown, n: () => void) => void }).handle(req, client.res, () => {}); + connected.push(client); + return client; +} + +afterEach(() => { + // 触发 req close 清理 heartbeat interval 与 clients Map(防句柄泄漏) + for (const c of connected.splice(0)) c.req.emit('close'); +}); +/** 把 writes 里的 `id:` + `data:` 行配成帧(heartbeat 注释行无 id 自动跳过) */ +function extractFrames(client: FakeClient): { id: string; data: Record }[] { + const frames: { id: string; data: Record }[] = []; + for (let i = 0; i < client.writes.length; i++) { + const m = /^id: (.+)\n$/.exec(client.writes[i]); + if (m && client.writes[i + 1]?.startsWith('data: ')) { + frames.push({ id: m[1], data: JSON.parse(client.writes[i + 1].slice('data: '.length).trim()) }); + } + } + return frames; +} + +// ── #324:SSE 直订 eventBus + 背压断开慢客户端 ── +describe('SSE /stream 背压(#324)', () => { it('write 返回 false 的慢客户端被断开并移除,正常客户端照常收到事件', () => { const normal = connectClient(); const slow = connectClient(); @@ -91,9 +103,10 @@ describe('SSE /stream 背压(#324)', () => { // 慢客户端:res.end 被调、之后不再收到写出 expect(slow.res.end).toHaveBeenCalledTimes(1); - // 正常客户端:收到 id 行 + 匿名 data 行,data 内含 event_type + // 正常客户端:收到 id 行(#491 起 = 服务端单调 seq,不再是信封 event_id)+ 匿名 data 行 const newWrites = normal.writes.slice(normalWritesBefore); - expect(newWrites.some(w => w === 'id: e-1\n')).toBe(true); + const idLine = newWrites.find(w => /^id: \d+\n$/.test(w)); + expect(idLine).toBeDefined(); const dataLine = newWrites.find(w => w.startsWith('data: ')); expect(dataLine).toBeDefined(); expect(JSON.parse(dataLine!.slice('data: '.length).trim()).event_type).toBe('task.updated'); @@ -126,3 +139,60 @@ describe('SSE /stream 背压(#324)', () => { } }); }); + +// ── #491:断线窗口事件 replay——重连带 Last-Event-ID 补发窗口内遗漏事件 ── +describe('SSE /stream 断线 replay(#491)', () => { + function publish(eventType: string, eventId: string, data: Record) { + eventBus.publish('events', { + event_type: eventType, event_id: eventId, + timestamp: '2026-09-11T00:00:00Z', data, + }); + } + + it('重连带 last-event-id:断线窗口内事件按序补发(含 channel.message_updated),connection.established 在其后', () => { + const a = connectClient(); + const cursor = extractFrames(a).at(-1)!.id; // connection.established 的 id = 当前 seq + + publish('channel.message_created', 'ev-c1', { channelId: 'ch-1', message: { id: 'm1' } }); + publish('channel.message_updated', 'ev-u1', { channelId: 'ch-1', messageId: 'm1' }); + + const b = connectClient({ 'last-event-id': cursor }); + const frames = extractFrames(b); + expect(frames).toHaveLength(3); + expect(frames[0].data.event_type).toBe('channel.message_created'); + expect(frames[1].data.event_type).toBe('channel.message_updated'); + // 按 seq 升序,且 connection.established 殿后(id = currentSeq ≥ 补发事件,游标不回退) + expect(Number(frames[0].id)).toBeLessThan(Number(frames[1].id)); + expect(Number(frames[1].id)).toBeLessThanOrEqual(Number(frames[2].id)); + expect(frames[2].data.clientId).toBeDefined(); + }); + + it('补发按订阅 topics 过滤:不匹配的事件不重放', () => { + const a = connectClient({}, 'channels'); + const cursor = extractFrames(a).at(-1)!.id; + + publish('channel.message_created', 'ev-c2', { channelId: 'ch-1', message: { id: 'm2' } }); + publish('task.updated', 'ev-t2', { taskId: 't-9' }); + + const b = connectClient({ 'last-event-id': cursor }, 'channels'); + const frames = extractFrames(b); + // 仅 channels 事件 + connection.established + expect(frames.map(f => f.data.event_type)).toEqual(['channel.message_created', undefined]); + }); + + it('lastEventId 无法解析(旧版 uuid 游标)→ 不补发,仅 connection.established', () => { + const b = connectClient({ 'last-event-id': 'a1b2c3d4-e5f6-uuid' }); + const frames = extractFrames(b); + expect(frames).toHaveLength(1); + expect(frames[0].data.clientId).toBeDefined(); + }); + + it('lastEventId 早于 buffer 最老事件(有洞)→ 不补发,由前端 refetch 兜底', () => { + // 直推 buffer 越过容量制造淘汰(容量 500,推到最老 seq > 2) + for (let i = 0; i < 600; i++) sseReplayBuffer.push('all', 'task.updated', { i }); + const b = connectClient({ 'last-event-id': '1' }); + const frames = extractFrames(b); + expect(frames).toHaveLength(1); + expect(frames[0].data.clientId).toBeDefined(); + }); +}); diff --git a/apps/api/src/modules/events/sse-replay-buffer.ts b/apps/api/src/modules/events/sse-replay-buffer.ts new file mode 100644 index 00000000..86b2ff35 --- /dev/null +++ b/apps/api/src/modules/events/sse-replay-buffer.ts @@ -0,0 +1,58 @@ +/** + * #491:SSE 短窗口内存 replay buffer(环形,按服务端分配的 seq 单调递增)。 + * + * 断线/背压窗口内的事件在重连(Last-Event-ID)时按序补发; + * 窗口外的洞不补发,由前端 onReconnect 全量 refetch 兜底(决策 9)。 + * 不改持久化存储——进程重启即清空,重启后前端同样走 refetch 兜底。 + */ + +export interface ReplayEntry { + /** 服务端分配的单调递增序号,即 SSE `id:` 行(重连游标) */ + seq: number; + /** getTopicFromEventType 映射结果,供重连时按客户端订阅 topics 过滤 */ + topic: string; + eventType: string; + /** 完整信封(event_type/event_id/timestamp/data)——客户端按信封/消息 id 幂等去重 */ + data: unknown; +} + +export class SseReplayBuffer { + private entries: ReplayEntry[] = []; + private seq = 0; + + constructor(private readonly capacity = 500) {} + + /** 入队并返回分配的 seq(从 1 开始单调递增);容量超限淘汰最老 */ + push(topic: string, eventType: string, data: unknown): number { + const seq = ++this.seq; + this.entries.push({ seq, topic, eventType, data }); + if (this.entries.length > this.capacity) this.entries.shift(); + return seq; + } + + get currentSeq(): number { + return this.seq; + } + + get size(): number { + return this.entries.length; + } + + /** buffer 内最老事件 seq;空 buffer 为 0 */ + get oldestSeq(): number { + return this.entries[0]?.seq ?? 0; + } + + /** + * 取出 lastEventId 之后的遗漏事件(按 seq 升序)。 + * 返回 null = 有洞(lastEventId 早于 buffer 最老事件),调用方不补发; + * lastEventId 无法解析(空串 / 旧版 uuid 游标)视为新连接,返回空数组。 + */ + replay(lastEventId: string): ReplayEntry[] | null { + if (!/^\d+$/.test(lastEventId)) return []; + const since = Number(lastEventId); + if (since >= this.seq) return []; + if (since < this.oldestSeq - 1) return null; + return this.entries.filter(e => e.seq > since); + } +} diff --git a/apps/api/src/modules/events/sse.routes.ts b/apps/api/src/modules/events/sse.routes.ts index 7274f65b..2ad8b72c 100644 --- a/apps/api/src/modules/events/sse.routes.ts +++ b/apps/api/src/modules/events/sse.routes.ts @@ -7,6 +7,10 @@ * topics — comma-separated topic filter (executions, tasks, all) * Last-Event-ID — reconnection support (standard SSE header) * + * #491:Last-Event-ID 已真正消费——事件经 sse-replay-buffer 短窗口内存 buffer + * (环形,id = 服务端单调 seq),重连时补发窗口内遗漏事件;窗口外的洞不补发, + * 由前端 onReconnect 全量 refetch 兜底。 + * * Provides a simpler alternative to WebSocket for one-way server→client streaming. * Uses EventBus pub/sub (B0-002). */ @@ -15,9 +19,13 @@ import { Router, Request, Response } from 'express'; import { eventBus } from '@dommaker/studio-shared'; import { v4 as uuidv4 } from 'uuid'; import { logger } from '../../utils/logger.js'; +import { SseReplayBuffer } from './sse-replay-buffer.js'; const router = Router(); +/** #491:进程级 replay buffer(容量 500,约覆盖分钟级断线窗口;导出供测试锁定行为) */ +export const sseReplayBuffer = new SseReplayBuffer(500); + interface SSEClient { id: string; res: Response; @@ -53,10 +61,12 @@ function ensureEventSubscription() { // eventBus 精确匹配走 EventEmitter.emit,handler 抛异常会向上抛——内部 try/catch 护住 try { const topic = getTopicFromEventType(event.event_type); + // #491:先入 replay buffer(分配单调 seq 作为 SSE id 行),再广播 + const seq = sseReplayBuffer.push(topic, event.event_type, event); for (const client of clients.values()) { if (client.topics.has('all') || client.topics.has(topic)) { // 转发完整信封(event_type/event_id/timestamp/data)——客户端按 event_type 分发 - sendSSE(client, event.event_type, event, event.event_id); + sendSSE(client, event.event_type, event, String(seq)); } } } catch (error) { @@ -66,16 +76,16 @@ function ensureEventSubscription() { logger.info('[SSE] Event subscription established'); } -function sendSSE(client: SSEClient, eventType: string, data: any, eventId?: string) { +function sendSSE(client: SSEClient, eventType: string, data: any, eventId: string) { try { - const id = eventId || uuidv4(); + // `id:` 行 = 服务端单调 seq(#491,重连 Last-Event-ID 游标);信封 event_id 留在 data 内。 // 背压(#324 决策):任一 write 返回 false(内核缓冲区满)即断开慢客户端, // 不让单个慢连接拖住整个广播循环;正常客户端不受影响。 - if (!client.res.write(`id: ${id}\n`)) return disconnectSlowClient(client); + if (!client.res.write(`id: ${eventId}\n`)) return disconnectSlowClient(client); // 不写 `event:` 行(匿名事件):EventSource.onmessage 只接收匿名事件, // 命名事件必须按类型逐个 addEventListener —— 前端统一从 data.event_type 分发。 if (!client.res.write(`data: ${JSON.stringify(data)}\n\n`)) return disconnectSlowClient(client); - client.lastEventId = id; + client.lastEventId = eventId; } catch { // Client disconnected clients.delete(client.id); @@ -110,10 +120,23 @@ router.get('/stream', (req: Request, res: Response) => { 'X-Accel-Buffering': 'no', // Disable nginx buffering }); - // Send initial connection event - sendSSE(client, 'connection.established', { clientId, topics: Array.from(topics) }); - + // #491:重连补发——Last-Event-ID 落在 buffer 窗口内则按 seq 升序补发遗漏事件 + // (按订阅 topics 过滤);有洞(null)或游标无法解析则不补发,由前端 + // onReconnect 全量 refetch 兜底。补发必须在 connection.established 之前—— + // 后者以 currentSeq 为 id,先发会让 EventSource 游标越过补发事件。 ensureEventSubscription(); + const missed = sseReplayBuffer.replay(lastEventId); + if (missed) { + for (const entry of missed) { + if (!clients.has(clientId)) break; // 补发途中背压断开即停 + if (topics.has('all') || topics.has(entry.topic)) { + sendSSE(client, entry.eventType, entry.data, String(entry.seq)); + } + } + } + + // Send initial connection event(id = 当前 seq:新连接游标就位,后续重连从断点续) + sendSSE(client, 'connection.established', { clientId, topics: Array.from(topics) }, String(sseReplayBuffer.currentSeq)); // Heartbeat to keep connection alive const heartbeat = setInterval(() => { diff --git a/apps/api/src/modules/pmo/analysis-handoff.ts b/apps/api/src/modules/pmo/analysis-handoff.ts index 5dab6edd..1763101e 100644 --- a/apps/api/src/modules/pmo/analysis-handoff.ts +++ b/apps/api/src/modules/pmo/analysis-handoff.ts @@ -26,7 +26,7 @@ import { eventBus, logger, createSettledTracker, type FileStore } from '@dommake import { WorkUnitService, ANALYSIS_TASKS_MAX, type WorkUnitData, type WorkUnitMetadata } from '../workunit/workunit.service.js'; import { parseWuMetadata } from '../workunit/wu-metadata.js'; import { ChannelMessageService } from '../channels/channel-message.service.js'; -import { resolveStageRouting, routingFallbackText, type StageRoutingResolution } from '../channels/routing.js'; +import { resolveStageRouting, routingFallbackText, shouldEmitFallbackReminder, type StageRoutingResolution } from '../channels/routing.js'; import { dispatchMonitorAlerts } from '../agents/monitor/monitor-alerts.js'; export class AnalysisHandoff { @@ -237,7 +237,8 @@ export class AnalysisHandoff { ); } // #466 路由回退提醒(建单完成后出声,与任务清单同线程) - if (routingFallback) { + // #497: 同频道同档同原因冷却窗内不重复出声 + if (routingFallback && shouldEmitFallbackReminder(fresh.channelId!, 'implement', routingFallback)) { await this.post(fresh, routingFallbackText('implement', routingFallback)); } } diff --git a/apps/api/src/modules/pmo/project.service.ts b/apps/api/src/modules/pmo/project.service.ts index d31ae8d0..7bdcfc3b 100644 --- a/apps/api/src/modules/pmo/project.service.ts +++ b/apps/api/src/modules/pmo/project.service.ts @@ -8,7 +8,7 @@ import { FileStore, generateId, parseFrontmatter } from '@dommaker/studio-shared'; import { logger } from '../../utils/logger.js'; import { channelMessageService } from '../channels/channel-message.service.js'; -import { resolveStageRouting, routingFallbackText } from '../channels/routing.js'; +import { resolveStageRouting, routingFallbackText, shouldEmitFallbackReminder } from '../channels/routing.js'; import { WorkUnitService } from '../workunit/workunit.service.js'; import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -625,7 +625,8 @@ FOG: <待决问题> }); // #466 路由回退提醒(非阻断):配置失效不影响发布主链路 - if (planRouting?.fallback) { + // #497: 同频道同档同原因冷却窗内不重复出声 + if (planRouting?.fallback && shouldEmitFallbackReminder(input.channelId, 'plan', planRouting)) { await channelMessageService.createAgentMessage( input.channelId, 'Studio', diff --git a/apps/api/src/modules/workunit/CONTEXT.md b/apps/api/src/modules/workunit/CONTEXT.md index b9124f41..093fd078 100644 --- a/apps/api/src/modules/workunit/CONTEXT.md +++ b/apps/api/src/modules/workunit/CONTEXT.md @@ -15,7 +15,7 @@ WorkUnit 核心域: 任务单元 CRUD、认领与状态机; F5 双向沟通的 N - `waiting-input.ts` - F5 双向沟通 + 回复即复活(全 blocked 类型): 线程人类回复 -> active + pendingReplies 注入; ownership 挂起按工程归属解析(project-discovery 唯一命中 -> 绑定 + 写回 Requirement.projectId + 置 unassigned); wu-token-budget 按人三选分流; plan-step-limit(#471, plan 步数额度到线) 回复即续期(planStepAllowance += PLAN_STEP_LIMIT 后走通用复活); Web 按钮 POST /:id/resume + /:id/close 复用原语。 - `blocked-cta.ts` - blocked 消息统一 CTA 模板: withBlockedCta / buildDeadLetterNotice。 - `wu-closure.ts` - 系统推 WU -> closed 统一出口 closeWorkUnitWithNotice(快照 + workunit:closed 事件 + 频道说明, 各步 best-effort)。 -- `wu-messenger.ts` - WU 频道系统消息统一出口 postWuSystemMessage(走 ChannelMessageService.createAgentMessage: append + eventBus + SSE); milestone:true 解析 pmoId + atHuman:true 且 #468 起同步落 NotificationService(type=wu_milestone 全用户,带 wuId/channelId/highlight link,best-effort 不阻断发帖); lazy import pmo-branch-resolver 避环。 +- `wu-messenger.ts` - WU 频道系统消息统一出口 postWuSystemMessage(走 ChannelMessageService.createAgentMessage: append + eventBus + SSE); 线程锚点优先级:显式 opts.replyToId > metadata.anchorMessageId(#494 派单建单落档,消 created 事件先于派发消息落库的 findAnchorMessage 竞态) > 首条根消息查找; milestone:true 解析 pmoId + atHuman:true 且 #468 起同步落 NotificationService(type=wu_milestone 全用户,带 wuId/channelId/highlight link,best-effort 不阻断发帖); lazy import pmo-branch-resolver 避环。 - `claim-announce.ts` - 「认领即发声」原语 claimWorkUnitAndAnnounce(#445 自 agent-loop 私有方法提升, #175 决策 1 同一行为): claim 成功 -> WU 线程发「『认领方名』已认领任务,开始执行」普通系统消息(发声 best-effort 只 warn 不阻断, claim 失败原样抛出)。两入口同路径: AgentLoop.claimAndAnnounce(涌现, 署角色名) 与 REST POST /:id/claim(人工引导片认领, 署用户显示名; #445 起 body.agentId 可省略, 缺省 = 会话用户 id)。注意: 人工认领同样写 5min 租约, 无心跳续租, timeout-release 到期会释放回池(改释放语义属 WU 状态机/自动化行为改动, #441 Out of Scope, 留作后续决策点)。 - `timeout-release.ts` - 超时 WU 释放回 unassigned(≥3 次转 blocked), 释放即杀原 holder 进程组(kill(-pid, SIGKILL); pid 复用按 /proc 启动时间与 startedAt ±10min 比对兜底); decision/plan(#471) 单不进扫描。导出 `pidStartMatchesInstance` 供 agents/instance-timeout-scan 复用。 - `delegation-gate.ts` - A2A 委派闸门(纯代码): 成员/自派生/深度1/宽度3/树8/环/重复委派校验 + 树预算(TREE_TOKEN_BUDGET 400K; #320 起树已耗读 token 账本 `utils/token-ledger`, 不再每次委派全扫事件流; 账本同步失败兜底 treeTotal=0/pass, 与原读失败语义一致)。 diff --git a/apps/api/src/modules/workunit/__tests__/wu-messenger.test.ts b/apps/api/src/modules/workunit/__tests__/wu-messenger.test.ts index b836bcca..42a135a3 100644 --- a/apps/api/src/modules/workunit/__tests__/wu-messenger.test.ts +++ b/apps/api/src/modules/workunit/__tests__/wu-messenger.test.ts @@ -181,6 +181,41 @@ describe('anchor 线程锚点(AC-C2 迁移)', () => { }); }); +describe('#494 显式 anchor(metadata.anchorMessageId,mention 派单建单时落档)', () => { + it('WU 携 anchorMessageId → 优先锚它,即使存在更早的根消息(消除 findAnchorMessage 时序竞态)', async () => { + const wu = await createWu(); + await appendMsg(wu.id, { content: '更早的根消息', createdAt: '2026-09-01T00:00:00Z' }); + const dispatch = await appendMsg(wu.id, { content: '派发消息', createdAt: '2026-09-02T00:00:00Z' }); + await wuService.update(wu.id, { metadata: { anchorMessageId: dispatch.id } }); + const fresh = (await wuService.getById(wu.id))!; + + const record = await postWuSystemMessage(fresh, '认领播报', { fileStore }); + + expect(record!.replyToId).toBe(dispatch.id); + }); + + it('无 anchorMessageId → 既有 anchor 语义不变(最早根消息)', async () => { + const wu = await createWu(); + await appendMsg(wu.id, { content: 'later root', createdAt: '2026-09-01T00:02:00Z' }); + const earlier = await appendMsg(wu.id, { content: 'earlier root', createdAt: '2026-09-01T00:01:00Z' }); + + const record = await postWuSystemMessage(wu, '跟进', { fileStore }); + + expect(record!.replyToId).toBe(earlier.id); + }); + + it('显式 opts.replyToId 优先于 metadata.anchorMessageId', async () => { + const wu = await createWu(); + const dispatch = await appendMsg(wu.id, { content: '派发消息', createdAt: '2026-09-02T00:00:00Z' }); + await wuService.update(wu.id, { metadata: { anchorMessageId: dispatch.id } }); + const fresh = (await wuService.getById(wu.id))!; + + const record = await postWuSystemMessage(fresh, '挂指定消息', { fileStore, replyToId: 'explicit-msg-id' }); + + expect(record!.replyToId).toBe('explicit-msg-id'); + }); +}); + describe('里程碑 meta(2026-07 PMO-flow UX §6-3/§10)', () => { it('milestone: true + pmoId 解析命中 → meta 带 pmoId + atHuman', async () => { mockResolvePmoProjectId.mockResolvedValue('proj-1'); diff --git a/apps/api/src/modules/workunit/workunit-crud.ts b/apps/api/src/modules/workunit/workunit-crud.ts index 5807122b..b7b4ad30 100644 --- a/apps/api/src/modules/workunit/workunit-crud.ts +++ b/apps/api/src/modules/workunit/workunit-crud.ts @@ -13,7 +13,7 @@ import { randomUUID } from 'crypto'; import { logger, eventBus, FileStore, type WorkUnitSnapshot, type WorkUnitEvent } from '@dommaker/studio-shared'; import { ChannelMessageService, channelMessageService } from '../channels/channel-message.service.js'; -import { resolveStageRouting, routingFallbackText, ROUTING_STAGE_LABELS } from '../channels/routing.js'; +import { resolveStageRouting, routingFallbackText, shouldEmitFallbackReminder, ROUTING_STAGE_LABELS } from '../channels/routing.js'; import { postWuSystemMessage } from './wu-messenger.js'; import { resolveInitialStatus, WU_LEASE_TTL_MS } from './workunit.types.js'; import { buildStatusById, resolveClaimable } from './wu-dependencies.js'; @@ -316,6 +316,8 @@ export class WorkUnitCrudService { fallback: routing.fallback, profileName: routing.profileName, }); + // #497: 同频道同档同原因冷却窗内不重复出声(路由回退本身不受影响) + if (!shouldEmitFallbackReminder(parent.channelId!, 'implement', routing)) return; } await postWuSystemMessage(parent, notice, { fileStore: this.fileStore, diff --git a/apps/api/src/modules/workunit/workunit.types.ts b/apps/api/src/modules/workunit/workunit.types.ts index 900569aa..b83eb296 100644 --- a/apps/api/src/modules/workunit/workunit.types.ts +++ b/apps/api/src/modules/workunit/workunit.types.ts @@ -24,6 +24,10 @@ export interface WorkUnitMetadata { _consecutiveReviewRejections?: number; // 连续 review reject 计数(3x → auto-block) sourceMessageId?: string; // createFromMessage 涌现路径来源 creationMode?: string; // 创建模式:from-message / manual + // #494(方案 c,票内预授权):频道派发消息 id——mention/频道默认派单建单时落档; + // wu-messenger 系统消息(认领播报等)优先锚它,消除「created 事件先于派发消息落库」的 + // findAnchorMessage 时序竞态;缺失时回退既有「首条根消息」语义 + anchorMessageId?: string; _cumulativeTokens?: number; // 内部 token 累计追踪 // #162(T8-E1,#130 决策 3):WU 级 token 预算上限(显式数值,任何类型可带,首个消费 = 巡检单)。 // 对照 _cumulativeTokens(billed 口径簿记)超线 → need_input 挂起待人三选 diff --git a/apps/api/src/modules/workunit/wu-messenger.ts b/apps/api/src/modules/workunit/wu-messenger.ts index 5dfaaa01..a9c0b763 100644 --- a/apps/api/src/modules/workunit/wu-messenger.ts +++ b/apps/api/src/modules/workunit/wu-messenger.ts @@ -9,7 +9,8 @@ * ChannelMessageService.createAgentMessage(append + eventBus 'channel.message_sent' + SSE)。 * * 形态约定: - * - 默认 agentName='Studio',挂在 WU 线程 anchor(首条根消息)下;显式 replyToId 时跳过 anchor 查找。 + * - 默认 agentName='Studio',挂在 WU 线程 anchor 下——显式 replyToId > metadata.anchorMessageId + * (#494 派单落档)> 首条根消息查找;显式 replyToId 时跳过 anchor 解析。 * - milestone=true → 里程碑 meta(best-effort 解析 pmoId + atHuman:true, * 2026-07 PMO-flow UX §6-3/§10:NotificationBell 监听 meta.atHuman,pmoId 供跳转 PMO 详情); * opts.meta 合并覆盖里程碑 meta。 @@ -21,7 +22,7 @@ import { FileStore, logger, type ChannelMessageData } from '@dommaker/studio-shared'; import { NotificationService } from '@dommaker/studio-notification'; import { ChannelMessageService, type MessageMeta, type MessageRecord } from '../channels/channel-message.service.js'; -import { parseWuTitle } from './wu-metadata.js'; +import { parseWuMetadata, parseWuTitle } from './wu-metadata.js'; import type { WorkUnitData } from './workunit.service.js'; export interface PostWuSystemMessageOptions { @@ -106,7 +107,11 @@ export async function postWuSystemMessage( if (!trimmed || !wu.channelId) return null; const fileStore = opts?.fileStore ?? new FileStore(); - const anchor = opts?.replyToId === undefined + // #494:WU metadata.anchorMessageId(派单建单时落档的派发消息 id)优先于 + // findAnchorMessage——workunit.created 同步触发 observe→claim→认领播报,显式 anchor + // 消除「派发消息尚未落库 → 找不到 anchor → 播报落独立根」的时序竞态;缺失时回退既有语义。 + const metaAnchor = opts?.replyToId === undefined ? parseWuMetadata(wu.metadata).anchorMessageId : undefined; + const anchor = opts?.replyToId === undefined && !metaAnchor ? await findAnchorMessage(wu.id, wu.channelId, fileStore).catch(() => null) // anchor 查询失败不阻断发帖 : null; const meta: MessageMeta | undefined = opts?.milestone @@ -117,7 +122,7 @@ export async function postWuSystemMessage( wu.channelId, opts?.agentName ?? 'Studio', trimmed, - { replyToId: opts?.replyToId ?? anchor?.id ?? undefined, meta, workUnitId: wu.id }, + { replyToId: opts?.replyToId ?? metaAnchor ?? anchor?.id ?? undefined, meta, workUnitId: wu.id }, ); if (opts?.milestone && record) { diff --git a/apps/web/src/CONTEXT.md b/apps/web/src/CONTEXT.md index 2b9e158c..778d0978 100644 --- a/apps/web/src/CONTEXT.md +++ b/apps/web/src/CONTEXT.md @@ -49,7 +49,7 @@ Web 前端主源码。路由、全局状态、API 客户端、UI 组件、样式 | `CommandPalette` / `useGlobalShortcuts` | `components/CommandPalette.tsx` / `hooks/useGlobalShortcuts.ts` | 批次 D-2 项 8:Cmd/Ctrl+K 全局搜索面板 + 全局快捷键注册底座(细节见运行时约定「全局搜索」条) | | `useStreamFollow` / `useChannelCardActions` | `hooks/` | 频道流滚动状态机(#322 自 ChannelDetailPage 整块搬移)/ 卡片 action 路由(dispatch 单一入口;#352 起人审提案分支经 PROPOSAL_ACTION_INDEX 参数化调用 proposalCardConfigs.exec) | | `useProposalReview` / `ReviewProposalCard` | `hooks/useProposalReview.ts` / `components/channel/ReviewProposalCard.tsx` | 人审提案卡合一(#352,ADR 2026-08-25 决策 5):6 卡坍缩为壳 + `proposalCardConfigs` 纯数据配置(#356 auditor_suggestion 并入,AuditorSuggestionCard 删除);reviewed/pending/armed 生命周期 + 挂载期派生已审态单点化;act 执行未全绿时按提案状态重派生一次,命中终态即时收敛;memory 卡 exec 逐草稿结算含 not-pending 同向终态幂等跳过 + failed 终态标签(#367,正本闸门不可逆) | -| `ChannelWorkBar` | `components/channel/` | 频道工作条(2026-09 合并 ChannelLiveBars + ChannelStageBar,docs/plans/2026-09-channel-workbar.md):自持有 useChannelLiveExecutions(#322 重渲边界不变,step 事件不触达页面);渲染规则 = 无 currentWu 无 active 留「状态同步中…」占位(#474,原整条静默消失)/ 无 currentWu 仅 live 列表 / 有 currentWu stepper 主区(阶段语义 deriveDisplayState 同口径)+ 自身 active 叠加「第 N 步 · 动作」+ 其他 active「+N 进行中」chip 展开列表;currentWuId 未命中 fail-closed 主区不渲染。**2026-09 批次1 stepper 视觉强化**(docs/plans/2026-09-channel-visual-polish.md ①):全部经 `.mc-workbar` 作用域覆盖 wu-detail.css 的 wu-stepper-bar/wu-bstep-line/wu-st-* 类(紧凑定长连线左对齐 + max-width 640 / 当前站点径 12px+发光 / 连线提对比 / 时间戳 opacity 弱化 / 底 hairline 提一档)——**全局 stepper 类不动,WU 详情页不受影响**,后续工作条视觉调整沿用此作用域模式。**E1 站点可点**(2026-09 页面重设计):stepper 经 StationStepper 可选 `onStationClick` 渲染为按钮,点击开 currentWu 抽屉(与 live 徽标统一入口);按钮/hover/focus 样式同样走 `.mc-workbar` 作用域覆盖。**批次 D-2 项6 频道内闸门 1 击化**(docs/plans/2026-09-ui-interaction-polish.md):可选 `gate` prop(`WorkBarGateHandlers` = 三个写路径回调,ChannelDetailPage 注入——直调 API + 响应体就地并入 channelWus,status_changed SSE + 建议重拉兜底);currentWu 处于闸门态(pending / in_review / done 缺 l3,与 WuGateActions 内部分支同口径)时工作条右端 `.mc-workbar-gate` 直挂共享 WuGateActions(btn-sm 紧凑档),不离开消息流完成闸门;工作条内 `.modal-overlay` 作用域提 z-index 400(全站 50 < 抽屉 300,覆盖式抽屉同开时弹窗防被盖) | +| `ChannelWorkBar` | `components/channel/` | 频道工作条(2026-09 合并 ChannelLiveBars + ChannelStageBar,docs/plans/2026-09-channel-workbar.md):自持有 useChannelLiveExecutions(#322 重渲边界不变,step 事件不触达页面);渲染规则 = 无 currentWu 无 active 留占位(#474,原整条静默消失;#488 起占位三态——可选 `wuIdle` prop,语义 = 建议端点已成功返回且 currentWuId=null,ChannelDetailPage 按 `suggestionsResolvedFor === id` 记账(切频道自动回加载态、请求失败 catch / #490 degraded=true 响应均不落账 → 不误显空闲)→ 空闲文案「频道暂无进行中的工作」,否则加载态「状态同步中…」)/ 无 currentWu 仅 live 列表 / 有 currentWu stepper 主区(阶段语义 deriveDisplayState 同口径)+ 自身 active 叠加「第 N 步 · 动作」+ 其他 active「+N 进行中」chip 展开列表;currentWuId 未命中 fail-closed 主区不渲染。**2026-09 批次1 stepper 视觉强化**(docs/plans/2026-09-channel-visual-polish.md ①):全部经 `.mc-workbar` 作用域覆盖 wu-detail.css 的 wu-stepper-bar/wu-bstep-line/wu-st-* 类(紧凑定长连线左对齐 + max-width 640 / 当前站点径 12px+发光 / 连线提对比 / 时间戳 opacity 弱化 / 底 hairline 提一档)——**全局 stepper 类不动,WU 详情页不受影响**,后续工作条视觉调整沿用此作用域模式。**E1 站点可点**(2026-09 页面重设计):stepper 经 StationStepper 可选 `onStationClick` 渲染为按钮,点击开 currentWu 抽屉(与 live 徽标统一入口);按钮/hover/focus 样式同样走 `.mc-workbar` 作用域覆盖。**批次 D-2 项6 频道内闸门 1 击化**(docs/plans/2026-09-ui-interaction-polish.md):可选 `gate` prop(`WorkBarGateHandlers` = 三个写路径回调,ChannelDetailPage 注入——直调 API + 响应体就地并入 channelWus,status_changed SSE + 建议重拉兜底);currentWu 处于闸门态(pending / in_review / done 缺 l3,与 WuGateActions 内部分支同口径)时工作条右端 `.mc-workbar-gate` 直挂共享 WuGateActions(btn-sm 紧凑档),不离开消息流完成闸门;工作条内 `.modal-overlay` 作用域提 z-index 400(全站 50 < 抽屉 300,覆盖式抽屉同开时弹窗防被盖) | | `ChannelTopbarMenu` | `components/channel/` | E1 顶栏 ⋯ 菜单(2026-09 页面重设计):收纳成员管理(ChannelMemberManager `triggerClassName` 换菜单行形态)/ 工单路由(ChannelRoutingEditor,#466 三档 plan/implement/review 下拉,候选=频道成员,乐观更新失败回滚,存量配置面板展开时经 channelApi.get 拉取)/ 默认工程(ChannelDefaultProjectSelect)/ 频道动态入口(<1024,外层 `.mc-act-open` 沿用既有显隐);点外部收起,`.select-panel`(Select portal 面板)点击豁免。**#474:「当前 PMO」chip 不再收进本菜单**——提升为顶栏可见位(ChannelDetailPage 直挂 ChannelCurrentPmoChip,与待办 chip 同排) | | `NeedsAttentionSection` | `components/monitoring/` | 监控页「需要处理」区;#398 告警按归一化 message 签名分组(`alertGrouping.ts`:数字/hex id 占位归一,×N + 最近时间,>3 组折叠「还有 N 类」,纯前端不动探针口径)。**E4 告警下钻**(2026-09 页面重设计):可选 `onAlertClick(group)`——告警行渲染为按钮,点击 → MonitoringPage 切事件检索 tab 并经 `EventSearchPanel` 新 `initialFilters` prop 预填 `type=monitor:alert` + level=warning + 签名关键词(`alertSignatureKeyword`:原文切除 hex id/数字段取最长文本段,同组共享稳定段),面板带 initialFilters 挂载即自动检索一次;手动回概览时清 seed 防旧预填重放 | | `MonitorSection` / `charts` | `components/monitoring/MonitorSection.tsx` / `charts.tsx` | #398 监控页区块容器(§7.5:术语标题 + 白话副标题 + 可选 22px 主数字 --fs-stat+mono)与手搓图表三件套(UsageBar 预算用量条 / DayBars byDay 柱 / HBars 横条,零图表库,样式 `mc-*` 在 mission-control.css,承 #396 先例) | @@ -66,7 +66,7 @@ Web 前端主源码。路由、全局状态、API 客户端、UI 组件、样式 | `mergeStepEvents` / `lastProgressEntry` / `formatStepTokens` | `utils/executionSteps.ts` | 执行步共享纯函数(#396 自 ExecutionSteps 抽取,ExecutionFlow 共用):步事件并集合并 / progressLog 末条 / token k 缩写 | | `renderSuggestionCopy` | `utils/suggestionCopy.ts` | #443(spec #441)引导文案模板注册表:后端建议端点只回 `{id,kind,params}` 结构化数据,文案全部经本注册表渲染(说人话、带工单上下文、无内部术语;快照测试锁定);未知模板 id → null(fail-closed 不渲染)。#446 起注册 prompt 形态模板(`diagnose-blocked` / `transcribe-review-checklist`,后者 label 冠「可选」):prompt 片的预填指令本体不在本注册表——由后端 `text` 字段承载(单一正本,契约测试在后端断言 @mention 可解析),本注册表只渲染展示文案 label/hint。#447:#440 静态映射 `utils/wuSuggestions.ts`(suggestionsForWu/pickCurrentWu)已删——引导片与阶段条「当前工单」唯一来源 = 建议端点(currentWuId) | | `getSuggestionAction` | `utils/suggestionActions.ts` | #444 动作片 → 确定性接口注册表:`redispatch-review` → `workunitApi.dispatchReview`(直调 dispatch-review 端点,与 ReviewDispatcher 自动派发同原语;URL 契约测试锁定);#445 `claim-wu` → `workunitApi.claim(wuId)`(直调 claim 端点,认领即发声原语与 loop 自动认领同路径;前端不带身份,认领人由服务端按会话用户解析)。附确认弹窗文案(title/confirmLabel/confirmMessage);未注册 id → null(fail-closed 不执行) | -| `SuggestionChips` / `AgentAvatar` | `components/channel/` | #440:建议片(点击经 ChannelInput `prefill={text,nonce}` 通道填入,dismiss 会话级)/ 频道顶部阶段条(复用 buildLifecycle+StationStepper,deriveDisplayState 同口径;2026-09 已并入 ChannelWorkBar)/ per-agent identicon 确定性头像(图样在 utils/avatar)。#443:SuggestionChips 扩为三形态渲染骨架——status 只读(非按钮无发送语义,`.mc-suggest-status`)/ action(onAction 直调骨架,#444 起有产出方:补派评审经页面 ConfirmDialog 一次确认后 `getSuggestionAction` 直调,生效后重拉建议片随前置条件转假消失,失败原因内联进弹窗)/ prompt(缺省)。#446:端点 prompt 片透传后端 `text` 进 SuggestionChips(点击 → onPick(text) 预填进输入框,人可编辑后发送走既有 @mention 消息路由,不自动发送;prompt 片缺 text → 不渲染,fail-closed 不点空指令)。#447:引导片唯一来源 = 端点派生片(静态映射已删),dismiss 台账 key = `ep:{wuId}:{suggestionId}`;阶段条 currentWu = 端点 currentWuId × channelWus(命中不了不渲染,fail-closed) | +| `SuggestionChips` / `AgentAvatar` | `components/channel/` | #440:建议片(点击经 ChannelInput `prefill={text,nonce}` 通道填入,dismiss 会话级)/ 频道顶部阶段条(复用 buildLifecycle+StationStepper,deriveDisplayState 同口径;2026-09 已并入 ChannelWorkBar)/ per-agent identicon 确定性头像(图样在 utils/avatar)。#443:SuggestionChips 扩为三形态渲染骨架——status 只读(非按钮无发送语义,`.mc-suggest-status`)/ action(onAction 直调骨架,#444 起有产出方:补派评审经页面 ConfirmDialog 一次确认后 `getSuggestionAction` 直调,生效后重拉建议片随前置条件转假消失,失败原因内联进弹窗)/ prompt(缺省)。#446:端点 prompt 片透传后端 `text` 进 SuggestionChips(点击 → onPick(text) 预填进输入框,人可编辑后发送走既有 @mention 消息路由,不自动发送;prompt 片缺 text → 不渲染,fail-closed 不点空指令)。#447:引导片唯一来源 = 端点派生片(静态映射已删),dismiss 台账 key = `ep:{wuId}:{suggestionId}`;阶段条 currentWu = 端点 currentWuId × channelWus(命中不了不渲染,fail-closed)。#484:dismiss 收片粒度——每片独立 ✕(✕ 不嵌片按钮,片 + ✕ 由 `.mc-suggest-item` 容器并排承载),onDismiss 上送被点片、页面按 dismissKey 单片记账;台账会话级(刷新复活)语义不变,持久化需先改 #441 spec 另行提案 | | `icons` | `components/ui/icons.tsx` | #474 图标策略定稿(全去 emoji):16 枚 stroke SVG 图标组件(IconChat/IconChart/IconClipboard/IconUsers/IconBook/IconLibrary/IconActivity/IconSearch/IconSettings/IconGrid/IconCheck/IconRefresh/IconClock + 批次 D-0 补 IconSun/IconMoon/IconMonitor 主题三态)——24×24 viewBox、stroke=currentColor 随主题/active 态、aria-hidden 不进读屏、size 默认 16;Sidebar 主导航 / MoreDropdown / DeliveryPanel 徽章与台账 / 主题切换控件(ThemeToggleButton/ThemeSettings)同源消费,新图标需求一律加在此 | | `MetaStrip` | `components/ui/MetaStrip.tsx` | #440 标题下密排元信息条:label:value 项,空值省略、全空不占位(WU 详情页 / PMO 项目页共用) | | `Skeleton` / `SkeletonText` / `SkeletonCard` | `components/ui/Skeleton.tsx` | 批次 E-2 静态骨架占位(样式 `.skeleton`/`.skeleton-text` 在 theme.css「加载状态」区,紧邻 `.loading-spinner`):加载态替代「加载中...」纯文字,WU 列表/详情、ProjectDetail、PMO、DiscussionPanel、ProjectPipeline 6 处接入;整块 aria-hidden。**静态零动画——禁止给 skeleton 加 shimmer/pulse,style-guide §2.5 动效白名单不覆盖** | @@ -88,7 +88,7 @@ Web 前端主源码。路由、全局状态、API 客户端、UI 组件、样式 - **未归属过滤(#405,消费 #428 API)**:WorkUnitListPage 状态 pill 行新增「未归属」pill——服务端过滤(store `unattributedOnly` → `attributed=false` 参数,与 status/type 过滤交集组合),计数徽标 = 服务端 pagination.total(`unattributedTotal`,挂载/重连经 `loadUnattributedCount` limit=1 轻量拉取;loadWorkUnits 仅在无 status/type 组合过滤时顺带同步,组合过滤下 total 是交集计数不得覆盖徽标;best-effort 失败留旧值,null 不显数字)。`applyWorkunitEvent` 过滤匹配新增归属维度:过滤态下 SSE 增量行须过 `isUnattributedWu`(无 reqId 且戳 pmoId‖legacy ownershipProjectId 为 null,与服务端 parseWuPmoId 同口径的本地镜像)。 - **WorkUnit 列表页行形态**(2026-09 UI 丝滑化 Step 2,docs/plans/2026-09-ui-smoothness.md):筛选合一——第二套 pill 筛选行已删,统计条改可点击 `StatChip`(总数/待确认/待领取/进行中/待验收/待人工,点击过滤/再点回全部);行从 card 边框块改 `.wu-row` 无边框行(行间细分隔线 + 左侧 3px 状态色条 + hover 微底色)。样式独立文件 `styles/workunits.css`(页面内 import),状态色与 `WU_STATUS_COLORS` 语义一致。**E2 页面重设计**(docs/plans/2026-09-page-redesign.md):整行展开区(MetaStrip 9 字段/metadata toggle/ExecutionSteps/DiscussionPanel/ReviewHint)已删——行点击直跳 `/workunits/:id` 详情页(**2026-09-10 起抽屉形态已撤**:列表行原挂的 `WorkUnitDrawer` 与行尾「↗」深链均已删,行本体即唯一入口,本页零 Drawer 引用),行内闸门按钮(WuGateActions)保留作快速处置;分页 E2-5 = 底部「加载更多」追加式(store `loadMoreWorkUnits` 拉 page+1 按 id 去重拼接)+「已加载 X / 共 N」(共 N = pagination.total,与「总数」chip 同口径)。**批次 D-1.6 视觉试点**(docs/plans/2026-09-ui-interaction-polish.md,纯视觉层不动数据流):① StatChip 数字档归 `--fs-title`(16px)+font-mono(`--fs-stat` 按 style-guide §2.3 仅统计卡大数字用,chip 非统计卡;label 保持 `text-sm`=--fs-sm),激活 chip 追加 accent 底线(border-bottom 2px var(--accent-primary) + padding-bottom 4→3 补偿防抖动),#472 颜色语义不动;② 「待人工」行级提权——`derived.needsHuman`(in_review ‖ done 缺 l3,与统计 chip 同口径)行加 `.wu-row-human` 类,整行 `--warning-dim` 底色,hover 仍走 `--bg-hover` 不透明覆盖提亮(显式 `.wu-row.wu-row-human:hover` 规则锁叠加顺序);行 hover 即 `--bg-hover`(无私有 hover 值)。**批次 D-2 项4 标题搜索**(docs/plans/2026-09-ui-interaction-polish.md):页头 chip 行下加 `.input` 搜索框,300ms 防抖(LibraryPage 先例:debounceRef + 跳首次 effect)进 store `searchQuery`(`setSearchQuery` 归一 trim/空白→null,词未变 no-op 不重拉),loadWorkUnits/loadMoreWorkUnits 带 `q` 走服务端过滤(与 status/type/attributed 交集,total 即过滤后计数,「已加载 X / 共 N」随过滤自然变化);`applyWorkunitEvent` 的 matches 增 q 维度(scope 大小写不敏感子串,与服务端同口径——搜索态 SSE 增量不匹配不插入/不匹配即移除);搜索激活时 chip 行「计数口径」标注同 #472 规则出现。 - **「已沉睡」徽标 StaleSleepBadge**(#464,components/workunit/StaleSleepBadge.tsx,挂列表行 + 详情页头):72h 认领陈旧守卫命中的 WU 此前仍显示「待领取」。判定与后端 monitor-probes 同源——`metadata.staleGuardBlockedAt === updatedAt` 且 unassigned(守卫落盘的沉睡锚点);任何写刷新 updatedAt 即复活、徽标自消,无额外同步机制。频道侧出声由后端 stale_claim_guard 探针发 milestone 提醒。 -- **共享门禁轮询 `useGatedPoll`**(#313,架构评审候选 8):组件级私有轮询已收敛——`useRosterStoreSync`(#346 起 ChannelRail/useAgentRoster 的 30s 轮询经 store 接线单点走该 hook)、`useChannelEvents`(10s);语义 = 挂载首拉 + 仅当(visible ∧ SSE≠connected)轮询 + 回 visible 立即补拉;消费方 403 终态逻辑留在各自 fetch 闭包(短路请求,不新增依赖)。新轮询需求一律复用,禁止再写裸 setInterval 轮询(NotificationBell UI flash 定时器除外)。**useChannelEvents refetch = 合并语义**(#328):同频道 refetch 最新一页按 id 归并(已存在的以服务端版本刷新、新消息按 createdAt 有序插入),prepend 的历史页不丢;首拉/频道切换仍替换。hasMore 仅当本地最老消息落在最新一页内才以响应为准(页的 hasMore 描述头部方向,prepend 出页外时直接覆盖会错误重置 prepend 方向状态)。已知留白:SSE 连着时 AgentDashboardPage 上新建 profile 要等 remount 才发现(无 `agent.profile.created` 事件,决策接受)。 +- **共享门禁轮询 `useGatedPoll`**(#313,架构评审候选 8):组件级私有轮询已收敛——`useRosterStoreSync`(#346 起 ChannelRail/useAgentRoster 的 30s 轮询经 store 接线单点走该 hook)、`useChannelEvents`(10s);语义 = 挂载首拉 + 仅当(visible ∧ SSE≠connected)轮询 + 回 visible 立即补拉;消费方 403 终态逻辑留在各自 fetch 闭包(短路请求,不新增依赖)。新轮询需求一律复用,禁止再写裸 setInterval 轮询(NotificationBell UI flash 定时器除外)。**useChannelEvents refetch = 合并语义**(#328):同频道 refetch 最新一页按 id 归并(已存在的以服务端版本刷新、新消息按 createdAt 有序插入),prepend 的历史页不丢;首拉/频道切换仍替换。hasMore 仅当本地最老消息落在最新一页内才以响应为准(页的 hasMore 描述头部方向,prepend 出页外时直接覆盖会错误重置 prepend 方向状态)。**useChannelMessages 暴露 `error` 态**(#482):首拉/兜底轮询共用 fetchMessages,失败置 error(消息不清空),成功/频道切换清——ChannelDetailPage 据此三态分流:加载中骨架 / error 且 messages 空 → 错误态+重试钮(走 refresh)/ 真空态示例 chip;已有消息时轮询失败不整屏替换。已知留白:SSE 连着时 AgentDashboardPage 上新建 profile 要等 remount 才发现(无 `agent.profile.created` 事件,决策接受)。 - **一次性拉取 `useAsyncData`**(#350,架构评审候选 F5):useGatedPoll 之外的姊妹 hook,吃掉 `useState(data/loading/error)+useCallback(load)+useEffect(load)` 样板——6 处消费方归一(MonitoringPage / NeedsAttentionSection / ProjectDetailPage / PMOPage / KnowledgePage / AgentDetailPage 历史窗口)。语义:挂载首拉(微任务推迟,纪律同 useGatedPoll)+ deps 渲染期重置(切参当帧清数据置回加载,prevX 微模式内建)+ `reload()`(重拉保留旧数据、即清 error,刷新按钮/事件路径复用)+ `setData`(SSE 就地更新绕过 fetch 生命周期)。约定:**错误上屏与静默的分工留在 fetcher**——主拉取直接 reject(hook 落 `error.message`,要友好文案就在 fetcher 内 `throw new Error(...)`),best-effort 子拉取 fetcher 内自 catch 落 null(hook error 不上屏)。依赖驱动级联:ProjectDetailPage 子拉取 deps=[project.data](身份变化即级联重拉,替代原 loadData 全量串行)。新的一次性拉取一律复用,禁止再写裸 useState+useEffect 组合。已知行为差:拉取失败后成功刷新会清 error(原 MonitoringPage 永不清理的潜在 bug 顺带修正);ProjectDetail 子拉取由串行改并行(首屏更快,pop-in 顺序不变)。 - **SSE 负载消费约定·message_updated**(#315,ADR 2026-08-24 D1/D2):`useChannelMessages` 处理 `channel.message_updated` 优先读负载 `message` 全量本体(meta 为后端合并后真值)整体原位替换;旧形状(无 `message` 字段)回退顶层增量 `content`/`meta` patch。`createdAt` 为不可变诞生时刻(#317,ADR 2026-08-24 message-createdat-immutable),原位替换与刷新归位因此恒一致。 - **SSE 负载消费约定·instance status_changed**(#312,ADR 2026-08-24 体检;#313 修订):`agent.instance.status_changed` 负载带 `currentWorkUnit` 快照/`channelId`/`lastError` 系字段——#346 起就地更新唯一一份在 `rosterStore.applyInstanceStatusEvent`:以负载快照为准(含悬空 null),仅旧事件(无快照字段)回退补查;instanceId ‖ roleId 匹配(error 事件带新 instanceId 时RoleId 兜底并切 id),**未匹配实例 = 新角色实例,用负载合成条目插头部**(轮询不承担发现职责);原 useAgentRoster/ChannelRail 两副本已删。消费方 selector 订阅(roster 卡片/ChannelRail 状态点/AgentDetailPage),online 计数等派生面各自 useMemo。30s 轮询经 useRosterStoreSync→useGatedPoll 保留为 SSE 断开兜底。 @@ -108,9 +108,10 @@ Web 前端主源码。路由、全局状态、API 客户端、UI 组件、样式 - **批次 G-6**(docs/plans/2026-09-11-ui-polish-batch-fg.md,2026-09-11)统计档归一:MonitoringPage StatCard 大数字 `--fs-title` → `--fs-stat`(对齐 AgentDashboardPage/AgentDetailPage/AuditLogsPage/MonitorSection/ProjectProgressCard 五处既有口径,§2.3 统计大数字专用档)。 - **批次 H-1**(2026-09-11,治理人闸当场过)样式规范根部修正四项——把规矩改对而非给现状开口子:① §2.5 动效白名单 4 类扩 5 类,新增场景⑤「加载与进度反馈」(spinner 旋转 theme.css `spin 0.8s`、`.mc-dot-busy` pulse、进度条 `transition-all` 宽度/填充过渡收编——历史遗留白名单外动效名分落地,代码零改动);② §4.8 补豁免——统计卡大数字(`--fs-stat` 档)数据强调允许 accent(AgentDetailPage/MonitoringPage/AuditLogsPage 等统计卡现状收编);③ §2.3 补豁免——长文阅读正文(markdown 渲染、文档阅读页)允许 `lineHeight: 1.8`(MarkdownBody/LibraryDocPage 正本);④ §4.2 label 写法根治——原条文给的四属性内联示例与规则 2「禁内联布局样式」自相矛盾,theme.css「输入组件」区新增 `.form-label` 组件类(§4.4 登记),代码迁移 4 处(FirstRoleSetupModal ×3、StudioRoleSetupModal ×1,原 `u-text` + 四属性内联 → 单类;全仓 grep 复核无其他命中)。**留痕说明**:style-guide.md 被 .gitignore `docs/*` 忽略不入 git,条文修正只落本地磁盘;决策留痕 = 本条 + commit trailer `Governance-Approved: session`。 - **批次 H-2**(2026-09-11,docs/plans/2026-09-channel-attachments.md)频道截图粘贴/上传:ChannelInput 加 onPaste(clipboardData.files 有图 → 上传 → 光标处插入 markdown 图片语法 `![name](url)`,无图不拦默认文本粘贴)+ 图片选择按钮(隐藏 input[type=file],ui/icons 新增 IconImage);上传 = `channelApi.uploadAttachment`(JSON base64,POST /channels/:id/attachments),客户端先拦类型白名单/5MB 上限即时 toast,失败 toast 不动草稿(草稿从未被触碰,比发送失败回灌更简单),上传中 hint 行出「上传图片中…」占位。渲染双侧出图——agent 侧 MarkdownBody img 渲染器接 `resolveAttachmentSrc`(`utils/attachmentUrl.ts`:命中频道附件相对 URL 现拼 `?token=`(buildSseUrl 同款现取 authStore token,token 不落消息文本),其余 src 原样透传);人类/系统侧纯文本渲染自 `renderWithMentions` 升级为 `renderWithMentionsAndImages`(`utils/messageImages.tsx`:只识别本系统附件 URL 的图片语法 → ,任意外链图不渲染按文本原样输出,mention chip 共存)。消息体存相对 URL 即完整事实源,刷新后重渲现拼 token,天然持久。 +- **#485**(2026-09-11)composer 文件引用台账可视化:ChannelInput 已挂 fileRefs 渲染为输入框上方 chip(`.mc-fileref-chips`/`.mc-fileref-chip`,路径 + 工程 basename),✕ 单独移除(只动台账不动正文,发送过滤自然不携带);正文被编辑得不再含路径时 chip 标灰 + 「已失效」标记(`.mc-fileref-chip-invalid`),不静默丢弃;发送仍只上送正文含路径的有效引用(行为不变)。 - **导航骨架**(#393,spec §2/§4.1/§4.4):左侧菜单仅 4 主项(频道/PMO/WorkUnit/Agent),知识库/阅览室/监控/设置/审计日志收进顶部 header「更多」下拉(`MoreDropdown`,原 sidebar「更多」展开组已移除);全局 sidebar 桌面宽 176px(`responsive.css` ≥640px 档);详情页返回一律 `ui/BackButton`(PMO 项目→/pmo、Agent→/agents、WU→/workunits、阅览室文档→/library、Workspace→/settings),禁止再写右侧「返回」或散落的 navigate(-1) 拷贝。**E4/B-8 监控入口可见性**:MoreDropdown 按钮挂待处理计数徽标(近 24h 告警 overview.alerts.last24h + 提案待审 flywheel.proposalsPendingReview,两端点 60s 服务端缓存,best-effort 失败/403 落 0 隐藏;挂载首拉 + 展开时重拉,无 SSE 告警事件可接故不接重连;徽标 = NotificationBell 同款绝对定位独立槽位防 ellipsis 吞掉)。**#474 布局/信息架构批**:① 双路由合并——`/project/:id` 收编为 App 内 replace 重定向到唯一入口 `/pmo/project/:id`(存量书签/外链不失效,PmoNumberLink 同改);② 监控入口有待处理时升主导航临时项——SidebarNew 订阅 notificationStore.unreadCount(与 MoreDropdown 徽标同源口径),>0 追加「监控」项(带计数徽标 99+ 截断),归零即撤;③ 图标策略定稿**全去 emoji**——导航/徽章统一 `components/ui/icons.tsx` stroke SVG(详见该模块表行);④ 消息 footer 三枚同形 chip 差异化配色(`.mc-wu-link--wu` accent / `--pmo` success / `--req` warning)。 - **词表与频道名渲染唯一出口**(#429):WU 状态文案/配色一律消费 `@dommaker/studio-shared/web` 的 `WU_STATUS_LABELS`/`WU_STATUS_COLORS`(#399 §8.3 正词:待领取/进行中/待验收/完成),禁止组件内私有拷贝(#429 收 RequirementChainPanel,#472 收 ProjectPipeline——泳道词/色与 WU 卡 chip 同源自此);频道名渲染一律 `formatChannelName`(数据本身含前导 `#`,盲拼会出 `##`)。有意方言仅存 mapUtils `DEP_STATUS_LABEL`(依赖图大白话,对齐另议)。**#472 颜色语义**:pending「待确认」= 中性色(与 in_review「待验收」warning 分开,语义相反不同色);「待人工」= warning(error 红只留 blocked/failed 真错误,WorkUnitListPage StatChip 与 MonitoringPage 证据台账同改)。**#472 项目级词表**:`components/pmo/projectDisplay.ts` 是项目状态词(讨论中/开发中/验收中/已交付/已取消,delivered 归并已交付)+ 项目状态配色 + 交付策略文案(自动合并/分支交付)唯一出口——ProjectCard 状态 chip/策略标注、ProjectDetailPage MetaStrip、DeliveryPanel 台账、PmoNumberBadge tooltip/配色四处同源,禁止再写三元或裸输出英文值;DeliveryPanel 项目级徽标「待验收」已改「待交付」(与 WU 四站「待验收」分词)。**#472 统计 chip 口径**:WorkUnitListPage 「总数」chip 走全量徽标 `allTotal`(store 维护:无过滤 loadWorkUnits 顺带同步 / 过滤态 `loadAllCount` limit=1 补齐 / SSE created 全局 +1;null 回退过滤态 total——旧口径直接显示过滤态 total,切 tab 后「总数」变脸为过滤计数,2026-09-10 修复),其余 chip 计当前已加载子集,分页未全量/筛选/待人工生效时 chip 行内联标注「计数口径:当前 n/N 条」(措辞避开「已加载」——底栏分页文案有唯一断言)。**2026-09-10 截字修复**:行标题长 scope 2 行折行(`.wu-scope` clamp)+ title 悬停全文;抽屉主体标题 `.mc-drawer-subject-title` 允许折行、抽屉顶栏 uuid 加 title 悬停。**批次 D-3 项2 术语外露审计**(docs/plans/2026-09-ui-interaction-polish.md,延续 #473「机制词不上 UI」):频道右栏四站 stepper 站标签白话化(`activityRail.ts`:REQ→需求、WU n/m→任务 n/m);WorkUnitDrawer 行标「所属需求」、WU 详情页 FactRow「需求」+ 全链路按钮 tooltip、RequirementChainPanel 弹窗标题「需求全链路 · id」、右栏空态「暂无需求与动态」同步改;只登记不改:ProjectDetailPage「REQ 别名」(#399 定稿范围不动)、交付 409 `missing` 后端原文直渲(后端契约)、WU/REQ-xxxx 编号本身与技术性 tooltip(如列表页 chip「REQ 需求编号」)属豁免。 -- **频道工作区**(#394 三栏定稿,spec §4.1–4.3)= 左 ChannelRail(200px,频道名 500 字重/未读加粗)/ 中对话流(Linear 式弹性:max-width 无上限、左对齐、padding 32px,760/1000 双档居中已删)/ 右 ChannelActivityRail(300px「频道动态」REQ 链路卡)+ 覆盖式右抽屉 WorkUnitDrawer。原中栏 REQ chips 条已移除(REQ 呈现挪右栏链路卡)。消息分侧:人类右气泡、agent 左文档流、系统播报左对齐随文档流(#437,居中装置随 #394 取消限宽而废,区隔 = 淡色小字 + 无头)。**窄屏降级**(#395,spec §4.6):断点统一 Tailwind 档 lg/md/sm(mission-control.css 900/1100 自定断点已删);收起顺序 = 右栏先收(<1024 内联右栏卸载 → 顶栏「频道动态」入口开覆盖滑出抽屉,点 REQ/WU 收覆盖层再开详情抽屉;E1 起该入口收进顶栏 ⋯ 菜单 ChannelTopbarMenu,外层 `.mc-act-open` 显隐规则不变)→ 左栏再收(<768 内联 ChannelRail 卸载,并入 SidebarNew 渲染,640–767 随静态 sidebar、<640 随汉堡 overlay);REQ/WU 抽屉 768–1023 转 fixed 覆盖式、<768 全屏化(左上「← 返回」)。挂/不挂走 JS 断点 `hooks/useMediaQuery`(matchMedia 缺失回落宽屏默认,jsdom 既有测试零改动),CSS 只管入口显隐与覆盖形态;字号全端同值不豁免,44px 触控目标与 iOS 输入 16px 沿用 responsive.css 既有规则。**#440 引导面**:topbar 下挂频道工作条(ChannelWorkBar,2026-09 合并阶段条 + live 实况条,阶段口径与 WU 详情页同 deriveDisplayState)、输入框上方挂引导片(SuggestionChips);两者共用 channelWus 数据面(REST 打底 + status_changed upsert + 重连对齐,与 waitingWus/channelReqs 同模式)。**#443 起引导片双源**:端点派生片(`channelApi.getSuggestions`,挂载/重连/workunit.status_changed SSE 后重拉,不新增事件类型)+ #440 静态 prompt 片(#447 删);端点片经 `renderSuggestionCopy` 模板渲染,status 形态只读不可点。 +- **频道工作区**(#394 三栏定稿,spec §4.1–4.3)= 左 ChannelRail(200px,频道名 500 字重/未读加粗)/ 中对话流(Linear 式弹性:max-width 无上限、左对齐、padding 32px,760/1000 双档居中已删)/ 右 ChannelActivityRail(300px「频道动态」REQ 链路卡)+ 覆盖式右抽屉 WorkUnitDrawer。原中栏 REQ chips 条已移除(REQ 呈现挪右栏链路卡)。消息分侧:人类右气泡、agent 左文档流、系统播报左对齐随文档流(#437,居中装置随 #394 取消限宽而废,区隔 = 淡色小字 + 无头)。**窄屏降级**(#395,spec §4.6):断点统一 Tailwind 档 lg/md/sm(mission-control.css 900/1100 自定断点已删);收起顺序 = 右栏先收(<1024 内联右栏卸载 → 顶栏「频道动态」入口开覆盖滑出抽屉,点 REQ/WU 收覆盖层再开详情抽屉;E1 起该入口收进顶栏 ⋯ 菜单 ChannelTopbarMenu,外层 `.mc-act-open` 显隐规则不变)→ 左栏再收(<768 内联 ChannelRail 卸载,并入 SidebarNew 渲染,640–767 随静态 sidebar、<640 随汉堡 overlay);REQ/WU 抽屉 768–1023 转 fixed 覆盖式、<768 全屏化(左上「← 返回」)。挂/不挂走 JS 断点 `hooks/useMediaQuery`(matchMedia 缺失回落宽屏默认,jsdom 既有测试零改动),CSS 只管入口显隐与覆盖形态;字号全端同值不豁免,44px 触控目标与 iOS 输入 16px 沿用 responsive.css 既有规则。**#440 引导面**:topbar 下挂频道工作条(ChannelWorkBar,2026-09 合并阶段条 + live 实况条,阶段口径与 WU 详情页同 deriveDisplayState)、输入框上方挂引导片(SuggestionChips);两者共用 channelWus 数据面(REST 打底 + status_changed upsert + 重连对齐,与 waitingWus/channelReqs 同模式)。**#443 起引导片双源**:端点派生片(`channelApi.getSuggestions`,挂载/重连即时重拉;#489 起 SSE 触发面 = workunit.status_changed + channel.message_sent(本频道)+ requirement.created/updated,共享 500ms trailing 防抖合并连发事件,不新增事件类型)+ #440 静态 prompt 片(#447 删);端点片经 `renderSuggestionCopy` 模板渲染,status 形态只读不可点。 - **消息流管线**(#322):ChannelDetailPage 只剩编排(取数 hooks 组合 + 布局)。渲染段经 `useMemo` 消费 `deriveStreamView(messages, uiState)`(`utils/streamView.ts`)——归组(`groupIntoThreads`)/过程消息折叠(`collapseProcessReplies`)/连续合并(`shouldOmitHead`)/日期分隔/completed·active·visible 可见性全部算好;折叠 UI 状态(showCompleted/collapsedThreads/expandedProcGroups)由 `usePersistentStreamUI` 按频道持久化(localStorage)后作输入。滚动状态机在 `hooks/useStreamFollow.ts`,卡片 action 路由在 `hooks/useChannelCardActions.ts`(dispatch 单一入口,messages 经镜像 ref 保持 identity 稳定)。`ChannelMessageItem` 已 `React.memo`,父组件稳定 props 契约(`useCallback`/镜像 ref,onToggleThread 收 anchorId)——render-count 测试断言 step 事件下既有消息项零重渲。**live 执行状态下沉**:`useChannelLiveExecutions` 由 `ChannelWorkBar` 组件自持有(原调用点在页面),`workunit.execution.step` 只重渲该组件边界。**2026-09 批次1**(docs/plans/2026-09-channel-visual-polish.md):消息 footer WU chip 旁 ↗ 直跳钮已删(chip 自承载开抽屉,同目的地不双入口,勿复加);agent 文档流播报卡节标题提层级走 `.mc-md` 作用域(mc-md 仅频道 agent 消息使用,MarkdownBody 通用类与解析逻辑不动);ChannelInput 计数器无输入不渲染、辅助行提亮一档(--text-secondary)。**2026-09 批次2**:「回到底部」浮钮改贴右下角(`.mc-jump-wrap` flex-end + 缩小半透明,显隐逻辑不动);空频道态加示例提示 chip(`.mc-empty-chip` 虚线低调,文案常量 `EMPTY_EXAMPLE_PROMPTS`,点击走既有 `prefill={text,nonce}` 通道不自动发送——空态仅 `mc-stream` 头块一处渲染点,虚拟化/jsdom 全量共用);工作条内 running 徽标由 warning 黄改 accent 绿(初落地为 `.mc-workbar` 作用域覆盖;2026-09 批次C 起 `.mc-status-running` 全局统一 accent、作用域覆盖已删,warning 黄只留「待处理/需介入」语义)。**E1 页面重设计**(docs/plans/2026-09-page-redesign.md):顶栏收敛——5 动作平铺 → 「待回复 · N」chip(唯一待办信号位)+ ⋯ 菜单(ChannelTopbarMenu 收成员/默认工程/频道动态入口/当前 PMO);消息头「等待回复」badge(`.mc-wait-badge`)已删——「待回复」表达 = 顶栏 chip + 右栏 pinned 动态 + 消息内嵌回复区(行动点本体即流内信号),「已回复 ✓」保留;主行动点 = 输入框「发送」唯一 accent 实心不变。**批次 D-1.5 频道页视觉试点**(docs/plans/2026-09-ui-interaction-polish.md,纯视觉层):① 系统播报行首 `[CRITICAL]`/`[WARNING]` 裸文本前缀 chip 化——`utils/severityPrefix.ts parseSeverityPrefix` 纯函数解析(仅 critical/warning 出 chip,[INFO]/无前缀原样透传),ChannelMessageItem 系统播报分支渲染 `.mc-sev-chip` + `--error`/`--warning` 变体(dim 底+原色字+border 边,配方同 mc-wu-link 变体),前缀自展示文案剥除、消息数据不动;② agent 文档流 2px `--border-default` 左边线 + 12px 内距(`.mc-msg-agent`,人类气泡/系统播报不加);③ 输入框 focus = accent 描边 + `box-shadow: var(--shadow-glow)`(`.mc-input-row textarea:focus`;--motion-fast 未落地故无 transition,静态 focus 态)。 - **AgentDashboard 渲染边界**(#348,对齐 #322 三件套):执行动态(workunit.execution.step/stream chunk)落 `rosterActivityStore`,`RoleCard`(memo)经 `useRosterActivities(roleId)` 只订自己切片——chunk 只重渲对应卡、页面整树不动(render-count 测试断言静态卡零重渲);页面不持有 activities state,stats useMemo;动态文案 formatter 唯一出处 `api/workunit.ts formatExecutionStreamChunkText`。#414 起 roles 派生按 roleId 保 `{profile, runtime}` 包装对象身份(`rolesRef` 在 memo 内同步更新兼作身份锚点;profile 与 runtime 引用均未变即复用上次包装)——status_changed 数组级替换同样掀不动未变卡(render-count 测试两面对齐:stream chunk + agent.instance.status_changed;测试 mock onEvent 须收集**所有**订阅 handler 广播,store sync 与 activity 订阅是两路)。#397 起卡面状态口径统一走 `utils/agentStatus.ts`(`resolveCardStatusKey`/`AGENT_STATUS_RANK`/`matchesStatusFilter`:pill、页头筛选 chip、注意力排序三处同源);#433 起 Agent 详情页状态 pill 同源消费(`CARD_STATUS_LABELS`/`CARD_STATUS_COLORS`,disabled 归灰);**2026-09 UI 丝滑化 Step 1** 起 pill/页头 chip(7→5)/详情页 pill 改消费 4 态展示层(`resolveDisplayStatus`/`DISPLAY_STATUS_LABELS`,见该模块表行),细分态保留作详情页 pill 细分小字与 RoleCard error 红点角标,布局双栏在 `styles/agent-detail.css`(≥1280 2fr|1fr,内容最大宽 1400 取代该页 max-w-5xl,属 #433 获批方案的页面级偏离非 §4.7 新惯例);**rosterStore 是模块单例——页面测试须按用例重置(loadedAt/forbidden),否则 TTL/403 终态跨用例短路首拉**(AgentDashboardPage.test.tsx beforeEach 有样板)。**批次 D-2 项7**:RoleCard 新增 `onOpenWu` prop(WU 锚点/最近完成/动态行 → 开就地抽屉),页面侧 `useCallback` 空依赖保稳定引用(memo 契约同 #348 三件套);抽屉 = AgentDashboardPage 自挂懒加载 WorkUnitDrawer + `.ac-drawer-host` 全断点 fixed 覆盖宿主(NotificationBell D-2 项2 先例复用)。**批次 D-3 项1 空态引导模式**(「空态 = 说明 + 一个明确主行动」,频道空态 `.mc-empty-chip` 先例的模式对齐非组件照搬):本页零角色空态 = `.empty-state` 说明 + 「创建角色」主按钮(就地开 CreateRoleModal,与页头按钮同入口);PMOPage 零项目空态同模式(说明 + 「新建 PMO」主按钮开 CreateProjectDialog,上方虚线块入口保留)。 - **频道流滚动**(#289/#290;#322 起实现在 `hooks/useStreamFollow.ts`):程序写 scrollTop 必记 observed-top 台账(useStreamFollow `scrollStreamTo`),钉底/归属/跟随/行锚点判定走 `utils/streamFollow.ts` 纯函数(钉底阈值 `FOLLOW_THRESHOLD_PX=24`);加载更早 = 行锚点补偿(`data-message-id` 首个可见行位移校正,非高度差);阅读位置按频道持久化 localStorage(`utils/readingPosition.ts`,切频道/卸载存档,钉底存 null;存档 cleanup 必须挂 layout effect——#340:unmount 时 React 先跑组件自身 layout destroy、后遍历子树 detach ref/DOM 移除,passive cleanup 时 `streamRef` 已 detach,`captureAnchor` 量不到锚点);ResizeObserver 跟随撑高,离底浮「回到底部」。 @@ -130,8 +131,10 @@ Web 前端主源码。路由、全局状态、API 客户端、UI 组件、样式 - **AuditLogsPage E7 页面重设计**(2026-09,docs/plans/2026-09-page-redesign.md):主行动点「导出」自筛选行挪页头右侧;补时间范围筛选(开始/结束日期 input,本地日界转 ISO `startTime`/`endTime`,后端 list/stats/export 原生支持,纯前端接入);筛选变化统一走 `applyFilter` 重置 page=1(修复翻页后改筛选停留旧页的错位 bug);userId 输入 300ms 防抖(`userIdInput` 原值 + 防抖进 filters,模式同 LibraryPage);导出 URL 带 status/时间范围与列表口径一致(后端 export 路由已同步修复:补 action/resource/status 透传,且 /export 注册移到 /:id 之前解除遮蔽);视觉收敛 = 内容区 max-w-5xl、统计卡归一 StatCard 配方(`.card` + --fs-stat mono 数字)、统计/筛选/表格三区 `mc-block-label`、页标题去 📋、分页「上一页/下一页」(批次 B-5)。 - **设置域 E8 页面重设计**(2026-09,docs/plans/2026-09-page-redesign.md):Settings 页标题/三节标题去 emoji,ThemeSettings 主题卡圆角归 4px 卡片档(`rounded-xl`→`rounded`);「默认执行机器」保存成功加轻反馈(Select 旁「✓ 已保存」2s 定时淡出,失败仍 toast)+ 机器名直链 `/workspaces/:id`(WorkspacePage 唯一站内入口,仅 Admin 拿到 workspaces 清单时呈现,非 Admin 降级视图不出);WorkspacePage 骨架归 §4.7(`u-page-bg`/`u-page-head`/`page-title`/`max-w-5xl`)、runtime 行卡自造 `rounded-lg` 归 `.card`、「设为角色」自造按钮归 `btn btn-primary btn-sm`、删硬编码「0 个角色」假数据(无按 runtime 的角色计数现成接口,按方案兜底直接删)。**E8-3 RolesSetup 僵尸路由清理**:`/setup/roles` 路由与 `pages/RolesSetup.tsx`(+测试)已删——主路径 = CreateRoleModal(#397 §6.4),onboarding 的 FirstRoleSetupModal 是 App 根弹框不依赖该路由,ReviewHint `onSetupClick`(E2 起在 WorkUnitDrawer 闸门区上方)链 `/agents`;截图采集配置 `scripts/visual/config.ts` 同步移除 setup-roles 条目。**E8-4 创建角色表单合一(已做)**:WorkspacePage 内嵌创建角色 dialog 已删,复用 CreateRoleModal 正本——新增可选 `presetProvider` prop(additive,AgentDashboardPage 消费路径不变),传 preset 时跳过 runtime 清单拉取、单项固定预选、provider 只读;WorkspacePage 原「创建成功留框内联提示」差异行为归一为 §6.4 正本行为(关弹框 + onCreated),字段口径不变(name/description?/provider → channelApi.createAgent)。 - **频道翻页游标(#319,2026-08-24)**:`useChannelMessages.loadMore` 的 `before` = 最老消息 **id**(原 createdAt 时间戳,同毫秒多条会漏/重);后端锚点 id 不存在时返回空页 + hasMore=false,loadMore 据此自然停止 +- **频道发送乐观回显(#486,2026-09-11)**:`useChannelMessages.sendMessage` 先插本地 pending 消息(id 前缀 `pending-`,`ChannelMessage.pending` 为仅客户端字段,服务端不下发),成功后服务端本体原位替换(SSE 回声先到按 id 去重同样收敛不重复),失败回滚 pending + 上抛(ChannelInput 回灌草稿 + toast 的批次A路径不变);pending 不作 loadMore 分页游标(本地 id 服务端不存在,作锚点会翻空页)、ChannelMessageItem 不出回复/转任务动作,渲 `.mc-msg-pending` 降不透明度 + 气泡内「发送中…」标记;ChannelInput 发送中 textarea 不再禁用(可接着打下一条),sending 只保留发送钮/handleSend 防重复提交 +- **线程回复「已送达/等待 agent」即时反馈(#493,2026-09-11)**:`ChannelDetailPage.handleSend` 线程回复送达且 `sent.workUnitId` 非空(= workUnitId 继承成功、会触达 agent;顶层消息与归档降级回复不出)→ composer 上方出 `.mc-agent-ack` 状态条「已送达,等待 agent 响应…」(state `awaitingAgent` = { wuId, since });该 WU 的 agent 新消息到达(SSE 进 messages 即判)或 30s 兜底超时自动消失 - **DeliveryPanel branch-only 标记已交付(#469,2026-09-10)**:branch-only 且未交付时,台账区除「证据已齐请合并分支」提示(deliverable 时)外,常驻 commit 哈希输入 + 「标记已交付」按钮(`projectApi.markDelivered`,空 commit 禁用;409 走 toast error.message;成功清输入 + onRefresh)——系统外合并后人工落档,台账不再永停「✓ 可交付」。 -- **行动中心**(#468 统一行动中心,自通知中心 B2-003/B2-004 重塑;#415 重连对齐保留):「需要我做什么」两类事实一个端点 `GET /action-center`——状态派生 stateItems(reply/review/confirm,无已读概念、状态变即消)+ 事件持久 notifications(已读/未读墓碑)+ unreadCount,三段住 `stores/notificationStore`(读态跨组件共享前提——NotificationBell 只是视图)。**不发明第五套信号**:ChannelNeedInputChip(ChannelDetailPage waitingWus = stateItems 本频道 reply 项投影,本地 blocked REST/SSE 维护已删;reply 不再排除闸门类,排除改面板分区)、TriageBanner(未读 critical incident 通知投影——设计稿 §3 仅 critical 突破成横幅,warning 级留铃铛面板;severity 从 content 首行 `severity:` 解析,「关闭」= 逐条 markRead 持久化)、MoreDropdown 徽标(= unreadCount,原 monitoringApi 24h 计数已删)全部为同一数据源投影。**SSE 只作失效触发**:NotificationBell 上 channel.message_sent atHuman / workunit.status_changed → `load()` 重拉,SSE 不再直接入列。**打开频道即读**:ChannelDetailPage 进页 `markChannelRead(id)`,只清 channelId 匹配的未读(逐条 POST + unreadCount 乐观递减),其他频道不动。**点击直达消息**:频道分支跳转带 `?highlight=`,页面消费一次后交既有 highlightId 机制滚动定位+高亮 2s(目标在折叠线程先展开);后端通知 link 带消息粒度(#439),messageId 经 link 的 `?highlight=` 解析;目标掉出首页分页时沿 #319 翻页游标向前翻页定位(上限 10 页),翻到底/超限 → toast 可见反馈不静默;定位跳转先经 `unpinFromBottom` 解钉(#439 真机走查实测),highlight 目标是降级骨架行时骨架同样带 mc-msg-highlight。通知跳转优先级 #468 起为 WU > 频道(?highlight=) > PMO。**标题闪烁定时器**与行动中心挂钩:startFlash 开新必清旧(曾闭包单变量被覆盖导致 interval 永久泄漏闪烁),停止条件 = unreadCount + stateItems.length 归零/卸载(曾纯 10s 定时、全部已读后照闪)。**重连对齐**(#415,ADR D3):NotificationBell 注册 `onReconnect → load()`。**批次 D-2 处理待办流程断链修复**(docs/plans/2026-09-ui-interaction-polish.md 第一轮 1/2/3 项):① reply 深链——后端 stateItems 补 additive `messageId`(= 该 WU 频道线程最新非人类消息,与 chip「当前提问消息」#279 同口径),reply 点击跳频道带 `?highlight=`(messageId 缺失 fail-closed 不拼参数、无 channelId 仍落 WU 详情);② review/confirm 就地化——点击不再整页跳 `/workunits/:id`,改开 NotificationBell 自挂的 WorkUnitDrawer 实例(lazy 加载 + `.ac-drawer-host` 全断点 fixed 覆盖宿主,与频道页/列表页 drawer 状态机完全隔离;↗ 详情页深链入口随本次补进抽屉 subject 行);③ 下一个待办——闸门动作成功(当前 WU 掉出 stateItems 池 = 状态变即消语义反用)后抽屉内出「下一个 →」同池(review+confirm)导航/「待办都处理完了」收口;reply 线在频道页处理,由 NotificationBell 追踪点击过的 wuId、掉出池时 toast(toast 新增可选 `action` 行动按钮)给「下一个 →」跳下一条频道 | +- **行动中心**(#468 统一行动中心,自通知中心 B2-003/B2-004 重塑;#415 重连对齐保留):「需要我做什么」两类事实一个端点 `GET /action-center`——状态派生 stateItems(reply/review/confirm,无已读概念、状态变即消)+ 事件持久 notifications(已读/未读墓碑)+ unreadCount,三段住 `stores/notificationStore`(读态跨组件共享前提——NotificationBell 只是视图)。**不发明第五套信号**:ChannelNeedInputChip(ChannelDetailPage waitingWus = stateItems 本频道 reply 项投影,本地 blocked REST/SSE 维护已删;reply 不再排除闸门类,排除改面板分区;点条目定位 = 该 WU 当前提问消息高亮,#483 起提问掉出已加载分页时复用 #439 翻页定位循环——上限 10 页,翻到底/超限 → toast 兜底不静默)、TriageBanner(未读 critical incident 通知投影——设计稿 §3 仅 critical 突破成横幅,warning 级留铃铛面板;severity 从 content 首行 `severity:` 解析,「关闭」= 逐条 markRead 持久化)、MoreDropdown 徽标(= unreadCount,原 monitoringApi 24h 计数已删)全部为同一数据源投影。**SSE 只作失效触发**:NotificationBell 上 channel.message_sent atHuman / workunit.status_changed → `load()` 重拉,SSE 不再直接入列。**打开频道即读**:ChannelDetailPage 进页 `markChannelRead(id)`,只清 channelId 匹配的未读(逐条 POST + unreadCount 乐观递减),其他频道不动。**点击直达消息**:频道分支跳转带 `?highlight=`,页面消费一次后交既有 highlightId 机制滚动定位+高亮 2s(目标在折叠线程先展开);后端通知 link 带消息粒度(#439),messageId 经 link 的 `?highlight=` 解析;目标掉出首页分页时沿 #319 翻页游标向前翻页定位(上限 10 页),翻到底/超限 → toast 可见反馈不静默;定位跳转先经 `unpinFromBottom` 解钉(#439 真机走查实测),highlight 目标是降级骨架行时骨架同样带 mc-msg-highlight。通知跳转优先级 #468 起为 WU > 频道(?highlight=) > PMO。**标题闪烁定时器**与行动中心挂钩:startFlash 开新必清旧(曾闭包单变量被覆盖导致 interval 永久泄漏闪烁),停止条件 = unreadCount + stateItems.length 归零/卸载(曾纯 10s 定时、全部已读后照闪)。**重连对齐**(#415,ADR D3):NotificationBell 注册 `onReconnect → load()`。**批次 D-2 处理待办流程断链修复**(docs/plans/2026-09-ui-interaction-polish.md 第一轮 1/2/3 项):① reply 深链——后端 stateItems 补 additive `messageId`(= 该 WU 频道线程最新非人类消息,与 chip「当前提问消息」#279 同口径),reply 点击跳频道带 `?highlight=`(messageId 缺失 fail-closed 不拼参数、无 channelId 仍落 WU 详情);② review/confirm 就地化——点击不再整页跳 `/workunits/:id`,改开 NotificationBell 自挂的 WorkUnitDrawer 实例(lazy 加载 + `.ac-drawer-host` 全断点 fixed 覆盖宿主,与频道页/列表页 drawer 状态机完全隔离;↗ 详情页深链入口随本次补进抽屉 subject 行);③ 下一个待办——闸门动作成功(当前 WU 掉出 stateItems 池 = 状态变即消语义反用)后抽屉内出「下一个 →」同池(review+confirm)导航/「待办都处理完了」收口;reply 线在频道页处理,由 NotificationBell 追踪点击过的 wuId、掉出池时 toast(toast 新增可选 `action` 行动按钮)给「下一个 →」跳下一条频道 | - **`.mc-ws button` 全局重置已 `:where()` 降权**(#431,2026-09-01;原 #375 待清理项了断):mission-control.css 头部重置从 `.mc-ws button`(0-1-1)改 `:where(.mc-ws) button`(0-0-1)——原先被吞的 20 条单类按钮规则(`.mc-btn-primary` 背景、`.mc-icon-btn`/`.mc-drawer-close` 等的 color/padding,清单 = `.studio/research/2026-08-31-style-hygiene.md` §2)自此**首次生效**;`cursor: pointer` 仍由重置承担(TW v4 preflight 不含 button cursor)。mc-* 壳内按钮的单类样式不再需要双类复合绕行(既有绕行保留未清)。 - **共享结构类**(#431):`u-page-bg`(页面骨架底)/ `u-page-head`(页头条 padding+hairline)/ `u-btn-reset`(裸按钮重置,不含 font/color——preflight 已继承,避免压类上排类)住 mission-control.css 尾部块;`u-surface-0` = `--bg-secondary`(surface 档位:-0 secondary < 默认 elevated < -2 tertiary),登记于 style-guide §4.5/§4.7。**E5 增 `u-tab`/`u-tab-active`**:tab 形态正本(批次 D-4 定 Knowledge border-b 形态)激活态 accent 底线收进类,禁止再写内联 borderBottom。 - **测试 setup 禁用 `@testing-library/jest-dom/vitest` 入口**(2026-09-02,commit 0f1175c1):jest-dom 未声明 vitest peer,该入口被外部化后经 Node 解析向上命中根 node_modules 的另一 peer 变体 vitest 副本(本仓 pnpm monorepo 4 个变体共存),`@vitest/snapshot` 单例分裂 → 一切 `toMatchSnapshot` 报 "snapshot state not found"(vitest#7430/#7668;`deps.inline`/`server.deps.inline` 补救无效)。`src/test/setup.ts` 用官方等价写法 `expect.extend(jestDomMatchers)`(matchers 来自 `@testing-library/jest-dom/matchers`),matcher 集合同等。新增 setup 导入时同理警惕:任何经 node_modules 外部化传递 `import 'vitest'` 的入口都可能触发变体分裂。 diff --git a/apps/web/src/api/channel.ts b/apps/web/src/api/channel.ts index f91fd6ab..f1cb5ac3 100644 --- a/apps/web/src/api/channel.ts +++ b/apps/web/src/api/channel.ts @@ -13,6 +13,8 @@ export interface ChannelMessage { meta?: string | Record; /** #326:骨架标记——数据层降级产物,content/meta 大头已剥离,结构字段仍在(ADR 2026-08-25) */ degraded?: boolean; + /** #486:乐观回显本地标记(仅客户端 pending 态,服务端不下发;成功被本体替换、失败回滚) */ + pending?: boolean; createdAt: string; } @@ -96,6 +98,12 @@ export interface ChannelSuggestion { export interface ChannelSuggestions { currentWuId: string | null; suggestions: ChannelSuggestion[]; + /** + * #490:fail-closed 可观测标志——推导内部读取失败被吞时 true(此时 suggestions 必为空); + * 正常路径(含「确实无建议」)false。前端仅 console 记录 + 不落「已返回」台账 + * (ChannelWorkBar 占位保持加载态而非误显空闲),不出任何 UI。 + */ + degraded?: boolean; } export const channelApi = { diff --git a/apps/web/src/components/channel/ChannelInput.tsx b/apps/web/src/components/channel/ChannelInput.tsx index 26fd6697..a4a23136 100644 --- a/apps/web/src/components/channel/ChannelInput.tsx +++ b/apps/web/src/components/channel/ChannelInput.tsx @@ -3,6 +3,9 @@ // #281(决策 #249 §5 / #248 D9):@弹框统一分组——上 Agents 下 Files;文件候选走 // 频道词表(git ls-files)路径后缀精确匹配补全,选中插入纯路径文本(mention 正则不动), // 发送时携带结构化 files=[{repo, path}](仅保留正文仍含其路径的引用,防陈旧)。 +// #485:fileRefs 台账可视化——chip 可单独移除;正文被编辑得不含路径时 chip 标灰「已失效」,不静默丢弃。 +// #486:发送中 textarea 不再整段禁用(可接着打下一条);乐观回显在 useChannelMessages,本组件 +// 只保留发送钮/handleSend 的 sending 防重复提交。 import { useState, useRef, useCallback, useMemo, useEffect } from 'react'; import type { AgentProfile, ChannelMessage, FileRef } from '../../api/channel'; import { channelApi } from '../../api/channel'; @@ -249,8 +252,14 @@ export function ChannelInput({ onSend, sending, replyTo, onCancelReply, channelI void uploadImage(img); }; + // #485:chip 单独移除引用(不动正文路径文本——移除台账后发送过滤自然不携带) + const removeFileRef = useCallback((ref: FileRef) => { + setFileRefs(prev => prev.filter(f => !(f.repo === ref.repo && f.path === ref.path))); + }, []); + // 批次A 项1:await 真实发送结果——失败回灌文本/文件引用 + toast 提示 - // (参照 ChannelMessageItem 内嵌回复「失败保留 draft」模式,发送中输入框经 sending 禁用) + // (参照 ChannelMessageItem 内嵌回复「失败保留 draft」模式;#486 起发送中 textarea 不再禁用, + // 乐观回显由 useChannelMessages 承担,本组件只保留 sending 防重复提交守卫) const handleSend = async () => { const trimmed = content.trim(); if (!trimmed || sending) return; @@ -335,6 +344,34 @@ export function ChannelInput({ onSend, sending, replyTo, onCancelReply, channelI )} + {/* #485:已挂文件引用 chip——台账可视化;正文被编辑得不再含路径时标灰「已失效」(不再静默丢弃) */} + {fileRefs.length > 0 && ( +
+ {fileRefs.map(ref => { + const invalid = !content.includes(ref.path); + return ( + + {ref.path} + {repoBasename(ref.repo)} + {invalid && 已失效} + + + ); + })} +
+ )} +