From 54d07dc1e6f47cdfbadfef31b603be4e64de5c84 Mon Sep 17 00:00:00 2001 From: dommaker Date: Tue, 15 Sep 2026 20:56:43 +0800 Subject: [PATCH 001/116] =?UTF-8?q?feat(agents):=20=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E5=B7=A5=E7=A8=8B=E5=8C=96=E2=80=94=E2=80=94=E4=BA=A7?= =?UTF-8?q?=E5=87=BA=E5=AE=9E=E5=8F=8C=E9=97=B8=20+=20=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=E5=BC=95=E7=94=A8=E6=89=93=E6=B3=95=20+=20=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E5=86=B2=E7=AA=81=20LLM=20=E8=A7=A3=20+=20=E8=AE=A4=E9=A2=86?= =?UTF-8?q?=E5=89=8D=E9=80=82=E4=BB=BB=E5=88=A4=E6=96=AD=EF=BC=88=E5=86=B3?= =?UTF-8?q?=E7=AD=96=2014=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 骨架固定、环节内自由(闸设在环节交接点)+ 分层评价(代码闸底线 / agent 评审质量线): - completion-gates:COMPLETE 加「产出实」双闸——代码类 diff 非空(base..HEAD 提交计数)、 非代码类契约产物存在(review/plan/spec/decision 验 metadata 协议行,analysis 验 .studio/research/ 落盘文件);缺失降级 progress + hint,3 次 blocked 转人工 - prompt-composer:五类契约段尾加「本环节标准打法」指引(implement→tdd-implement、 bug→diagnosing-bugs、plan→requirement-clarify+to-tickets、analysis→research、 review→code-review);skill 分发机制不动 - merge-on-review-pass:merge 冲突终态现场不再 abort,交 LLM 解(system-executor 一次性会话 + 新内建 skill resolving-merge-conflicts),冲突标记清零才认解完; 合并后落 mergedAt 前在合并目标目录重跑 verify,红则 merge-verify-failed 转人工; 失败回退原有 abort + markMergeConflict 兜底 - claim-fitness(决策 14):认领前适任判断——resolveTarget 选中 unassigned 后、 claim 前一次性 LLM 判断;不适任落 WU metadata.unfitRoles(挂 WU 不挂角色,合规 D2),observe 过滤第 7 道;全员不适任 → blockForAllUnfit 转人工;显式指名不判、 判断失败从宽 --- apps/api/src/modules/agents/CONTEXT.md | 4 +- .../agent-loop-decision-conclusion.test.ts | 32 ++ .../__tests__/agent-loop-review-wu.test.ts | 7 +- .../__tests__/agent-loop-wu-worktree.test.ts | 6 +- .../agents/__tests__/completion-gates.test.ts | 216 ++++++++- .../agents/__tests__/prompt-composer.test.ts | 14 + .../loop/__tests__/claim-fitness.test.ts | 409 ++++++++++++++++++ .../api/src/modules/agents/loop/agent-loop.ts | 130 +++++- .../src/modules/agents/loop/claim-fitness.ts | 128 ++++++ .../modules/agents/loop/completion-gates.ts | 140 +++++- .../modules/agents/loop/prompt-composer.ts | 32 +- .../api/src/modules/agents/system-executor.ts | 2 + apps/api/src/modules/workunit/CONTEXT.md | 6 +- .../__tests__/merge-on-review-pass.test.ts | 224 +++++++++- .../modules/workunit/merge-on-review-pass.ts | 270 ++++++++++-- .../src/modules/workunit/workunit.service.ts | 50 ++- .../src/modules/workunit/workunit.types.ts | 33 ++ ...6-08-25-review-independence-trust-model.md | 22 + .../skills/resolving-merge-conflicts/SKILL.md | 27 ++ 19 files changed, 1686 insertions(+), 66 deletions(-) create mode 100644 apps/api/src/modules/agents/__tests__/agent-loop-decision-conclusion.test.ts create mode 100644 apps/api/src/modules/agents/loop/__tests__/claim-fitness.test.ts create mode 100644 apps/api/src/modules/agents/loop/claim-fitness.ts create mode 100644 packages/studio-skill/skills/resolving-merge-conflicts/SKILL.md diff --git a/apps/api/src/modules/agents/CONTEXT.md b/apps/api/src/modules/agents/CONTEXT.md index ff38a1d63..5af8ca38f 100644 --- a/apps/api/src/modules/agents/CONTEXT.md +++ b/apps/api/src/modules/agents/CONTEXT.md @@ -20,7 +20,7 @@ Agent 配置(profile)、运行实例(instance)、决策循环(loop) - `ops/` - ops.service(preflight 启动检查/health 轮询自愈/代理守护/默认数据 ensure;#409 起 worktree GC 已归一到 monitor-system-probes.gcStaleWorktrees 单实现,Ops 版 cleanupWorktrees 及其 hourly 挂载删除)、createHealthRoutes(/healthz 健康端点)、proc-probes(#344:/proc 系统探测单出口——statfs 磁盘/meminfo 内存//proc/*/stat 僵尸//proc/loadavg,零子进程;#374 起 ops.getStatus、monitor systemHealthCheck、ops preflight 磁盘检查三处同源委托;#418 起 +readProcessCmdline/countProcessesByCmdline/listPidsByCmdline/listZombieProcesses 四导出,triage.service 与 knowledge/env-snapper 探测同源) - `auditor/` - auditor.service(门面,24h 日审跨任务审计)、auditor-rules(错误归类/技能建议/知识健康;#523 起 OKR 低达成建 okr_proposal 单收口 WorkUnitService.create——原 commitSnapshot 直写不发 eventBus 事件,对唤醒体系隐形)、auditor-execution(低风险自动应用/确认卡片经 review-proposal 正本发卡/Resolution/Triage 升级;#439 起铃铛通知 link 带消息粒度——发卡后按 proposalId 反查卡消息拼 `?highlight=`,link 构造唯一出口 `buildAuditorNotificationLink`,反查不到降级频道粒度不阻断)、auditor-reports(行为趋势/七日趋势/tier 反馈)、review-adapter(#356:auditor_suggestion 卡接线 review-proposal 正本,kind=auditor,onApprove 建未指派 task 工单——自旧 channels/card-decision.service 搬入;审批走通用端点 /review-proposals/auditor/:id/*;`findAuditorCardMessageId` = 按提案 id 反查卡消息唯一出口,onApprove 原卡链接与 #439 通知 link 共用) - `knowledge/` - knowledge-curator.service(门面,冷启动+每日维护)、knowledge-extraction(提取 prompt 单一来源)、knowledge-cold-start(四源导入 docs/code/git/manual)、knowledge-maintenance(语义去重/质量评估/过期验证/矛盾审查) -- `loop/` - agent-loop(循环编排,拆分后仅保留类逻辑+re-export;#363:原 start() 同角色 terminated 启动清理已拆除,回收归 instance-timeout-scan)、agent-loop.types(类型契约,纯类型零运行时)、agent-loop-parsers(输出解析+prompt 模板纯函数)、agent-loop-events(tokens/tool:call 落盘,provider 分流 usage;#320 起 workunit:tokens 落盘后顺带 `noteTokenLedgerWritten` 更新 token 账本 `utils/token-ledger`,失败隔离)、agent-loop-guards(测试 WU 守卫+excludeAssignee)、lease-heartbeat(WU 租约心跳 30s,fencing 校验)、context-overflow(溢出识别+滚动摘要)、executor(Executor 接口,LocalExecutor 委托 agentRunner)、execution-step-events(步级事件落盘+步内流式 SSE)、wu-verification(自动验证可复用实现)、completion-gates(收口守卫链:提交/子任务/验证/软观测)、review-contract(verdict 语义单一来源 pass/reject/needs-info) +- `loop/` - agent-loop(循环编排,拆分后仅保留类逻辑+re-export;#363:原 start() 同角色 terminated 启动清理已拆除,回收归 instance-timeout-scan)、agent-loop.types(类型契约,纯类型零运行时)、agent-loop-parsers(输出解析+prompt 模板纯函数)、agent-loop-events(tokens/tool:call 落盘,provider 分流 usage;#320 起 workunit:tokens 落盘后顺带 `noteTokenLedgerWritten` 更新 token 账本 `utils/token-ledger`,失败隔离)、agent-loop-guards(测试 WU 守卫+excludeAssignee)、lease-heartbeat(WU 租约心跳 30s,fencing 校验)、context-overflow(溢出识别+滚动摘要)、executor(Executor 接口,LocalExecutor 委托 agentRunner)、execution-step-events(步级事件落盘+步内流式 SSE)、wu-verification(自动验证可复用实现)、completion-gates(收口守卫链:提交/子任务/空diff/契约产物/验证/软观测)、review-contract(verdict 语义单一来源 pass/reject/needs-info) - 根目录 - default-triggers(10 个系统 trigger:inspection-scan/dispatch-reconciliation/doc-semantic-review/#523 workunit-gate-escalation 等)、agent-loop.ts(门面,决策循环编排+re-export)、agent-output-parser(ACTION 协议解析/审查结论/任务拆分/动态间隔)、prompt-composer(prompt 组装,分段软定额截断)、agent-loop-workspace(worktree 解析/归属链/PMO 分支)、session-resume(续用判定+上限 MAX_SESSIONS_PER_WU=5)、agent-targeting(Observations->Target 解析)、default-provider(provider 默认选取)、instance-timeout-scan(心跳过期 5min 扫描+pid 复核;#363:统一回收 terminated 实例——跨角色 deleteState 连带判空删目录,闭环实例目录生命周期)、dispatch-reconciliation(派工/评审断链 5min 对账;#523 起自愈补建/重派成功本频道出声「断链已自愈」,告警/失败仍只走 #62 告警管线)、workunit-token-events(tokens/tool:call 事件写入)、agent-loop-utils(进程存活/git 根/worktrees 工具)、wu-test-guards(测试特征 WU 判定)、agent-loop-instance-state(实例状态:启动失败/idle 心跳/忙闲 SSE)、agent-loop-record-result(recordResult:提交/子任务/验证+状态迁移+里程碑;步骤安全阀 stepSafetyLimit——review=30、其余=15、#471 起 plan=null 不走强制收口)、agent-loop-step-guards(前置守卫:测试 WU 关闭/token 预算熔断;#471 起 plan 步数额度守卫在 agentStep 内 PLAN_STEP_LIMIT=60,到线 need_input 挂 blocked 转人,waiting-input 回复续期) ### 依赖关系 @@ -58,7 +58,7 @@ Agent 配置(profile)、运行实例(instance)、决策循环(loop) - **SystemExecutor 按源超时(#369)**:`run()` 超时解析 = 显式 `timeoutMs` > `DEFAULT_TIMEOUT_BY_EVENT_SOURCE[eventSource]` > 30s 全局默认;注册表收编重 prompt 源 knowledge-distill / constraint-audit / knowledge-maintenance =120s(依据 #365 实测蒸馏 21-27s 撞 30s)。新调用点必须带 eventSource(同时是 system:tokens 成本聚合键);轻源不进表走裸 30s,durationMs 遥测贴上限再评估 - **system:tokens 写通路可观测(#370)**:写入成功打 info 日志(eventSource/provider/durationMs),失败 warn 带 eventSource——"零产出"与"静默失败"可区分;#370 实证写链路本身无 bug(冒烟脚本 `scripts/system-tokens-smoke.ts` 直接 `getSystemExecutor().run()` 验证落盘),零产出根因是全部 13 个调用方事件驱动、环境未触发 - **频道发声**:里程碑+异常+每步简报+认领消息+步失败消息;认领/失败消息不过新鲜度检查 -- **认领门槛**:纯显式,三门槛:assigneeId 排他+excludeAssignee+blockedBy 依赖门禁 +- **认领门槛**:纯显式,三门槛:assigneeId 排他+excludeAssignee+blockedBy 依赖门禁;决策 14 起加认领前适任判断(loop/claim-fitness.ts)——resolveTarget 选中 unassigned 候选后、claim 前一次性轻量 LLM(复用 system-executor)问「能否胜任」,判 no 落档 WU `metadata.unfitRoles`(约束挂 WU 不挂角色身份,合规 ADR D2/决策 10),observe 第 7 道过滤(含本 role.id 即不可见);判断失败/解析不出从宽视为适任;unfitRoles 覆盖频道全部 active 成员 → `WorkUnitService.blockForAllUnfit` 置 blocked 转人工(语义方法直写,不动状态机表) - **失败步埋点**:recordOutcomeEvent 落 knowledge:outcome:failure/success - **Auditor 零执行早退**:24h 零执行不 push 不记录不升级 - **WU 收尾提取**:订阅 done -> 读 transcript -> LLM -> 角色记忆草稿区;与 R3 并行独立 diff --git a/apps/api/src/modules/agents/__tests__/agent-loop-decision-conclusion.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop-decision-conclusion.test.ts new file mode 100644 index 000000000..fae6cd94c --- /dev/null +++ b/apps/api/src/modules/agents/__tests__/agent-loop-decision-conclusion.test.ts @@ -0,0 +1,32 @@ +/** + * parseDecisionConclusion 单测(#463:decision WU 输出的 `## 结论摘要` 段解析 → + * metadata.decisionSuggestion,确认弹窗预填 + 收口闸 2 契约产物锚点的数据源)。 + */ +import { describe, it, expect } from 'vitest'; +import { parseDecisionConclusion } from '../loop/agent-loop.js'; + +describe('parseDecisionConclusion', () => { + it('提取 `## 结论摘要` 段正文(到下一个 ## 段为止)', () => { + const text = [ + '前序分析过程……', + '## 结论摘要', + '选方案 A,理由是 X。', + '第二行补充。', + '## 其他段', + '不应进入结论', + 'ACTION: COMPLETE: done', + ].join('\n'); + expect(parseDecisionConclusion(text)).toBe('选方案 A,理由是 X。\n第二行补充。'); + }); + + it('段末遇 ACTION: 行截断', () => { + const text = '## 结论摘要\n结论 B。\nACTION: COMPLETE: done'; + expect(parseDecisionConclusion(text)).toBe('结论 B。'); + }); + + it('无摘要段 / 段体为空 / 空输入 → null', () => { + expect(parseDecisionConclusion('只是普通输出\nACTION: COMPLETE: done')).toBeNull(); + expect(parseDecisionConclusion('## 结论摘要\n\n## 下一节\nx')).toBeNull(); + expect(parseDecisionConclusion('')).toBeNull(); + }); +}); diff --git a/apps/api/src/modules/agents/__tests__/agent-loop-review-wu.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop-review-wu.test.ts index b546cb359..2d7c8c323 100644 --- a/apps/api/src/modules/agents/__tests__/agent-loop-review-wu.test.ts +++ b/apps/api/src/modules/agents/__tests__/agent-loop-review-wu.test.ts @@ -112,7 +112,9 @@ describe('P1: review WU 守卫豁免', () => { } it('review WU + 父 worktree 有未提交改动 + COMPLETE → 不打回,in_review → done 自动收口,且全程不碰 git', async () => { - const { reviewWu } = await setupReviewWu(); + // reviewReport 模拟 agentStep 协议行解析落档(收口闸 2 要求 review 契约产物非空; + // 本用例走 recordResult 直调,绕过 agentStep,需预置) + const { reviewWu } = await setupReviewWu({ reviewReport: { approved: true } }); const after = await record(reviewWu.id, 'complete', 'LGTM,评审通过'); @@ -138,7 +140,8 @@ describe('P1: review WU 守卫豁免', () => { }); it('review WU stepCount 超 15 未超 30 时 COMPLETE → 不被强制拦截,仍走 complete → done 收口', async () => { - const { reviewWu } = await setupReviewWu({ stepCount: 15 }); + // reviewReport 预置理由同上(收口闸 2 契约产物锚点) + const { reviewWu } = await setupReviewWu({ stepCount: 15, reviewReport: { approved: true } }); const after = await record(reviewWu.id, 'complete', '评审通过'); diff --git a/apps/api/src/modules/agents/__tests__/agent-loop-wu-worktree.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop-wu-worktree.test.ts index a71f68499..1ecbd67c5 100644 --- a/apps/api/src/modules/agents/__tests__/agent-loop-wu-worktree.test.ts +++ b/apps/api/src/modules/agents/__tests__/agent-loop-wu-worktree.test.ts @@ -444,7 +444,11 @@ describe('B3b-i: 每 WU worktree 隔离 + 自动验证', () => { const parent = await createWu('task', { workspaceRoot: repoRoot, worktreePath: WT() }); const reviewWu = await createWu('review', { workspaceRoot: repoRoot, worktreePath: WT() }, { parentId: parent.id }); - await loop().recordResult({ workUnit: reviewWu }, { action: 'complete', summary: 'REVIEW_RESULT: {"verdict":"pass"}' }); + await loop().recordResult({ workUnit: reviewWu }, { + action: 'complete', summary: 'REVIEW_RESULT: {"verdict":"pass"}', + // reviewReport 模拟 agentStep 协议行解析落档(收口闸 2 要求 review 契约产物非空) + metadataUpdates: { reviewReport: { approved: true } }, + }); expect(mockExecSh).not.toHaveBeenCalled(); // review 子 WU complete 直接收口 done(P0 修复路径) diff --git a/apps/api/src/modules/agents/__tests__/completion-gates.test.ts b/apps/api/src/modules/agents/__tests__/completion-gates.test.ts index ac8491790..662b935bf 100644 --- a/apps/api/src/modules/agents/__tests__/completion-gates.test.ts +++ b/apps/api/src/modules/agents/__tests__/completion-gates.test.ts @@ -2,6 +2,7 @@ // 覆盖:三条守卫各自的触发/跳过/降级路径、守卫优先级(commit → child → verify)、 // hint 文案、l1 台账形状、verifyFailCount ≥3 → verifyBlocked、no-commit 计数/提醒。 import { describe, it, expect, vi } from 'vitest'; +import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; @@ -9,6 +10,8 @@ import { runCompletionGuards, hasUncommittedChanges, readHeadHash, + countBranchCommits, + hasAnalysisReport, parseWuGitLog, loadCompletionCheckersConfig, type CompletionCheckerFns, @@ -80,8 +83,10 @@ describe('completion-gates: §10.5 提交守卫', () => { // 注入 checkers=null 隔离提交守卫语义:harness ≥1.1.0 起软观测段激活, // contract-presence 对无 worktree 类型(含 review)会设计性回退解析 cwd 取 .harness 契约清单 // (见 completion-gates.ts runSoftObservation 注释),不在本测试的提交守卫豁免口径内。 + // reviewReport 备齐:收口闸 2(契约产物)对缺 reviewReport 的 review 会降级,不属于本测试口径。 const deps = makeDeps({ loadCompletionCheckers: async () => null }); - const out = await runCompletionGuards(ctxOf(makeWu({ type: 'review' }), {}), deps); + const out = await runCompletionGuards( + ctxOf(makeWu({ type: 'review' }), { reviewReport: { approved: true } }), deps); expect(deps.resolveExecutionCwd).not.toHaveBeenCalled(); expect(deps.hasUncommittedChanges).not.toHaveBeenCalled(); @@ -396,7 +401,10 @@ describe('completion-gates: T7-E2 软观测段', () => { expect(out.action).toBe('complete'); }); - it('圈定口径:review 型契约缺失(contracts 含 review + 无 reviewReport)→ violation 事件 + hint', async () => { + it('圈定口径:review 型契约(contracts 含 review)→ reviewReport 透传,violation 事件 + hint', async () => { + // reviewReport 备齐以过收口闸 2(缺报告已在闸 2 硬降级,走不到软观测段); + // 本用例只验软观测段把 reviewReport 透传给 harness contract-presence + const report = { approved: false, reason: '缺测试' }; const { deps, fns, events } = makeSoftDeps( { verifyContractPresence: vi.fn().mockReturnValue({ @@ -405,9 +413,9 @@ describe('completion-gates: T7-E2 软观测段', () => { }, { config: { contracts: ['review'] } }, ); - const out = await runCompletionGuards(ctxOf(makeWu({ type: 'review' }), {}), deps); + const out = await runCompletionGuards(ctxOf(makeWu({ type: 'review' }), { reviewReport: report }), deps); - expect(fns.verifyContractPresence).toHaveBeenCalledWith('review', { reviewReport: undefined }, { contracts: ['review'] }); + expect(fns.verifyContractPresence).toHaveBeenCalledWith('review', { reviewReport: report }, { contracts: ['review'] }); expect(events).toContainEqual(expect.objectContaining({ checker: 'contract-presence', verdict: 'violation' })); expect(out.guardUpdates.processCheckHint).toContain('[contract-presence]'); expect(out.action).toBe('complete'); @@ -423,6 +431,206 @@ describe('completion-gates: T7-E2 软观测段', () => { }); }); +describe('completion-gates: 收口闸 1(代码类 diff 非空)', () => { + const DIFF_META: WorkUnitMetadata = { worktreePath: '/repo/wt', worktreeBaseBranch: 'main' }; + + it('空 diff(rev-list=0)→ 降级 progress + diffEmptyHint + diffEmptyCount++,verify 不再触发', async () => { + const deps = makeDeps({ countBranchCommits: vi.fn().mockReturnValue(0) }); + const out = await runCompletionGuards(ctxOf(makeWu(), { ...DIFF_META, diffEmptyCount: 1 }), deps); + + expect(out.action).toBe('progress'); + expect(out.guardUpdates.diffEmptyCount).toBe(2); + expect(out.guardUpdates.diffEmptyHint) + .toBe('你报告了完成,但相对 main 没有任何提交内容(第 2 次)。请实现并 commit,或说明为何无需改动'); + // 优先级:闸 1 降级后 verify 不再触发 + expect(deps.runVerification).not.toHaveBeenCalled(); + expect(out.notices.diffEmptyBlocked).toBe(false); + }); + + it('有提交内容(rev-list>0)→ 放行不写 hint;既有计数归零', async () => { + const deps = makeDeps({ countBranchCommits: vi.fn().mockReturnValue(3) }); + const out = await runCompletionGuards(ctxOf(makeWu(), { ...DIFF_META, diffEmptyCount: 2 }), deps); + + expect(out.action).toBe('complete'); + expect(out.guardUpdates.diffEmptyHint).toBeUndefined(); + expect(out.guardUpdates.diffEmptyCount).toBe(0); + expect(out.notices.diffEmptyBlocked).toBe(false); + }); + + it('diffEmptyCount 到 3 → diffEmptyBlocked', async () => { + const deps = makeDeps({ countBranchCommits: vi.fn().mockReturnValue(0) }); + const out = await runCompletionGuards(ctxOf(makeWu(), { ...DIFF_META, diffEmptyCount: 2 }), deps); + + expect(out.guardUpdates.diffEmptyCount).toBe(3); + expect(out.notices.diffEmptyBlocked).toBe(true); + }); + + it('跳过条件:缺 worktreeBaseBranch → 不跑 git 探针;git 失败(null)→ 静默放行', async () => { + const spy = vi.fn().mockReturnValue(0); + const noBase = makeDeps({ countBranchCommits: spy }); + const out1 = await runCompletionGuards(ctxOf(makeWu(), { worktreePath: '/repo/wt' }), noBase); + expect(spy).not.toHaveBeenCalled(); + expect(out1.action).toBe('complete'); + + const gitFail = makeDeps({ countBranchCommits: vi.fn().mockReturnValue(null) }); + const out2 = await runCompletionGuards(ctxOf(makeWu(), DIFF_META), gitFail); + expect(out2.action).toBe('complete'); + expect(out2.guardUpdates.diffEmptyHint).toBeUndefined(); + }); + + it('跳过条件:非代码类(analysis)即使有 worktree/base 也不进闸 1', async () => { + const spy = vi.fn().mockReturnValue(0); + const deps = makeDeps({ countBranchCommits: spy }); + const out = await runCompletionGuards( + ctxOf(makeWu({ type: 'analysis' }), { ...DIFF_META, prototype: true }), deps); + + expect(spy).not.toHaveBeenCalled(); + expect(out.action).toBe('complete'); + }); +}); + +describe('completion-gates: 收口闸 2(非代码类契约产物)', () => { + it('review 缺 reviewReport → 降级 progress + contractArtifactHint;有 → 放行', async () => { + const missing = await runCompletionGuards(ctxOf(makeWu({ type: 'review' }), {}), makeDeps()); + expect(missing.action).toBe('progress'); + expect(missing.guardUpdates.contractArtifactCount).toBe(1); + expect(missing.guardUpdates.contractArtifactHint) + .toBe('契约产物缺失:评审结论(REVIEW_RESULT 协议输出)(第 1 次)。请补齐产物后再报告完成'); + + const present = await runCompletionGuards( + ctxOf(makeWu({ type: 'review' }), { reviewReport: { approved: true } }), makeDeps()); + expect(present.action).toBe('complete'); + expect(present.guardUpdates.contractArtifactHint).toBeUndefined(); + }); + + it('plan 缺 analysisTasks → 降级;有 → 放行', async () => { + const missing = await runCompletionGuards(ctxOf(makeWu({ type: 'plan' }), {}), makeDeps()); + expect(missing.action).toBe('progress'); + expect(missing.guardUpdates.contractArtifactHint).toContain('任务拆分清单'); + + const present = await runCompletionGuards( + ctxOf(makeWu({ type: 'plan' }), { analysisTasks: ['拆分子任务 A'] }), makeDeps()); + expect(present.action).toBe('complete'); + }); + + it('spec 缺 specTasks → 降级;有 → 放行', async () => { + const missing = await runCompletionGuards(ctxOf(makeWu({ type: 'spec' }), {}), makeDeps()); + expect(missing.action).toBe('progress'); + expect(missing.guardUpdates.contractArtifactHint).toContain('物化任务清单'); + + const present = await runCompletionGuards( + ctxOf(makeWu({ type: 'spec' }), { specTasks: [{ title: 't', ac: [], blockedBy: [] }] }), makeDeps()); + expect(present.action).toBe('complete'); + }); + + it('decision 缺 decisionSuggestion → 降级;有 → 放行', async () => { + const missing = await runCompletionGuards(ctxOf(makeWu({ type: 'decision' }), {}), makeDeps()); + expect(missing.action).toBe('progress'); + expect(missing.guardUpdates.contractArtifactHint).toContain('结论摘要'); + + const present = await runCompletionGuards( + ctxOf(makeWu({ type: 'decision' }), { decisionSuggestion: '选方案 A' }), makeDeps()); + expect(present.action).toBe('complete'); + }); + + it('analysis 报告缺失 → 降级;落盘 → 放行;巡检变体按 inspection 检查', async () => { + const reportMissing = makeDeps({ analysisReportExists: vi.fn().mockReturnValue(false) }); + const out1 = await runCompletionGuards( + ctxOf(makeWu({ type: 'analysis' }), { workspaceRoot: '/repo' }), reportMissing); + expect(out1.action).toBe('progress'); + expect(out1.guardUpdates.contractArtifactHint).toContain('调研报告(.studio/research/)'); + expect(reportMissing.analysisReportExists).toHaveBeenCalledWith('/repo', false); + + const reportPresent = makeDeps({ analysisReportExists: vi.fn().mockReturnValue(true) }); + const out2 = await runCompletionGuards( + ctxOf(makeWu({ type: 'analysis' }), { workspaceRoot: '/repo' }), reportPresent); + expect(out2.action).toBe('complete'); + + const inspection = makeDeps({ analysisReportExists: vi.fn().mockReturnValue(false) }); + const out3 = await runCompletionGuards( + ctxOf(makeWu({ type: 'analysis' }), { workspaceRoot: '/repo', inspection: true }), inspection); + expect(out3.action).toBe('progress'); + expect(out3.guardUpdates.contractArtifactHint).toContain('inspection-<日期>'); + expect(inspection.analysisReportExists).toHaveBeenCalledWith('/repo', true); + }); + + it('analysis 跳过条件:prototype 原型单豁免;无 workspaceRoot 无法校验 → 跳过', async () => { + const spy = vi.fn().mockReturnValue(false); + const proto = makeDeps({ analysisReportExists: spy }); + const out1 = await runCompletionGuards( + ctxOf(makeWu({ type: 'analysis' }), { prototype: true, workspaceRoot: '/repo' }), proto); + expect(spy).not.toHaveBeenCalled(); + expect(out1.action).toBe('complete'); + + const noRoot = makeDeps({ analysisReportExists: spy }); + const out2 = await runCompletionGuards(ctxOf(makeWu({ type: 'analysis' }), {}), noRoot); + expect(spy).not.toHaveBeenCalled(); + expect(out2.action).toBe('complete'); + }); + + it('contractArtifactCount 到 3 → contractArtifactBlocked', async () => { + const deps = makeDeps(); + const out = await runCompletionGuards( + ctxOf(makeWu({ type: 'decision' }), { contractArtifactCount: 2 }), deps); + + expect(out.guardUpdates.contractArtifactCount).toBe(3); + expect(out.notices.contractArtifactBlocked).toBe(true); + expect(out.action).toBe('progress'); + }); + + it('代码类(task)不进本闸:无锚点可判 → 不降级', async () => { + const out = await runCompletionGuards(ctxOf(makeWu(), {}), makeDeps()); + expect(out.action).toBe('complete'); + expect(out.guardUpdates.contractArtifactHint).toBeUndefined(); + }); +}); + +describe('completion-gates: 默认产物探针(真实实现)', () => { + it('countBranchCommits:真实 git 仓库按 base..HEAD 计数;非 git 目录 → null', () => { + const plain = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-plain-')); + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-repo-')); + try { + expect(countBranchCommits(plain, 'main')).toBeNull(); + + execSync('git init -q -b main && git config user.email t@t && git config user.name t', { cwd: repo }); + fs.writeFileSync(path.join(repo, 'a.txt'), 'a'); + execSync('git add . && git commit -qm init', { cwd: repo }); + expect(countBranchCommits(repo, 'main')).toBe(0); // HEAD 即 base,无领先提交 + + execSync('git checkout -qb task/wu-1', { cwd: repo }); + fs.writeFileSync(path.join(repo, 'b.txt'), 'b'); + execSync('git add . && git commit -qm work', { cwd: repo }); + expect(countBranchCommits(repo, 'main')).toBe(1); + } finally { + fs.rmSync(plain, { recursive: true, force: true }); + fs.rmSync(repo, { recursive: true, force: true }); + } + }); + + it('hasAnalysisReport:目录缺失/空目录/空文件 → false;非空报告 → true;inspection 只认 inspection-*.md', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-report-')); + try { + expect(hasAnalysisReport(dir, false)).toBe(false); // 无 .studio/research 目录 + + fs.mkdirSync(path.join(dir, '.studio', 'research'), { recursive: true }); + expect(hasAnalysisReport(dir, false)).toBe(false); // 空目录 + + const report = path.join(dir, '.studio', 'research', 'report.md'); + fs.writeFileSync(report, ''); + expect(hasAnalysisReport(dir, false)).toBe(false); // 空文件 + + fs.writeFileSync(report, '调研结论'); + expect(hasAnalysisReport(dir, false)).toBe(true); + expect(hasAnalysisReport(dir, true)).toBe(false); // 巡检变体不认普通报告 + + fs.writeFileSync(path.join(dir, '.studio', 'research', 'inspection-2026-09-15.md'), '巡检结论'); + expect(hasAnalysisReport(dir, true)).toBe(true); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); + describe('completion-gates: T7-E2 parseWuGitLog(git log %(trailers) 输出解析)', () => { it('sha/subject/parents/trailer/文件清单分段;merge commit 按 %P 父数标记;输出反转为升序', () => { const shaNew = 'b'.repeat(40); diff --git a/apps/api/src/modules/agents/__tests__/prompt-composer.test.ts b/apps/api/src/modules/agents/__tests__/prompt-composer.test.ts index b9807460d..7e1ea5657 100644 --- a/apps/api/src/modules/agents/__tests__/prompt-composer.test.ts +++ b/apps/api/src/modules/agents/__tests__/prompt-composer.test.ts @@ -933,6 +933,7 @@ describe('#119: 契约段生成器(按 WU type)+ 段序稳定性重排', () expect(prompt).toContain('## 产出契约'); expect(prompt).toContain('REVIEW_RESULT'); + expect(prompt).toContain('本环节标准打法:code-review'); expect(knowledgeContext).not.toContain('## 产出契约'); const baseIdx = prompt.indexOf('## 当前工作'); @@ -954,6 +955,7 @@ describe('#119: 契约段生成器(按 WU type)+ 段序稳定性重排', () expect(prompt).toContain('## 产出契约'); expect(prompt).toContain('测试先行'); expect(prompt).toContain('Phase commit'); + expect(prompt).toContain('本环节标准打法:tdd-implement'); }); it('契约段 decision(决策单)→ 结论摘要格式', async () => { @@ -1002,6 +1004,7 @@ describe('#119: 契约段生成器(按 WU type)+ 段序稳定性重排', () expect(prompt).toContain('## 产出契约'); expect(prompt).toContain('复现测试先行'); expect(prompt).toContain('防回归测试随修复同 commit'); + expect(prompt).toContain('本环节标准打法:diagnosing-bugs'); }); it('未知/无契约 type(task/feature)→ 空段不注入(spec 自 #463 起有物化清单契约)', async () => { @@ -1044,6 +1047,17 @@ describe('#119: 契约段生成器(按 WU type)+ 段序稳定性重排', () expect(SECTION_QUOTAS.contract).toBe(200); expect(Object.keys(CONTRACT_TEMPLATES).sort()).toEqual(['analysis', 'bug', 'decision', 'implement', 'plan', 'review', 'spec']); }); + + it('契约段尾标准打法指引行(不受定额截断影响,直接断言模板表):implement/bug/plan/analysis/review → 对应 skill', () => { + expect(CONTRACT_TEMPLATES.implement).toContain('本环节标准打法:tdd-implement(索引见 skills 段,先 loadSkill 读全文再开工)。'); + expect(CONTRACT_TEMPLATES.bug).toContain('本环节标准打法:diagnosing-bugs(索引见 skills 段,先 loadSkill 读全文再开工)。'); + expect(CONTRACT_TEMPLATES.plan).toContain('本环节标准打法:requirement-clarify + to-tickets(索引见 skills 段,先 loadSkill 读全文再开工)。'); + expect(CONTRACT_TEMPLATES.analysis).toContain('本环节标准打法:research(索引见 skills 段,先 loadSkill 读全文再开工)。'); + expect(CONTRACT_TEMPLATES.review).toContain('本环节标准打法:code-review(索引见 skills 段,先 loadSkill 读全文再开工)。'); + // decision/spec/巡检变体(INSPECTION_CONTRACT)无对应打法 skill,不加指引行 + expect(CONTRACT_TEMPLATES.decision).not.toContain('本环节标准打法'); + expect(CONTRACT_TEMPLATES.spec).not.toContain('本环节标准打法'); + }); }); describe('#161 T7-E2: processCheckHint 注入→消费→清除回路', () => { diff --git a/apps/api/src/modules/agents/loop/__tests__/claim-fitness.test.ts b/apps/api/src/modules/agents/loop/__tests__/claim-fitness.test.ts new file mode 100644 index 000000000..d7d0e775e --- /dev/null +++ b/apps/api/src/modules/agents/loop/__tests__/claim-fitness.test.ts @@ -0,0 +1,409 @@ +/** + * 决策 14 认领前适任判断(agents/loop/claim-fitness.ts + agent-loop 集成)单测 + * + * 覆盖(对应设计要点): + * - parseFitnessVerdict:FIT 协议解析(yes/no/理由/信封拆封),解析不出 → 从宽适任 + * - parseUnfitRoles/isRoleUnfit:metadata 容错解析 + * - judgeClaimFitness:prompt 组装(type+scope 截断+persona/acceptedTypes)、调用失败 → 从宽 + * - observe 第 7 道过滤:unfitRoles 含本 role → 不可见;看下一候选;全不适任 → 不认领 + * - ensureClaimFit:适任放行 / 不适任落档 unfitRoles / 指名不判 / 落档失败从宽 + * - 全员不适任:unfitRoles 覆盖频道全部 active 成员 → blocked + blockReason + 频道消息 + * + * 真实 FileStore(tmpdir)+ 真实 WorkUnitService;LLM 调用 mock getSystemExecutor + * (同 completion-extraction.test.ts 先例);CLI 执行与 knowledge-service mock。 + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { FileStore } from '@dommaker/studio-shared'; +import { WorkUnitService, type WorkUnitData } from '../../../workunit/workunit.service.js'; +import { parseWuMetadata } from '../../../workunit/wu-metadata.js'; + +const { mockRun } = vi.hoisted(() => ({ mockRun: vi.fn() })); + +vi.mock('../../system-executor.js', () => ({ + getSystemExecutor: () => ({ run: mockRun }), +})); + +vi.mock('@dommaker/studio-agent', () => ({ + agentRunner: { + executeLightweight: vi.fn(), + }, +})); + +vi.mock('../../../knowledge/knowledge-service', () => ({ + knowledgeService: { + injectContext: vi.fn().mockResolvedValue({ prompt: '', injectedIds: [] }), + recordOutcome: vi.fn().mockResolvedValue(undefined), + extractFromExecution: vi.fn().mockResolvedValue(undefined), + }, +})); + +import { AgentLoop, resolveTarget } from '../agent-loop'; +import { + buildClaimFitnessPrompt, parseFitnessVerdict, parseUnfitRoles, isRoleUnfit, + judgeClaimFitness, FITNESS_SCOPE_MAX_CHARS, +} from '../claim-fitness'; + +const SELF_ROLE_ID = 'role-self'; +const OTHER_ROLE_ID = 'role-other'; +const SELF_INSTANCE_ID = 'instance-self'; +const MY_CHANNEL = 'ch-mine'; + +const mockRole = { + id: SELF_ROLE_ID, + name: 'self-agent', + description: '后端开发角色', + channels: JSON.stringify([MY_CHANNEL]), + status: 'active', + createdAt: new Date(), + updatedAt: new Date(), + persona: '我负责后端 API 与数据层实现', + acceptedTypes: ['implement', 'bug'], +}; + +interface ObserveCapable { + observe(): Promise<{ + myActive: WorkUnitData[]; + unassigned: WorkUnitData[]; + newReplies: unknown[]; + }>; +} + +interface FitnessCapable { + ensureClaimFit(wu: WorkUnitData): Promise; +} + +let testDir: string; +let fileStore: FileStore; +let wuService: WorkUnitService; +let agentLoop: AgentLoop; + +beforeEach(() => { + vi.clearAllMocks(); + mockRun.mockResolvedValue({ output: 'FIT: yes: 属于本角色职能域' }); + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-loop-claim-fitness-')); + fileStore = new FileStore(testDir); + wuService = new WorkUnitService(fileStore); + agentLoop = new AgentLoop(mockRole, fileStore); + (agentLoop as unknown as { instance: unknown }).instance = { + id: SELF_INSTANCE_ID, + roleId: SELF_ROLE_ID, + sessionId: null, + status: 'idle', + currentWorkUnitId: null, + startedAt: new Date().toISOString(), + terminatedAt: null, + lastHeartbeat: null, + metadata: null, + }; +}); + +afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); +}); + +function observe() { + return (agentLoop as unknown as ObserveCapable).observe(); +} + +function ensureClaimFit(wu: WorkUnitData) { + return (agentLoop as unknown as FitnessCapable).ensureClaimFit(wu); +} + +async function createUnassignedWu(scope: string, metadata?: Record, assigneeId: string | null = null) { + return wuService.create({ + scope, channelId: MY_CHANNEL, type: 'implement', + status: 'unassigned', assigneeId, + ...(metadata ? { metadata } : {}), + }); +} + +/** 把 WU createdAt 回填到过去(控制 observe 的 createdAt asc 排序) */ +async function backdateCreatedAt(wuId: string, ms: number): Promise { + const snap = (await fileStore.getIndex()).find(s => s.id === wuId); + if (!snap) throw new Error(`wu ${wuId} not found`); + await fileStore.upsertSnapshot({ ...snap, createdAt: new Date(Date.now() - ms).toISOString() }); +} + +async function createChannelWithMembers(memberIds: string[]): Promise { + const now = new Date().toISOString(); + await fileStore.createChannel({ + id: MY_CHANNEL, + name: 'test-channel', + type: 'rnd', + defaultWorkspaceId: null, + defaultPath: null, + discordChannelId: null, + discordWebhookUrl: null, + members: JSON.stringify(memberIds), + createdAt: now, + updatedAt: now, + }); +} + +async function createProfile(id: string, name: string, status: string): Promise { + const now = new Date().toISOString(); + await fileStore.createProfile({ + id, name, description: null, channels: '[]', status, provider: 'claude', + createdAt: now, updatedAt: now, + }); +} + +describe('parseFitnessVerdict', () => { + it('FIT: yes → 适任,理由随行为空可缺省', () => { + expect(parseFitnessVerdict('FIT: yes: 属于后端范畴')).toEqual({ fit: true, reason: '属于后端范畴' }); + expect(parseFitnessVerdict('FIT: yes')).toEqual({ fit: true, reason: '' }); + }); + + it('FIT: no → 不适任,理由取同行余下部分', () => { + expect(parseFitnessVerdict('FIT: no: 这是前端样式任务')).toEqual({ fit: false, reason: '这是前端样式任务' }); + }); + + it('claude --output-format json 信封先拆封再解析', () => { + const envelope = JSON.stringify({ type: 'result', result: 'FIT: no: 与角色无关' }); + expect(parseFitnessVerdict(envelope)).toEqual({ fit: false, reason: '与角色无关' }); + }); + + it('模型输出夹带其他行 → 只认 FIT 行', () => { + expect(parseFitnessVerdict('让我想想……\nFIT: no: 做不了\n以上')).toEqual({ fit: false, reason: '做不了' }); + }); + + it('解析不出(无 FIT 行/空串/坏信封)→ 从宽适任', () => { + expect(parseFitnessVerdict('我觉得可以试试吧').fit).toBe(true); + expect(parseFitnessVerdict('').fit).toBe(true); + expect(parseFitnessVerdict('{"type":"other"}').fit).toBe(true); + }); + + it('理由超 120 字符截断', () => { + const long = parseFitnessVerdict(`FIT: no: ${'x'.repeat(200)}`); + expect(long.fit).toBe(false); + expect(long.reason.length).toBe(120); + }); +}); + +describe('parseUnfitRoles / isRoleUnfit', () => { + const entry = { roleId: SELF_ROLE_ID, reason: 'r', at: '2026-09-15T00:00:00.000Z' }; + + it('正常名单解析 + 命中判定', () => { + const meta = JSON.stringify({ unfitRoles: [entry] }); + expect(parseUnfitRoles(meta)).toEqual([entry]); + expect(isRoleUnfit(meta, SELF_ROLE_ID)).toBe(true); + expect(isRoleUnfit(meta, OTHER_ROLE_ID)).toBe(false); + }); + + it('缺失/坏 JSON/非数组/缺 roleId 条目 → 空名单(不排除)', () => { + expect(parseUnfitRoles(null)).toEqual([]); + expect(parseUnfitRoles(undefined)).toEqual([]); + expect(parseUnfitRoles('{bad json')).toEqual([]); + expect(parseUnfitRoles(JSON.stringify({ unfitRoles: 'nope' }))).toEqual([]); + expect(parseUnfitRoles(JSON.stringify({ unfitRoles: [{ reason: 'x' }, entry] }))).toEqual([entry]); + }); +}); + +describe('buildClaimFitnessPrompt / judgeClaimFitness', () => { + it('prompt 含 role 名/persona/acceptedTypes/wu type/scope', () => { + const prompt = buildClaimFitnessPrompt( + { type: 'implement', scope: '给 API 加分页' }, + mockRole as never, + ); + expect(prompt).toContain('self-agent'); + expect(prompt).toContain('我负责后端 API 与数据层实现'); + expect(prompt).toContain('implement、bug'); + expect(prompt).toContain('类型 implement'); + expect(prompt).toContain('给 API 加分页'); + }); + + it('scope 超长按 FITNESS_SCOPE_MAX_CHARS 截断', () => { + const prompt = buildClaimFitnessPrompt( + { type: 'task', scope: 's'.repeat(FITNESS_SCOPE_MAX_CHARS + 500) }, + mockRole as never, + ); + expect(prompt).toContain('[truncated]'); + expect(prompt.length).toBeLessThan(FITNESS_SCOPE_MAX_CHARS + 500); + }); + + it('判断调用(默认走 getSystemExecutor)返回 FIT: no → 不适任', async () => { + mockRun.mockResolvedValue({ output: 'FIT: no: 前端任务' }); + const verdict = await judgeClaimFitness({ type: 'implement', scope: '改样式' }, mockRole as never); + expect(verdict.fit).toBe(false); + expect(mockRun).toHaveBeenCalledTimes(1); + const callArgs = mockRun.mock.calls[0]; + expect(callArgs[1].eventSource).toBe('claim-fitness'); + }); + + it('判断调用抛错(spawn 失败/超时/未配置)→ 从宽适任', async () => { + mockRun.mockRejectedValue(new Error('cli timeout')); + const verdict = await judgeClaimFitness({ type: 'implement', scope: '任何任务' }, mockRole as never); + expect(verdict.fit).toBe(true); + }); +}); + +describe('observe 第 7 道过滤(unfitRoles 不可见)', () => { + it('unfitRoles 含本 role → 不可见;含其他 role → 可见', async () => { + const unfitForSelf = await createUnassignedWu('别人做不了的单', { + unfitRoles: [{ roleId: SELF_ROLE_ID, reason: '做不了', at: new Date().toISOString() }], + }); + const unfitForOther = await createUnassignedWu('别人已被排除的单', { + unfitRoles: [{ roleId: OTHER_ROLE_ID, reason: '不适合他', at: new Date().toISOString() }], + }); + + const obs = await observe(); + const ids = obs.unassigned.map(w => w.id); + expect(ids).not.toContain(unfitForSelf.id); + expect(ids).toContain(unfitForOther.id); + }); + + it('首候选不适任 → resolveTarget 取下一候选(继续评估下一单)', async () => { + const first = await createUnassignedWu('更早但不适合我的单', { + unfitRoles: [{ roleId: SELF_ROLE_ID, reason: 'r', at: new Date().toISOString() }], + }); + await backdateCreatedAt(first.id, 60_000); + const second = await createUnassignedWu('稍晚的普通单'); + + const obs = await observe(); + const target = resolveTarget(obs); + expect(target?.workUnit.id).toBe(second.id); + }); + + it('全部候选不适任 → resolveTarget 为 null(本轮不认领静默等待)', async () => { + await createUnassignedWu('单甲', { + unfitRoles: [{ roleId: SELF_ROLE_ID, reason: 'r', at: new Date().toISOString() }], + }); + await createUnassignedWu('单乙', { + unfitRoles: [{ roleId: SELF_ROLE_ID, reason: 'r', at: new Date().toISOString() }], + }); + + const obs = await observe(); + expect(obs.unassigned).toHaveLength(0); + expect(resolveTarget(obs)).toBeNull(); + }); +}); + +describe('ensureClaimFit(认领前闸门)', () => { + it('判 yes → 放行,metadata 不写 unfitRoles', async () => { + const wu = await createUnassignedWu('普通后端单'); + expect(await ensureClaimFit(wu)).toBe(true); + expect(mockRun).toHaveBeenCalledTimes(1); + const meta = parseWuMetadata((await wuService.getById(wu.id))!.metadata); + expect(meta.unfitRoles).toBeUndefined(); + }); + + it('判 no → 不放行 + unfitRoles 落档 {roleId, reason, at}', async () => { + mockRun.mockResolvedValue({ output: 'FIT: no: 纯前端样式任务' }); + const wu = await createUnassignedWu('调整按钮颜色'); + expect(await ensureClaimFit(wu)).toBe(false); + + const meta = parseWuMetadata((await wuService.getById(wu.id))!.metadata); + expect(meta.unfitRoles).toHaveLength(1); + expect(meta.unfitRoles![0].roleId).toBe(SELF_ROLE_ID); + expect(meta.unfitRoles![0].reason).toBe('纯前端样式任务'); + expect(typeof meta.unfitRoles![0].at).toBe('string'); + }); + + it('已落档过本 role → 去重不重复追加', async () => { + mockRun.mockResolvedValue({ output: 'FIT: no: 做不了' }); + const wu = await createUnassignedWu('单', { + unfitRoles: [{ roleId: SELF_ROLE_ID, reason: '旧理由', at: '2026-09-14T00:00:00.000Z' }], + }); + expect(await ensureClaimFit(wu)).toBe(false); + const meta = parseWuMetadata((await wuService.getById(wu.id))!.metadata); + expect(meta.unfitRoles).toHaveLength(1); + expect(meta.unfitRoles![0].reason).toBe('做不了'); + }); + + it('判断调用失败 → 从宽放行,不落档', async () => { + mockRun.mockRejectedValue(new Error('spawn failed')); + const wu = await createUnassignedWu('普通单'); + expect(await ensureClaimFit(wu)).toBe(true); + const meta = parseWuMetadata((await wuService.getById(wu.id))!.metadata); + expect(meta.unfitRoles).toBeUndefined(); + }); + + it('显式指名本 role 的 WU 不判(人已点名),直接放行且不调用 LLM', async () => { + const wu = await createUnassignedWu('指名单', undefined, SELF_ROLE_ID); + expect(await ensureClaimFit(wu)).toBe(true); + expect(mockRun).not.toHaveBeenCalled(); + }); + + it('落档失败(FileStore 故障)→ 从宽放行(宁可误抢,不每轮空烧判断)', async () => { + mockRun.mockResolvedValue({ output: 'FIT: no: 做不了' }); + const wu = await createUnassignedWu('普通单'); + const svc = (agentLoop as unknown as { workUnitService: WorkUnitService }).workUnitService; + const original = svc.update.bind(svc); + svc.update = vi.fn().mockRejectedValue(new Error('fs broken')) as never; + try { + expect(await ensureClaimFit(wu)).toBe(true); + } finally { + svc.update = original; + } + }); + + it('判 no 但 WU 已被他人抢先认领 → 不落档', async () => { + mockRun.mockResolvedValue({ output: 'FIT: no: 做不了' }); + const wu = await createUnassignedWu('普通单'); + await wuService.claim(wu.id, 'instance-other'); + expect(await ensureClaimFit(wu)).toBe(false); + const meta = parseWuMetadata((await wuService.getById(wu.id))!.metadata); + expect(meta.unfitRoles).toBeUndefined(); + }); +}); + +describe('全员不适任转人工', () => { + beforeEach(async () => { + await createChannelWithMembers([SELF_ROLE_ID, OTHER_ROLE_ID]); + await createProfile(SELF_ROLE_ID, 'self-agent', 'active'); + }); + + it('unfitRoles 覆盖频道全部 active 成员 → blocked + blockReason + 频道消息', async () => { + await createProfile(OTHER_ROLE_ID, 'other-agent', 'active'); + mockRun.mockResolvedValue({ output: 'FIT: no: 跨领域任务' }); + const wu = await createUnassignedWu('谁都不会做的单', { + unfitRoles: [{ roleId: OTHER_ROLE_ID, reason: '他也不行', at: new Date().toISOString() }], + }); + + expect(await ensureClaimFit(wu)).toBe(false); + + const after = (await wuService.getById(wu.id))!; + expect(after.status).toBe('blocked'); + const meta = parseWuMetadata(after.metadata); + expect(meta.blockReason).toContain('unfit-all'); + expect(meta.unfitRoles!.map(e => e.roleId).sort()).toEqual([OTHER_ROLE_ID, SELF_ROLE_ID].sort()); + // 仿 blocked 转人模式:频道消息「无人能接」 + const messages = await fileStore.queryAllMessages({ workUnitId: wu.id }); + expect(messages.some(m => m.content.includes('无人能接此任务'))).toBe(true); + }); + + it('还有其他 active 成员未判 → 保持 unassigned,不转人工', async () => { + await createProfile(OTHER_ROLE_ID, 'other-agent', 'active'); + mockRun.mockResolvedValue({ output: 'FIT: no: 我做不了' }); + const wu = await createUnassignedWu('普通单'); + + expect(await ensureClaimFit(wu)).toBe(false); + const after = (await wuService.getById(wu.id))!; + expect(after.status).toBe('unassigned'); + const messages = await fileStore.queryAllMessages({ workUnitId: wu.id }); + expect(messages.some(m => m.content.includes('无人能接此任务'))).toBe(false); + }); + + it('inactive 成员不计入「全员」——唯一 active 成员判 no 即全员不适任', async () => { + await createProfile(OTHER_ROLE_ID, 'other-agent', 'inactive'); + mockRun.mockResolvedValue({ output: 'FIT: no: 做不了' }); + const wu = await createUnassignedWu('普通单'); + + expect(await ensureClaimFit(wu)).toBe(false); + expect((await wuService.getById(wu.id))!.status).toBe('blocked'); + }); + + it('无频道 WU 判 no → 落档但不判全员(保持 unassigned)', async () => { + mockRun.mockResolvedValue({ output: 'FIT: no: 做不了' }); + const wu = await wuService.create({ + scope: '无频道单', channelId: null, type: 'implement', status: 'unassigned', assigneeId: null, + }); + expect(await ensureClaimFit(wu)).toBe(false); + const after = (await wuService.getById(wu.id))!; + expect(after.status).toBe('unassigned'); + expect(parseWuMetadata(after.metadata).unfitRoles).toHaveLength(1); + }); +}); diff --git a/apps/api/src/modules/agents/loop/agent-loop.ts b/apps/api/src/modules/agents/loop/agent-loop.ts index 1f95e3fd6..16e0d2297 100644 --- a/apps/api/src/modules/agents/loop/agent-loop.ts +++ b/apps/api/src/modules/agents/loop/agent-loop.ts @@ -51,6 +51,7 @@ import { resolveToolTraceFile, writeToolCallEvents, type RealUsage, } from './agent-loop-events.js'; import { testWuGuardEnabled, isTestLikeWorkUnit, parseExcludeAssignee } from './agent-loop-guards.js'; +import { judgeClaimFitness, isRoleUnfit, parseUnfitRoles } from './claim-fitness.js'; import { composeStepPrompt } from './prompt-composer.js'; import { handleDelegateBranch } from './delegate-branch.js'; import { shouldResumeSession, RESUME_FAILURE_RE } from './session-resume.js'; @@ -420,6 +421,12 @@ export class AgentLoop { // Claim if unassigned if (target.workUnit.status === 'unassigned') { + // 决策 14 认领前适任判断(「做不了的不抢」):一次性 LLM 判 no → 落档 + // metadata.unfitRoles 后本轮放弃;下一轮 observe 经第 7 道过滤自动看下一候选, + // 全部候选不适任则静默等待。成本与认领次数成正比,不进 observe 轮询 + if (!(await this.ensureClaimFit(target.workUnit))) { + continue; + } const claimed = await this.claimAndAnnounce(target.workUnit); if (!claimed) { await sleep(1_000); @@ -638,6 +645,83 @@ export class AgentLoop { } catch { /* best-effort:负载构建失败绝不阻断主循环 */ } } + /** + * 决策 14 认领前适任判断(agents/loop/claim-fitness.ts)。 + * 显式指名(assigneeId=profile id,人已点名)不判直接放行;涌现认领才判。 + * 判 no → recordClaimUnfit 落档 unfitRoles(含全员不适任转 blocked 检查)→ false。 + * 判断本身从宽(judgeClaimFitness 内部全 catch);落档失败同口径从宽放行—— + * 宁可误抢(NEED_INPUT 兜底)也不可因 FileStore 抖动每轮空烧判断调用。 + */ + private async ensureClaimFit(wu: WorkUnitData): Promise { + if (wu.assigneeId) return true; + const verdict = await judgeClaimFitness(wu, this.role); + if (verdict.fit) return true; + try { + await this.recordClaimUnfit(wu, verdict.reason); + } catch (err) { + logger.warn(`[AgentLoop] record unfitRoles failed — 从宽放行认领: ${getErrorMessage(err)}`, { wuId: wu.id }); + return true; + } + return false; + } + + /** + * 不适任落档:读-改-写合并最新 metadata 追加 unfitRoles 条目 + * (读-改-写互覆防护同 completion-extraction 哨兵先例)。WU 已非 unassigned + * (他角色抢先认领)→ 不落档。落档后检查全员不适任 → 转 blocked 转人工。 + */ + private async recordClaimUnfit(wu: WorkUnitData, reason: string): Promise { + const fresh = await this.workUnitService.getById(wu.id); + if (!fresh || fresh.status !== 'unassigned') return; + const unfitRoles = [ + ...parseUnfitRoles(fresh.metadata).filter(e => e.roleId !== this.role.id), + { roleId: this.role.id, reason, at: new Date().toISOString() }, + ]; + await this.workUnitService.update(fresh.id, { + metadata: { ...parseWuMetadata(fresh.metadata), unfitRoles }, + }); + logger.info(`[AgentLoop] ${this.role.name} 认领前适任判断 = 不适任,已落档 unfitRoles`, { + wuId: fresh.id, reason, + }); + await this.blockIfAllMembersUnfit(fresh.id, unfitRoles); + } + + /** + * 全员不适任转人工:unfitRoles 覆盖该频道全部 active 成员(口径同 + * review-dispatcher.getChannelActiveMembers:channel.members ∩ active profile,排除 studio) + * → WU 置 blocked + blockReason 落档 + 频道消息「无人能接」(仿现有 blocked 转人模式: + * withBlockedCta + 里程碑)。成员未知(历史频道未回填)/ 无频道 → 不判,保持涌现等待。 + * 各步失败只记日志,不抛(调用方本轮已放弃认领,WU 保持 unassigned 等下轮/人工)。 + */ + private async blockIfAllMembersUnfit(wuId: string, unfitRoles: { roleId: string }[]): Promise { + try { + const wu = await this.workUnitService.getById(wuId); + if (!wu?.channelId) return; + const channel = await this.fileStore.getChannel(wu.channelId); + const memberIds = parseChannels(channel?.members); + if (memberIds.length === 0) return; + const activeMembers = (await this.fileStore.listProfiles({ status: 'active' })) + .filter(p => memberIds.includes(p.id) && p.name !== 'studio'); + if (activeMembers.length === 0) return; + const unfitIds = new Set(unfitRoles.map(e => e.roleId)); + if (!activeMembers.every(p => unfitIds.has(p.id))) return; + + const blockReason = `unfit-all: 频道 ${activeMembers.length} 个 active 角色认领前适任判断均为不适任`; + // unassigned → blocked 不在 VALID_TRANSITIONS,走语义方法直写(blockForManualRelease 先例); + // 返回 null = 已非 unassigned(他角色抢先认领/状态迁移),不再转人工 + const blocked = await this.workUnitService.blockForAllUnfit(wuId, blockReason); + if (!blocked) return; + logger.warn(`[AgentLoop] 全员不适任,WU 转 blocked 待人工`, { wuId, blockReason }); + await this.postToDiscussionSpace( + wuId, + withBlockedCta('频道内无人能接此任务,已转 blocked 等待人工介入', blockReason), + blocked, + ); + } catch (err) { + logger.warn(`[AgentLoop] blockIfAllMembersUnfit failed (non-blocking): ${getErrorMessage(err)}`, { wuId }); + } + } + /** Stop the agent loop and clean up */ stop(): void { this.alive = false; @@ -781,6 +865,9 @@ export class AgentLoop { if (parseExcludeAssignee(s.metadata) === this.role.id) return false; // #109(M4 接单过滤):metadata.blockedBy 中有未 done 的 WU → 对所有 loop 不可见 if (hasUnfinishedDeps(s.metadata, statusById)) return false; + // 决策 14 认领前适任判断:本角色已被判不适任(metadata.unfitRoles 含本 role.id)→ 不可见。 + // 约束挂 WU 不挂角色身份(合规 ADR D2/决策 10),同 excludeAssignee 口径 + if (isRoleUnfit(s.metadata, this.role.id)) return false; return true; }).sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) .slice(0, 5) @@ -1623,7 +1710,7 @@ export class AgentLoop { // P0 修复 6: traceId(与 agentStep 同一来源,供日志行携带) const traceId = typeof metadata.traceId === 'string' && metadata.traceId ? metadata.traceId : undefined; - // 收口守卫链(§10.5 提交守卫 → §6-2 子任务守卫 → B3b-i 自动验证守卫)已抽到 + // 收口守卫链(§10.5 提交守卫 → §6-2 子任务守卫 → 产出实双闸(空 diff / 契约产物)→ B3b-i 自动验证守卫)已抽到 // ./completion-gates.js(行为一字不改,含守卫顺序/hint 写法/l1 台账/合并视图口径)—— // recordResult 只保留编排:构建合并视图(上方)→ 跑守卫 → delegate/新鲜度/强制收口 → // 单次原子写 → 状态迁移与频道通知。git/子任务查询经 deps 注入(loop 绑定的两个方法下传)。 @@ -1639,6 +1726,9 @@ export class AgentLoop { const noCommitNotice = guards.notices.noCommit; const verifyBlocked = guards.notices.verifyBlocked; const verifyPassNotice = guards.notices.verifyPassed; + // 收口闸(产出实):空 diff / 契约产物连续打回 ≥3 次 → blocked(模式同 verifyBlocked) + const diffEmptyBlocked = guards.notices.diffEmptyBlocked; + const contractArtifactBlocked = guards.notices.contractArtifactBlocked; // F6-c:本 step COMPLETE 守卫是否已跑过验证 —— 下方步骤超限强制收口路径据此避免重复跑 const verifyGuardRan = guards.notices.verifyGuardRan; @@ -1754,6 +1844,10 @@ export class AgentLoop { const blockReasonUpdates: Partial = {}; if (verifyBlocked) { blockReasonUpdates.blockReason = `verify-failed x${guardUpdates.verifyFailCount}: 自动验证连续失败`; + } else if (diffEmptyBlocked) { + blockReasonUpdates.blockReason = `diff-empty x${guardUpdates.diffEmptyCount}: 报告完成但无提交内容`; + } else if (contractArtifactBlocked) { + blockReasonUpdates.blockReason = `contract-artifact x${guardUpdates.contractArtifactCount}: 契约产物连续缺失`; } else if (consecutiveStuck >= 3) { blockReasonUpdates.blockReason = action === 'failed' && result.summary ? `stuck: 连续 3 步无进展(${result.summary.slice(0, 200)})` @@ -1847,6 +1941,40 @@ export class AgentLoop { return; } + // 收口闸(产出实):空 diff / 契约产物连续打回 ≥3 次 → blocked 并频道说明(模式同 verifyBlocked) + const outputGateBlocked = diffEmptyBlocked + ? { + failureType: 'diff_empty', + message: `连续 ${guardUpdates.diffEmptyCount} 次报告完成却没有任何提交内容,任务已转 blocked,等待人类介入`, + } + : contractArtifactBlocked + ? { + failureType: 'contract_artifact', + message: `契约产物连续缺失 ${guardUpdates.contractArtifactCount} 次,任务已转 blocked,等待人类介入。缺失产物与提示已记录到任务上下文`, + } + : null; + if (outputGateBlocked) { + // #178(#63 决议 2):状态迁移前 fencing,易主即静默退出 + if (wu.status !== 'blocked' && !(await this.transitionIfHeld(wuId, 'blocked'))) return; + // #172(#60 决策 Q1):WU 级终态失败事件落盘(level=warning,#62 失败趋势探测数据源) + void emitWorkUnitFailedEvent({ + workUnitId: wuId, + failureType: outputGateBlocked.failureType, + blockReason: String(blockReasonUpdates.blockReason ?? ''), + consecutiveStuck, + attempts: stepCount, + totalDurationMs: Math.max(0, Date.now() - new Date(wu.createdAt).getTime()), + traceId, + }).catch(() => {}); + // #176(决策 #57 D3-1):blocked 里程碑统一携带 CTA 行动召唤块 + await this.postToDiscussionSpace( + wuId, + withBlockedCta(outputGateBlocked.message, String(blockReasonUpdates.blockReason ?? '')), + wu, + ); + return; + } + // B3b-i: 验证全绿 → 频道简报(跑了哪几条;仅当 COMPLETE 未被其他守卫拦截) if (verifyPassNotice && action === 'complete') { await this.postToDiscussionSpace(wuId, verifyPassNotice); diff --git a/apps/api/src/modules/agents/loop/claim-fitness.ts b/apps/api/src/modules/agents/loop/claim-fitness.ts new file mode 100644 index 000000000..4cd2260fc --- /dev/null +++ b/apps/api/src/modules/agents/loop/claim-fitness.ts @@ -0,0 +1,128 @@ +/** + * 认领前适任判断(决策 14,docs/adr/2026-08-25-review-independence-trust-model.md 补充段)。 + * + * 问题:频道角色认领是「代码代抢」——observe(零 LLM)过滤 → resolveTarget(纯代码排序) + * → claim 抢占,agent 第一次看到 scope 全文是认领后的第一步 prompt,错误认领是结构性必然。 + * 本模块在 resolveTarget 选中 unassigned 候选之后、claimAndAnnounce 之前插一道一次性 LLM + * 判断:「能否胜任、是否属于你」。只在角色空闲且即将认领时触发(成本与认领次数成正比), + * 不在 observe 15s 轮询里加 LLM。 + * + * 治理约束(ADR D2/决策 10):约束挂 WU 不挂角色身份——不适任结果落档 WU 侧 + * metadata.unfitRoles 名单({roleId, reason, at}),observe 过滤链据此排除本角色; + * 不恢复 acceptedTypes 静态类型过滤(那是治理变更)。 + * + * 从宽哲学:判断调用失败/超时/解析不出 → 视为适任(宁可误抢不可漏抢,错抢还有 + * NEED_INPUT 甩人兜底)。落档失败同口径从宽放行。 + * + * LLM 调用复用 system-executor(轻量一次性 spawn,无 worktree,与 distill / + * wu-completion-extraction 同款机制),经 getSystemExecutor() 懒单例;测试注入 run 替代。 + */ +import type { AgentProfileData } from '@dommaker/studio-shared'; +import type { WorkUnitData, WorkUnitMetadata } from '../../workunit/workunit.service.js'; +import type { UnfitRoleEntry } from '../../workunit/workunit.types.js'; +import { getSystemExecutor } from '../system-executor.js'; + +/** 适任判断输入的 scope 截断上限(字符)——轻量 prompt,控制单次调用规模 */ +export const FITNESS_SCOPE_MAX_CHARS = 2_000; +/** 不适任理由截断上限(字符)——落档 unfitRoles.reason 与频道消息共用 */ +export const FITNESS_REASON_MAX_CHARS = 120; + +export interface FitnessVerdict { + fit: boolean; + reason: string; +} + +/** 一次性 LLM 调用签名(SystemExecutor.run 的子集;测试注入替代真实 CLI spawn) */ +export type FitnessRunner = ( + prompt: string, + options?: { systemPrompt?: string; eventSource?: string }, +) => Promise<{ output: string }>; + +/** + * 适任判断 prompt(单一来源):候选 WU 的 type + scope 摘要 + role 的 + * persona/description/acceptedTypes,一次性问「能否胜任、是否属于你」。 + * acceptedTypes 在此仅作判断素材(推断),不做静态过滤(决策 10 红线不碰)。 + */ +export function buildClaimFitnessPrompt( + wu: Pick, + role: AgentProfileData, +): string { + const persona = (role.persona ?? role.description ?? '').trim(); + const accepted = (role.acceptedTypes ?? []).join('、') || '(未声明)'; + const scope = (wu.scope ?? '').length > FITNESS_SCOPE_MAX_CHARS + ? `${wu.scope.slice(0, FITNESS_SCOPE_MAX_CHARS)}…[truncated]` + : (wu.scope ?? ''); + return [ + `你是角色「${role.name}」。`, + persona ? `角色自述:${persona}` : null, + `角色声明的职能域:${accepted}`, + '', + `待认领工单(类型 ${wu.type}):`, + scope, + ].filter(line => line !== null).join('\n'); +} + +/** 判断系统提示词:输出协议唯一出处(parseFitnessVerdict 据此解析) */ +export const CLAIM_FITNESS_SYSTEM_PROMPT = `你是认领前适任判断器。根据角色自述与职能域,判断该角色能否胜任、该工单是否属于它。 +拿不准一律视为适任(误抢的代价远小于漏抢)。 +只输出一行,协议:FIT: yes|no: 一行理由(不超过 80 字)。不要输出任何其他内容。`; + +/** + * 解析判断输出。容错链:claude --output-format json 信封({type:'result',result:string}) + * 先拆封取模型文本 → 逐行找 `FIT: yes|no` → 理由取同行余下部分(截断)。 + * 解析不出 → 从宽 { fit: true }。 + */ +export function parseFitnessVerdict(output: string): FitnessVerdict { + let text = output; + try { + const parsed: unknown = JSON.parse(output); + const result = (parsed as { result?: unknown } | null)?.result; + if (typeof result === 'string') text = result; + } catch { /* 非 JSON 信封 → 按裸文本扫 */ } + for (const line of text.split('\n')) { + const m = line.match(/^\s*FIT:\s*(yes|no)\b\s*:?\s*(.*)$/i); + if (!m) continue; + const reason = (m[2] ?? '').trim().slice(0, FITNESS_REASON_MAX_CHARS); + return { fit: m[1].toLowerCase() === 'yes', reason }; + } + return { fit: true, reason: '' }; +} + +/** + * 一次性适任判断。任何失败(spawn 失败/超时/未配置 studio provider)→ 从宽适任。 + * eventSource='claim-fitness' 走 system:tokens 记账(默认 30s 超时,轻量 prompt 够用)。 + */ +export async function judgeClaimFitness( + wu: Pick, + role: AgentProfileData, + run?: FitnessRunner, +): Promise { + const call: FitnessRunner = run ?? ((prompt, options) => getSystemExecutor().run(prompt, options)); + try { + const result = await call(buildClaimFitnessPrompt(wu, role), { + systemPrompt: CLAIM_FITNESS_SYSTEM_PROMPT, + eventSource: 'claim-fitness', + }); + return parseFitnessVerdict(result.output); + } catch { + return { fit: true, reason: '' }; // 从宽:判断调用失败视为适任(NEED_INPUT 兜底错抢) + } +} + +/** 安全解析 WU metadata.unfitRoles —— 缺失/损坏/非数组一律 [](不排除),仿 parseExcludeAssignee */ +export function parseUnfitRoles(metadata: WorkUnitMetadata | string | null | undefined): UnfitRoleEntry[] { + try { + const m = typeof metadata === 'string' ? JSON.parse(metadata) : metadata; + const v = (m as { unfitRoles?: unknown } | null)?.unfitRoles; + if (!Array.isArray(v)) return []; + return v.filter((e): e is UnfitRoleEntry => + !!e && typeof e === 'object' && typeof (e as UnfitRoleEntry).roleId === 'string'); + } catch { + return []; + } +} + +/** observe 第 7 道过滤判据:本角色是否已被判不适任 */ +export function isRoleUnfit(metadata: WorkUnitMetadata | string | null | undefined, roleId: string): boolean { + return parseUnfitRoles(metadata).some(e => e.roleId === roleId); +} diff --git a/apps/api/src/modules/agents/loop/completion-gates.ts b/apps/api/src/modules/agents/loop/completion-gates.ts index 8979afe2b..5ebf7ae14 100644 --- a/apps/api/src/modules/agents/loop/completion-gates.ts +++ b/apps/api/src/modules/agents/loop/completion-gates.ts @@ -1,6 +1,7 @@ /** * 收口守卫链(2026-08 从 agent-loop.recordResult 抽出,行为一字不改): - * recordResult 的 COMPLETE 收口判定 —— §10.5 提交守卫 → §6-2 子任务守卫 → B3b-i 自动验证守卫 + * recordResult 的 COMPLETE 收口判定 —— §10.5 提交守卫 → §6-2 子任务守卫 → 产出实双闸 + * (闸 1 代码类 diff 非空 / 闸 2 非代码类契约产物)→ B3b-i 自动验证守卫 * → T7-E2 软观测段(#161,只观测不拦截:checker:soft_check 台账 + processCheckHint)。 * 顺序即优先级:前面的守卫把 action 降级为 progress 后,后面的 COMPLETE 守卫自然不再触发。 * @@ -15,7 +16,7 @@ */ import { execFileSync, execSync } from 'child_process'; -import { existsSync, readFileSync } from 'fs'; +import { existsSync, readFileSync, readdirSync, statSync } from 'fs'; import { join } from 'path'; import yaml from 'js-yaml'; import { logger, withAttestation } from '@dommaker/studio-shared'; @@ -54,6 +55,71 @@ export function readHeadHash(cwd: string): string | null { } } +/** 收口闸 1(产出实): git rev-list --count ..HEAD —— 提交守卫已保证无未提交改动, + * 这里判「有没有提交内容」。git 失败返回 null —— 静默跳过(基础设施故障不阻断完成)。 */ +export function countBranchCommits(cwd: string, baseBranch: string): number | null { + try { + const out = execFileSync('git', ['rev-list', '--count', `${baseBranch}..HEAD`], { + cwd, timeout: 5000, encoding: 'utf-8', + }).trim(); + const n = Number.parseInt(out, 10); + return Number.isFinite(n) ? n : null; + } catch { + return null; + } +} + +/** 收口闸 2(analysis):契约报告落盘检查 —— /.studio/research/ 下存在非空文件 + * (inspection 巡检变体只认 inspection-*.md)。目录不存在/读取失败 = 无产物(false)。 */ +export function hasAnalysisReport(workspaceRoot: string, inspection: boolean): boolean { + try { + const dir = join(workspaceRoot, '.studio', 'research'); + for (const name of readdirSync(dir)) { + if (inspection && !/^inspection-.+\.md$/.test(name)) continue; + const st = statSync(join(dir, name)); + if (st.isFile() && st.size > 0) return true; + } + return false; + } catch { + return false; + } +} + +/** 闸 2 锚点分派(按 wu.type):返回缺失产物的人话描述;null = 产物在或本类型不进闸。 + * decision 的 `## 结论摘要` 解析落档已由 #463 提供(agent-loop → metadata.decisionSuggestion), + * 本闸只验非空,不重复解析。 */ +function missingContractArtifact( + wu: WorkUnitData, + metadata: WorkUnitMetadata, + reportExists: (workspaceRoot: string, inspection: boolean) => boolean, +): string | null { + switch (wu.type) { + case 'review': + return metadata.reviewReport ? null : '评审结论(REVIEW_RESULT 协议输出)'; + case 'plan': + return Array.isArray(metadata.analysisTasks) && metadata.analysisTasks.length > 0 + ? null : '任务拆分清单(TASK: 协议行)'; + case 'spec': + return Array.isArray(metadata.specTasks) && metadata.specTasks.length > 0 + ? null : '物化任务清单(TASK: 协议行)'; + case 'decision': + return typeof metadata.decisionSuggestion === 'string' && metadata.decisionSuggestion.trim().length > 0 + ? null : '结论摘要(## 结论摘要 段)'; + case 'analysis': { + if (metadata.prototype === true) return null; // 原型单契约是 prototype/ 分支代码,豁免本闸 + const root = typeof metadata.workspaceRoot === 'string' && metadata.workspaceRoot.length > 0 + ? metadata.workspaceRoot : null; + if (!root) return null; // 无落盘根无法校验 → 静默跳过 + const inspection = metadata.inspection === true; + return reportExists(root, inspection) + ? null + : inspection ? '巡检报告(.studio/research/inspection-<日期>.md)' : '调研报告(.studio/research/)'; + } + default: + return null; // 代码类由闸 1 覆盖,其余类型无契约产物锚点 + } +} + /** 守卫链输入。metadata 必须是「持久化 + 本 step metadataUpdates」的合并视图(调用方构建): * 首个 step 的 worktreePath 等字段由 agentStep 经 result.metadataUpdates 传入、此刻尚未落库; * 只看持久化值会让首 step 的 COMPLETE 退到主仓库(干净)做检查而漏拦。 */ @@ -74,6 +140,10 @@ export interface CompletionGuardDeps { listUnfinishedChildren: (wuId: string) => Promise; hasUncommittedChanges?: (cwd: string) => boolean; readHeadHash?: (cwd: string) => string | null; + /** 收口闸 1: git rev-list --count ..HEAD(默认 execFileSync;失败返回 null = 静默跳过) */ + countBranchCommits?: (cwd: string, baseBranch: string) => number | null; + /** 收口闸 2: analysis 契约报告落盘检查(默认 fs 实现;inspection 变体只认 inspection-*.md) */ + analysisReportExists?: (workspaceRoot: string, inspection: boolean) => boolean; runVerification?: (wu: WorkUnitData, metadata: WorkUnitMetadata, worktreePath: string) => Promise; /** T7-E2(#161): harness 三纯函数(默认 = @dommaker/harness 静态导入,#425 去镜像; * 返回 null = 软观测段整体 fail-open 跳过) */ @@ -95,6 +165,10 @@ export interface CompletionGuardNotices { verifyPassed: string | null; /** B3b-i: verifyFailCount ≥3 → blocked 并频道说明 */ verifyBlocked: boolean; + /** 收口闸 1: diffEmptyCount ≥3 → blocked 并频道说明 */ + diffEmptyBlocked: boolean; + /** 收口闸 2: contractArtifactCount ≥3 → blocked 并频道说明 */ + contractArtifactBlocked: boolean; /** F6-c:本 step COMPLETE 守卫是否已跑过验证 —— 步骤超限强制收口路径据此避免重复跑 */ verifyGuardRan: boolean; } @@ -339,15 +413,19 @@ async function runSoftObservation( } /** - * 依次跑收口守卫(顺序即优先级,前三张任一降级后后续 COMPLETE 守卫不再触发): + * 依次跑收口守卫(顺序即优先级,前面的守卫任一降级后后续 COMPLETE 守卫不再触发): * 1. §10.5 提交守卫:COMPLETE + 未提交改动 → 降级 progress + commitGuardHint; * PROGRESS 无提交监视(lastCommitHash/noCommitSteps,≥3 → noCommit notice + 归零)。 * review WU 整体豁免(评审职责是读不是写);路径解析/git 失败一律静默跳过。 * 2. §6-2 子任务守卫:存在未完结子 WU → 降级 progress + childGuardHint。 - * 3. B3b-i 自动验证守卫:代码类 WU 有 worktreePath 才跑(runWuVerification); + * 3. 收口闸 1(产出实):代码类 COMPLETE 但 base..HEAD 无提交内容 → 降级 progress + diffEmptyHint, + * diffEmptyCount ≥3 → diffEmptyBlocked;有提交 → 计数归零。 + * 4. 收口闸 2(产出实):非代码类契约产物锚点缺失 → 降级 progress + contractArtifactHint, + * contractArtifactCount ≥3 → contractArtifactBlocked。 + * 5. B3b-i 自动验证守卫:代码类 WU 有 worktreePath 才跑(runWuVerification); * 失败 → verifyFailCount++/verifyFailHint/l1 rejected 台账/降级,≥3 → verifyBlocked; * 全绿 → verifyReport + l1 approved 台账 + verifyPassed 简报。 - * 4. T7-E2 软观测段:仅 action 仍为 complete 才跑;不降级不阻断,违规落台账 + + * 6. T7-E2 软观测段:仅 action 仍为 complete 才跑;不降级不阻断,违规落台账 + * processCheckHint(详见 runSoftObservation)。 */ export async function runCompletionGuards( @@ -365,6 +443,8 @@ export async function runCompletionGuards( noCommit: false, verifyPassed: null, verifyBlocked: false, + diffEmptyBlocked: false, + contractArtifactBlocked: false, verifyGuardRan: false, }; @@ -413,6 +493,56 @@ export async function runCompletionGuards( } } + // 收口闸 1(产出实):代码类 WU 声称完成但 base..HEAD 无任何提交内容 → 降级 progress + diffEmptyHint。 + // 提交守卫已保证工作区干净,这里判「有没有提交内容」——空 diff 不许进 in_review。 + // review 不在 CODE_WORKTREE_TYPES(diff-only 不改代码),天然不覆盖; + // 缺 worktreeBaseBranch / git 失败 → 静默跳过(基础设施故障不阻断完成)。 + // diffEmptyCount ≥3 → blocked(模式同 verifyFailCount)。 + if (action === 'complete' + && CODE_WORKTREE_TYPES.has(wu.type) + && typeof metadata.worktreePath === 'string' && metadata.worktreePath.length > 0 + && typeof metadata.worktreeBaseBranch === 'string' && metadata.worktreeBaseBranch.length > 0) { + const countCommits = deps.countBranchCommits ?? countBranchCommits; + let commitCount: number | null = null; + try { + commitCount = countCommits(metadata.worktreePath, metadata.worktreeBaseBranch); + } catch { + commitCount = null; + } + if (commitCount === 0) { + const failCount = (metadata.diffEmptyCount ?? 0) + 1; + guardUpdates.diffEmptyCount = failCount; + guardUpdates.diffEmptyHint = `你报告了完成,但相对 ${metadata.worktreeBaseBranch} 没有任何提交内容(第 ${failCount} 次)。请实现并 commit,或说明为何无需改动`; + action = 'progress'; + notices.diffEmptyBlocked = failCount >= 3; + logger.info(`[AgentLoop] Diff guard: COMPLETE downgraded for ${wuId} (no commits since ${metadata.worktreeBaseBranch}, count ${failCount})`); + } else if (commitCount !== null && (metadata.diffEmptyCount ?? 0) > 0) { + guardUpdates.diffEmptyCount = 0; // 有提交内容 → 计数归零(与 verifyFailCount 同模式) + } + } + + // 收口闸 2(产出实):非代码类 WU 契约产物锚点缺失 → 降级 progress + contractArtifactHint。 + // 锚点分派见 missingContractArtifact(review→reviewReport、plan→analysisTasks、spec→specTasks、 + // decision→decisionSuggestion、analysis→调研报告落盘);代码类不进本闸(由闸 1 覆盖)。 + // contractArtifactCount ≥3 → blocked(模式同 verifyFailCount)。 + if (action === 'complete') { + const reportExists = deps.analysisReportExists ?? hasAnalysisReport; + let missingAnchor: string | null = null; + try { + missingAnchor = missingContractArtifact(wu, metadata, reportExists); + } catch { + missingAnchor = null; // 检查故障静默跳过,绝不因基础设施故障阻断完成 + } + if (missingAnchor) { + const failCount = (metadata.contractArtifactCount ?? 0) + 1; + guardUpdates.contractArtifactCount = failCount; + guardUpdates.contractArtifactHint = `契约产物缺失:${missingAnchor}(第 ${failCount} 次)。请补齐产物后再报告完成`; + action = 'progress'; + notices.contractArtifactBlocked = failCount >= 3; + logger.info(`[AgentLoop] Contract-artifact guard: COMPLETE downgraded for ${wuId} (missing: ${missingAnchor}, count ${failCount})`); + } + } + // B3b-i(决策 D3 前半): COMPLETE 前自动验证 —— 仅代码类 WU(有专属 worktree)。 // 提交守卫/子任务守卫已通过(action 仍为 complete)才跑;命令解析:覆盖 > 约定(见 resolveVerifyCommands)。 // 全绿 → verifyReport 落档 + 频道简报;任一失败 → 降级 progress,失败命令+输出尾部注入下一轮 prompt, diff --git a/apps/api/src/modules/agents/loop/prompt-composer.ts b/apps/api/src/modules/agents/loop/prompt-composer.ts index 5c81e6827..9b9432ae3 100644 --- a/apps/api/src/modules/agents/loop/prompt-composer.ts +++ b/apps/api/src/modules/agents/loop/prompt-composer.ts @@ -74,17 +74,22 @@ export const WAITING_QUESTION_REPLAY_MAX_CHARS = 300; * + bug 路由规则与升级触发器(#121);bug → 复现测试先行 + 防回归测试随修复同 commit(#121)。 * #163(T8-E2):analysis + metadata.inspection===true → 巡检契约(INSPECTION_CONTRACT, * 优先级高于 analysis 通用模板)。 - * 未列出的 type(task/feature/spec 等)→ 空段(不注入)。 + * 各类型契约段尾附「本环节标准打法」指引行(implement→tdd-implement、bug→diagnosing-bugs、 + * plan→requirement-clarify + to-tickets、analysis→research、review→code-review), + * 指向 skills 段索引按需 loadSkill 读全文;decision/spec/巡检变体无对应打法 skill,不加。 + * 未列出的 type(task/feature 等)→ 空段(不注入)。 */ export const CONTRACT_TEMPLATES: Record = { review: [ '完成审查后,除 ACTION 行外,还必须在输出的最后一行给出结构化结论:', 'REVIEW_RESULT: {"verdict":"pass"|"reject"|"needs-info","summary":"一句话结论","issues":[{"severity":"error"|"warn"|"info","message":"问题描述"}]}', '(verdict=pass 通过 / reject 打回 / needs-info 上下文不足转人工;缺少该行将转人工评审。)', + '本环节标准打法:code-review(索引见 skills 段,先 loadSkill 读全文再开工)。', ].join('\n'), implement: [ '测试先行:先写失败测试(RED)再实现到测试全绿(GREEN),测试全绿后才算完成。', 'Phase commit:按阶段分批提交,commit message 用 `phase(<阶段名>): <摘要>` 格式。', + '本环节标准打法:tdd-implement(索引见 skills 段,先 loadSkill 读全文再开工)。', ].join('\n'), decision: [ '结论摘要格式:输出末段 `## 结论摘要`,用一句话给出待决问题的结论与理由。', @@ -111,6 +116,7 @@ export const CONTRACT_TEMPLATES: Record = { ' TASK: <任务描述>(3~8 条,可被独立认领完成;人工确认后按清单自动派工)', ' FOG: <待决问题>(探路型才输出,至多 12 条;确认后入探路台账,不再单独立决策单)', ' DESTINATION: <一句话目标>(可选,缺省用项目标题)', + '本环节标准打法:requirement-clarify + to-tickets(索引见 skills 段,先 loadSkill 读全文再开工)。', ].join('\n'), analysis: [ '方法论二选一(详见 skills 段 research / prototype 全文):', @@ -118,11 +124,13 @@ export const CONTRACT_TEMPLATES: Record = { 'prototype → 一次性代码落 prototype/ 分支(不合并、不进评审),结论(回答了什么问题)记录回工单。', 'bug 路由(#121):bug 默认快速路(不开图不写成文单),落任务单走「诊断→复现→修复→防回归」(diagnosing-bugs)。', '升级触发器:根因在需求/设计层 → 转决策单或开图;诊断事实(复现命令/已排除假设/定位结论)随票携带。', + '本环节标准打法:research(索引见 skills 段,先 loadSkill 读全文再开工)。', ].join('\n'), // #121:bug 类契约段模板——复现测试先行(FAIL 复现 bug → 修复 → GREEN),防回归测试随修复同 commit bug: [ '复现测试先行:先写 FAIL 的复现测试(断言报告的症状,确认变红),再修复到 GREEN。', '防回归测试随修复同 commit:复现测试最小化后即为防回归测试,与修复代码同一个 commit 提交。', + '本环节标准打法:diagnosing-bugs(索引见 skills 段,先 loadSkill 读全文再开工)。', ].join('\n'), }; @@ -202,7 +210,7 @@ export interface ComposedStepPrompt { /** * 组装本 step 的 prompt 与注入上下文: - * 1. hint 读取(pendingReplies / commitGuardHint / verifyFailHint / childGuardHint / processCheckHint,注入后即消费); + * 1. hint 读取(pendingReplies / commitGuardHint / verifyFailHint / childGuardHint / processCheckHint / diffEmptyHint / contractArtifactHint,注入后即消费); * 2. base prompt:pendingReplies > target.newReplies > continue; * 3. 三类 guard hint 段注入; * 4. 注入段分段软定额 + 池内余量共享(#91),稳定前缀序 persona > roster > skills > map > memory > knowledge > files(#285), @@ -241,6 +249,16 @@ export async function composeStepPrompt( ? metadata.processCheckHint : null; + // 收口闸 1(产出实):上一轮 COMPLETE 因空 diff(base..HEAD 无提交内容)被打回时的提示(注入后即消费) + const diffEmptyHint = typeof metadata.diffEmptyHint === 'string' && metadata.diffEmptyHint.length > 0 + ? metadata.diffEmptyHint + : null; + + // 收口闸 2(产出实):上一轮 COMPLETE 因契约产物缺失被打回时的提示(注入后即消费) + const contractArtifactHint = typeof metadata.contractArtifactHint === 'string' && metadata.contractArtifactHint.length > 0 + ? metadata.contractArtifactHint + : null; + // #95: 仅新会话回放 waitingQuestion(截 300 字符)并入人类回复段——断链后 agent 忘了自己提过什么问题 const replyTexts = pendingReplies.length > 0 && ctx.isNewSession === true @@ -259,6 +277,8 @@ export async function composeStepPrompt( verifyFailHint ? `## 验证失败\n\n${verifyFailHint}` : null, childGuardHint ? `## 子任务提醒\n\n${childGuardHint}` : null, processCheckHint ? `## 过程检查提醒\n\n${processCheckHint}` : null, + diffEmptyHint ? `## 提交内容提醒\n\n${diffEmptyHint}` : null, + contractArtifactHint ? `## 契约产物提醒\n\n${contractArtifactHint}` : null, ].filter((s): s is string => s !== null).join('\n\n'); // #91: 分段软定额 + 池内余量共享 —— 段有效预算 = 定额 + 池;用量差额回流池中。 @@ -408,6 +428,14 @@ export async function composeStepPrompt( // T7-E2: 提示已注入 prompt,清除避免后续步骤重复注入 consumedHintUpdates.processCheckHint = undefined; } + if (diffEmptyHint) { + // 收口闸 1: 提示已注入 prompt,清除避免后续步骤重复注入 + consumedHintUpdates.diffEmptyHint = undefined; + } + if (contractArtifactHint) { + // 收口闸 2: 提示已注入 prompt,清除避免后续步骤重复注入 + consumedHintUpdates.contractArtifactHint = undefined; + } return { prompt, pendingReplies, knowledgeContext, skillMatched, injectedKnowledgeIds, consumedHintUpdates }; } diff --git a/apps/api/src/modules/agents/system-executor.ts b/apps/api/src/modules/agents/system-executor.ts index 65d2561a9..87c0f3aed 100644 --- a/apps/api/src/modules/agents/system-executor.ts +++ b/apps/api/src/modules/agents/system-executor.ts @@ -47,6 +47,8 @@ export const DEFAULT_TIMEOUT_BY_EVENT_SOURCE: Readonly> = 'knowledge-distill': 120_000, 'constraint-audit': 120_000, 'knowledge-maintenance': 120_000, + // 合并冲突 LLM 解:会话内含解冲突 + typecheck/test 验证命令(对齐 VERIFY_COMMAND_TIMEOUT_MS 单条 10min 量级) + 'merge-conflict-resolution': 600_000, }; export interface SystemExecutorResult { diff --git a/apps/api/src/modules/workunit/CONTEXT.md b/apps/api/src/modules/workunit/CONTEXT.md index d0f57a576..2596f27f3 100644 --- a/apps/api/src/modules/workunit/CONTEXT.md +++ b/apps/api/src/modules/workunit/CONTEXT.md @@ -20,7 +20,7 @@ WorkUnit 核心域: 任务单元 CRUD、认领与状态机; F5 双向沟通的 N - `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, 与原读失败语义一致)。 -- `merge-on-review-pass.ts` - 评审通过后自动合并: task/ --no-ff 合并回目标分支, 冲突 rebase 重试一次, 仍冲突置 blocked(转人工走 markMergeConflict); pmoBranch 走 PMO 分支交合 worktree(`/pmo-`)。 +- `merge-on-review-pass.ts` - 评审通过后自动合并: task/ --no-ff 合并回目标分支, 冲突 rebase 重试一次, 仍冲突**保留现场交 LLM 解**(system-executor 一次性会话 + resolving-merge-conflicts skill, 审计落 metadata.mergeResolution), LLM 失败/超时回退 abort+markMergeConflict 转人工; 合并成功后落 mergedAt 前在合并目标目录重跑 verify(runWuVerification), 红 → 同款 blocked 转人工(blockReason 前缀 merge-verify-failed); pmoBranch 走 PMO 分支交合 worktree(`/pmo-`)。 - `wu-metadata.ts` - metadata 访问器(零依赖叶子): parseWuMetadata(容错解析) / parseWuTitle(展示名 title??scope 唯一出口, #312 起 getAgentSummary 与 status_changed 负载共用) / clearSessionBookkeeping(16 字段会话簿记权威清单, review 子 WU 不继承) / mergedWuView(持久化 + metadataUpdates 合并视图)。 - `wu-dependencies.ts` - 接单依赖判定(零依赖叶子): parseBlockedBy / buildStatusById / hasUnfinishedDeps / resolveClaimable。 - `assignee-resolver.ts` - assigneeId 双语义批量解析器: buildAssigneeProfileResolver -> (assigneeId) => profileId | null。 @@ -43,9 +43,9 @@ WorkUnit 核心域: 任务单元 CRUD、认领与状态机; F5 双向沟通的 N - **开图/物化/收尾哨兵**: analysis/plan(#471) WU done 且 l3.summary 含 `FOG:`/`DESTINATION:` -> 初始化 PMO map 台账(#471 起不再逐条建 decision 单, fog[].wuId 恒 null; metadata 落 mapOpenedAt 哨兵); spec WU done 且 l3.summary 含 `TASK:|AC:|BLOCKEDBY:|LEG:` -> 批量建 task 单(specTasksSpawnedAt 哨兵; #463 起无 TASK 行不落哨兵 = 人审有意不物化, 补确认 l3 补写再发 status_changed 可再触发物化; #471 起只服务存量 spec 单); done 触发角色记忆提取(memoryExtractedAt 哨兵)。各哨兵写入走「写入前重读合并」防同事件互覆。 - **#463 确认表单结构化**: review-passed 端点收可选 `confirm` body(decision 结论 / spec 物化 tasks / analysis·plan destination+fog+tasks——#471 起 plan 与 analysis 同形同契约)——校验与序列化正本在 confirm-payload.ts(resolveReviewConfirm), 后端把表单数据序列化进 l3.summary(存储契约不变, 下游 parseSpecTasks/parseMapOpening/decision-resolution 原样消费), confirm 与裸 summary 并存时 confirm 优先; analysis/plan 的 tasks 经 reviewPassed options.analysisTasks 覆写 metadata.analysisTasks(人审剔除/行内编辑生效)。预填数据源 = agent COMPLETE 落档的 specTasks(spec 输出 TASK 物化行)/decisionSuggestion(decision 输出 `## 结论摘要` 段)——prompt 契约见 agents/loop/prompt-composer CONTRACT_TEMPLATES。 - **#467 裁决轮提交**: `POST /:id/ruling`(human-only)——plan 裁决轮挂起(blocked + metadata.planRulings 非空 + waitingReason='plan-ruling')时收 `items:[{question, action:accept|reopen, conclusion?}]`, 校验(validateRulingItems, 400)后交 pmo/plan-ruling.applyPlanRuling: 批量落探路台账 + 组合裁决结果文本复活同会话。409 = 非 blocked / 无待裁 rulings。 -- **并发写收口**: 全部快照写路径走 FileStore 锁内 commitSnapshot/commitRemoval(appendEvent + upsertSnapshot 同一把 workunits flock 成对, 删除落 closed+deleted 墓碑); metadata 增量写走 updateMetadata(id, mutator)(mutator 基于锁内最新 metadata, stepCount/consecutiveStuck 锁内重计, progressLog/pendingReplies 锁内尾部追加); createGuarded(input, guard) 支撑 review 建子 WU 锁内 check-then-create; 启动对账 reconcileIndex 接 apps/api index.ts。 +- **写单口与并发收口**: WU 全部写路径唯一出口 = WorkUnitService 语义方法(create/transitionStatus/update/delete/unclaim/claim + wu-closure 统一关闭 + updateMetadata 增量), 业务模块直调 FileStore 写原语即违规——docs/adr/2026-09-15-workunit-write-single-mouth.md(2026-09-15 定稿, #523 建单收口同源; 收口对象 = discord 按钮回调/monitor TTL/ops GC/trigger UPDATE 四绕过点, 实现随票落地后在此补记删除事件与 reason 口径)。锁内口径: 全部快照写路径走 FileStore 锁内 commitSnapshot/commitRemoval(appendEvent + upsertSnapshot 同一把 workunits flock 成对, 删除落 closed+deleted 墓碑, 墓碑事件行由 service.delete 单点构造调用方不自拼); metadata 增量写走 updateMetadata(id, mutator)(mutator 基于锁内最新 metadata, stepCount/consecutiveStuck 锁内重计, progressLog/pendingReplies 锁内尾部追加); createGuarded(input, guard) 支撑 review 建子 WU 锁内 check-then-create; 启动对账 reconcileIndex 接 apps/api index.ts。 - **创建期父戳继承(#402)**: create/createGuarded 对带 parentId 的建单, 显式 metadata 未带归因戳(pmoId ‖ legacy ownershipProjectId)时从父 WU 继承, legacy 归一为 canonical pmoId(解析走 requirements/wu-pmo-attribution.ts 零依赖叶子, 无环)。创建期一次性落档语义不变, 显式传入优先, 父缺失/无戳不动(无归属 WU 合法, 不伪造)。覆盖默认管线展开、MCP/DELEGATE 子单等一切带父建单路径, 新增派生入口无需各自传戳。 -- **metadata 字段承载**: workspaceRoot/ownershipSource/waitingReason(工程归属); worktreePath/worktreeBranch/worktreeBaseBranch/worktreeBaseRepo + verifyCommands/verifyReport/verifyFailCount/verifyFailHint(代码类 WU worktree 与自动验证); mergedAt/mergeCommit/mergeConflict/conflictFiles(自动合并); pmoId(canonical PMO 归因戳, 唯一直读 key; legacy `ownershipProjectId` 同位名仅读取侧兼容); pmoBranch(PMO 分支合并目标); blockedBy/ac(接单依赖与验收标准, 机制只存不解释); progressLog(前序进展, 环形保留最近 5 条); sessionSummary(溢出滚动摘要); defaultTaskAssigneeId(analysis 确认默认执行角色); manualRelease/manualReleaseReason(转人工留痕); analysisTasks/analysisTasksSpawned/analysisTasksSpawnedAt/analysisFog/analysisDestination/autoConfirmedBy/autoConfirmedAt(analysis 派生哨兵集; #471 起 plan 同字段同口径); tokenBudget(#162 WU 级 token 额度; #471 起 publish 对 plan 默认落 1M, env STUDIO_PLAN_TOKEN_BUDGET 覆盖)/planStepAllowance(#471 plan 步数续期授权额度); specTasks/decisionSuggestion(#463 确认弹窗结构化预填, agent COMPLETE 落档)。 +- **metadata 字段承载**: workspaceRoot/ownershipSource/waitingReason(工程归属); worktreePath/worktreeBranch/worktreeBaseBranch/worktreeBaseRepo + verifyCommands/verifyReport/verifyFailCount/verifyFailHint(代码类 WU worktree 与自动验证); mergedAt/mergeCommit/mergeConflict/conflictFiles/mergeResolution(自动合并 + LLM 解冲突审计); pmoId(canonical PMO 归因戳, 唯一直读 key; legacy `ownershipProjectId` 同位名仅读取侧兼容); pmoBranch(PMO 分支合并目标); blockedBy/ac(接单依赖与验收标准, 机制只存不解释); progressLog(前序进展, 环形保留最近 5 条); sessionSummary(溢出滚动摘要); defaultTaskAssigneeId(analysis 确认默认执行角色); manualRelease/manualReleaseReason(转人工留痕); analysisTasks/analysisTasksSpawned/analysisTasksSpawnedAt/analysisFog/analysisDestination/autoConfirmedBy/autoConfirmedAt(analysis 派生哨兵集; #471 起 plan 同字段同口径); tokenBudget(#162 WU 级 token 额度; #471 起 publish 对 plan 默认落 1M, env STUDIO_PLAN_TOKEN_BUDGET 覆盖)/planStepAllowance(#471 plan 步数续期授权额度); specTasks/decisionSuggestion(#463 确认弹窗结构化预填, agent COMPLETE 落档)。 - **鉴权**: 15 条写端点(CRUD/claim/unclaim/review/status/讨论区发消息/编辑消息/opportunities adopt·ignore/resume/close) = requireAuth()+requireNotGuest(); GET 只读保持大门层。review-passed/review-rejected/POST /:id/status 拒绝 authorType=agent(403, A2A §4.4: 验收权只在人; 服务层 transitionStatus 不限 agent 内部合法迁移)。authorType/agentName 是自声明身份(不作凭证, 已知局限)。 - **批量最近完成(#387)**: GET /last-done?assigneeIds= 每 assignee 最近一条 done/completed(completedAt ?? updatedAt 降序取首; lastDoneByAssignee @ workunit.service.ts 全量索引一次扫描, 顺带消掉旧「limit=20 先截后滤」漏单口径)。roster 空闲卡「最近完成」专用, 消逐实例 GET /?assigneeId= 的 N+1; 单次 id 上限 MAX_BATCH_IDS=100。GET 只读, 大门层鉴权不变。 - **未归属过滤(#428, #402 决策 4 补交)**: GET /?attributed=false 服务端过滤未归属 WU(口径 = 无 reqId 且归因戳 parseWuPmoId 解析为 null, 含 legacy ownershipProjectId 同位名); attributed=true 反向; 缺省/非法值不过滤。计数复用分页响应 total(过滤后计数), 无独立 count 端点; 前端 #405 消费。reqId 悬挂(指向不存在 REQ)暂按有 reqId 计为已归属, 如需改口径先回 #402 决策层。 diff --git a/apps/api/src/modules/workunit/__tests__/merge-on-review-pass.test.ts b/apps/api/src/modules/workunit/__tests__/merge-on-review-pass.test.ts index 5d35758c6..1aee43228 100644 --- a/apps/api/src/modules/workunit/__tests__/merge-on-review-pass.test.ts +++ b/apps/api/src/modules/workunit/__tests__/merge-on-review-pass.test.ts @@ -10,10 +10,12 @@ import { FileStore } from '@dommaker/studio-shared'; import { WorkUnitService, waitForReviewPassSettled, type WorkUnitMetadata, type WorkUnitData } from '../workunit.service.js'; import { mergeWorktreeBranchOnReviewPass, cleanupPrototypeWorktreeOnReviewPass } from '../merge-on-review-pass.js'; -const { mockExecSh, mockPostWuSystemMessage, mockResolvePmoProjectId } = vi.hoisted(() => ({ +const { mockExecSh, mockPostWuSystemMessage, mockResolvePmoProjectId, mockSystemExecRun, mockLoadSingleSkill } = vi.hoisted(() => ({ mockExecSh: vi.fn(), mockPostWuSystemMessage: vi.fn(), mockResolvePmoProjectId: vi.fn(), + mockSystemExecRun: vi.fn(), + mockLoadSingleSkill: vi.fn(), })); vi.mock('@dommaker/studio-shared/node', async (importOriginal) => { @@ -27,6 +29,17 @@ vi.mock('../../requirements/pmo-branch-resolver.js', () => ({ resolvePmoProjectIdForWU: mockResolvePmoProjectId, })); +// LLM 解冲突:spawn 复用 system-executor 一次性会话(mock 掉真实 LLM 调用) +vi.mock('../../agents/system-executor.js', () => ({ + getSystemExecutor: () => ({ run: mockSystemExecRun }), +})); + +// LLM 解冲突:skill 全文经 skillLoader.loadSingle 加载(mock 掉磁盘 seed 依赖; +// 全 mock 对齐 claim-skill-integration.test.ts 模式——本包无 src 别名,走 dist) +vi.mock('@dommaker/studio-skill', () => ({ + skillLoader: { loadSingle: mockLoadSingleSkill }, +})); + // wu-messenger 间谍包装:真实发送保留(消息断言不受影响),另断言委托参数(milestone 等) vi.mock('../wu-messenger.js', async (importOriginal) => { const orig = await importOriginal() as { postWuSystemMessage: (...args: unknown[]) => Promise }; @@ -91,6 +104,9 @@ beforeEach(async () => { return null; } }); + // LLM 解冲突默认实现:skill 可用 + spawn 成功(是否"解完"由各用例的 diff-filter=U mock 决定) + mockLoadSingleSkill.mockReturnValue({ id: 'resolving-merge-conflicts', name: 'resolving-merge-conflicts', prompt: 'SKILL 流程卡全文' }); + mockSystemExecRun.mockResolvedValue({ output: 'resolved; typecheck+test green', usage: undefined, durationMs: 1234 }); tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'merge-on-review-pass-')); fileStore = new FileStore(tmpDir); wuService = new WorkUnitService(fileStore); @@ -307,7 +323,7 @@ describe('B3b-ii: 评审通过后自动合并', () => { expect(meta.mergedAt).toBeDefined(); }); - it('冲突转人工(rebase 冲突):取冲突文件清单 → 清理现场 → WU blocked + 频道转人工', async () => { + it('冲突兜底转人工(rebase 冲突,LLM 解后仍有残留冲突):LLM 尝试过 → 清理现场 → WU blocked + 频道转人工', async () => { mockExecSh.mockImplementation(async (cmd: string) => { if (cmd.includes('merge --no-ff')) throw new Error('CONFLICT: content'); if (cmd.includes(`rebase '${BASE}'`)) throw new Error('rebase conflict'); @@ -321,8 +337,12 @@ describe('B3b-ii: 评审通过后自动合并', () => { const outcome = await mergeWorktreeBranchOnReviewPass(wuService, wu, fileStore); expect(outcome).toEqual({ attempted: true, merged: false, conflictFiles: ['src/a.ts', 'src/b.ts'] }); + // LLM 解冲突已尝试(spawn cwd = rebase 现场 worktree),但 mock 下冲突清单不变 → 判定未解完 → 兜底 + expect(mockLoadSingleSkill).toHaveBeenCalledWith('resolving-merge-conflicts'); + expect(mockSystemExecRun).toHaveBeenCalledTimes(1); + expect(mockSystemExecRun.mock.calls[0][1]).toEqual(expect.objectContaining({ cwd: WT, eventSource: 'merge-conflict-resolution' })); const cmds = calledCommands(); - // 现场清理:rebase --abort 执行过;不清理 worktree/分支(留人工) + // 现场清理:LLM 兜底后 rebase --abort 执行过;不清理 worktree/分支(留人工) expect(cmds.some(c => c.includes('rebase --abort'))).toBe(true); expect(cmds.some(c => c.includes('worktree remove'))).toBe(false); expect(cmds.some(c => c.includes('branch -d'))).toBe(false); @@ -333,6 +353,9 @@ describe('B3b-ii: 评审通过后自动合并', () => { expect(meta.mergeConflict).toBe(true); expect(meta.conflictFiles).toEqual(['src/a.ts', 'src/b.ts']); expect(meta.mergedAt).toBeUndefined(); + // LLM 解冲突审计落档:未解完(仍有残留冲突) + expect(meta.mergeResolution).toEqual(expect.objectContaining({ ok: false, state: 'rebase', cwd: WT })); + expect(meta.mergeResolution!.error).toContain('仍有 2 个冲突文件'); const msgs = await studioMessages(wu.id); expect(msgs).toHaveLength(1); @@ -347,7 +370,7 @@ describe('B3b-ii: 评审通过后自动合并', () => { ); }); - it('冲突转人工(rebase 成功但二次 merge 仍冲突):冲突文件取自 baseRepo 现场', async () => { + it('冲突兜底转人工(rebase 成功但二次 merge 仍冲突,LLM 解后仍有残留):冲突文件取自合并目标现场', async () => { let mergeAttempts = 0; mockExecSh.mockImplementation(async (cmd: string) => { if (cmd.includes('merge --no-ff')) { @@ -375,6 +398,199 @@ describe('B3b-ii: 评审通过后自动合并', () => { expect(msgs[0].content).toContain('src/x.ts'); }); + it('冲突保留现场交 LLM 解(rebase 冲突):不先 abort;LLM 解完 rebase → 重放 merge 成功 → verify 绿 → merged', async () => { + let mergeAttempts = 0; + let llmCalled = false; + mockSystemExecRun.mockImplementation(async () => { + llmCalled = true; + return { output: 'resolved src/a.ts src/b.ts; typecheck+test green', usage: undefined, durationMs: 5678 }; + }); + mockExecSh.mockImplementation(async (cmd: string) => { + if (cmd.includes('merge --no-ff')) { + mergeAttempts++; + if (mergeAttempts === 1) throw new Error('CONFLICT: content'); + return { stdout: '', stderr: '' }; // LLM 解完 rebase 后重放成功 + } + if (cmd.includes(`rebase '${BASE}'`)) throw new Error('rebase conflict'); + if (cmd.includes('diff --name-only --diff-filter=U')) { + // LLM 调用前:rebase 现场有冲突;LLM 调用后:已解清 + return { stdout: llmCalled ? '' : 'src/a.ts\nsrc/b.ts\n', stderr: '' }; + } + if (cmd.includes('rev-parse HEAD')) return { stdout: `${HEAD}\n`, stderr: '' }; + return { stdout: '', stderr: '' }; + }); + const wu = await createWu(worktreeMeta()); + + const outcome = await mergeWorktreeBranchOnReviewPass(wuService, wu, fileStore); + + expect(outcome).toEqual({ attempted: true, merged: true, mergeCommit: HEAD }); + expect(mergeAttempts).toBe(2); // 首次失败 + LLM 解完 rebase 后重放 + // spawn 一次性解冲突会话:skill 全文作 systemPrompt,cwd = rebase 现场(worktree) + expect(mockLoadSingleSkill).toHaveBeenCalledWith('resolving-merge-conflicts'); + expect(mockSystemExecRun).toHaveBeenCalledTimes(1); + const [prompt, spawnOpts] = mockSystemExecRun.mock.calls[0] as [string, Record]; + expect(spawnOpts).toEqual(expect.objectContaining({ + cwd: WT, + systemPrompt: 'SKILL 流程卡全文', + eventSource: 'merge-conflict-resolution', + })); + expect(prompt).toContain(`cwd: ${WT}`); + expect(prompt).toContain('rebase 进行中'); + expect(prompt).toContain(`目标分支: ${BASE}`); + expect(prompt).toContain(`来源分支: ${BRANCH}`); + expect(prompt).toContain('src/a.ts'); + const cmds = calledCommands(); + // 现场保留:rebase --abort 从未执行(LLM 解成功,无需兜底清理) + expect(cmds.some(c => c.includes('rebase --abort'))).toBe(false); + + const updated = await wuService.getById(wu.id); + expect(updated!.status).not.toBe('blocked'); + const meta = JSON.parse(updated!.metadata!) as WorkUnitMetadata; + expect(meta.mergedAt).toBeDefined(); + // LLM 解冲突审计落档:成功 + 输出摘要 + expect(meta.mergeResolution).toEqual(expect.objectContaining({ + ok: true, state: 'rebase', cwd: WT, durationMs: 5678, + conflictFiles: ['src/a.ts', 'src/b.ts'], + })); + expect(meta.mergeResolution!.summary).toContain('resolved src/a.ts'); + }); + + it('冲突保留现场交 LLM 解(二次 merge 冲突):LLM 在合并目标目录解 merge → merged', async () => { + let mergeAttempts = 0; + let llmCalled = false; + mockSystemExecRun.mockImplementation(async () => { + llmCalled = true; + return { output: 'merge committed', usage: undefined, durationMs: 100 }; + }); + mockExecSh.mockImplementation(async (cmd: string) => { + if (cmd.includes('merge --no-ff')) { + mergeAttempts++; + throw new Error('CONFLICT: content'); // 首次 + 二次均冲突;LLM 自己 commit merge(无第三次) + } + if (cmd.includes('diff --name-only --diff-filter=U')) { + return { stdout: llmCalled ? '' : 'src/x.ts\n', stderr: '' }; + } + if (cmd.includes('rev-parse HEAD')) return { stdout: `${HEAD}\n`, stderr: '' }; + return { stdout: '', stderr: '' }; + }); + const wu = await createWu(worktreeMeta()); + + const outcome = await mergeWorktreeBranchOnReviewPass(wuService, wu, fileStore); + + expect(outcome).toEqual({ attempted: true, merged: true, mergeCommit: HEAD }); + expect(mergeAttempts).toBe(2); // LLM 解 merge 现场后无重放(merge 由 LLM commit) + // spawn cwd = 合并目标目录(baseRepo),prompt 指明 merge 现场 + const [prompt, spawnOpts] = mockSystemExecRun.mock.calls[0] as [string, Record]; + expect(spawnOpts).toEqual(expect.objectContaining({ cwd: REPO })); + expect(prompt).toContain('merge 进行中'); + const cmds = calledCommands(); + // 现场保留:LLM 调用后不再有 merge --abort(仅 rebase 重试前清首次现场那一次) + expect(cmds.filter(c => c.includes('merge --abort'))).toHaveLength(1); + + const meta = JSON.parse((await wuService.getById(wu.id))!.metadata!) as WorkUnitMetadata; + expect(meta.mergedAt).toBeDefined(); + expect(meta.mergeResolution).toEqual(expect.objectContaining({ ok: true, state: 'merge', cwd: REPO })); + }); + + it('LLM 解冲突失败/超时 → 回退现有 abort + markMergeConflict 转人工(兜底不变)', async () => { + mockSystemExecRun.mockRejectedValue(new Error('LLM session timeout')); + mockExecSh.mockImplementation(async (cmd: string) => { + if (cmd.includes('merge --no-ff')) throw new Error('CONFLICT: content'); + if (cmd.includes(`rebase '${BASE}'`)) throw new Error('rebase conflict'); + if (cmd.includes('diff --name-only --diff-filter=U')) return { stdout: 'src/a.ts\n', stderr: '' }; + return { stdout: '', stderr: '' }; + }); + const wu = await createWu(worktreeMeta()); + + const outcome = await mergeWorktreeBranchOnReviewPass(wuService, wu, fileStore); + + expect(outcome).toEqual({ attempted: true, merged: false, conflictFiles: ['src/a.ts'] }); + const cmds = calledCommands(); + // 兜底清理:rebase --abort 执行过;worktree/分支保留 + expect(cmds.some(c => c.includes('rebase --abort'))).toBe(true); + expect(cmds.some(c => c.includes('worktree remove'))).toBe(false); + expect(cmds.some(c => c.includes('branch -d'))).toBe(false); + + const updated = await wuService.getById(wu.id); + expect(updated!.status).toBe('blocked'); + const meta = JSON.parse(updated!.metadata!) as WorkUnitMetadata; + expect(meta.mergeConflict).toBe(true); + expect(meta.mergedAt).toBeUndefined(); + expect(meta.mergeResolution).toEqual(expect.objectContaining({ ok: false, state: 'rebase' })); + expect(meta.mergeResolution!.error).toContain('LLM session timeout'); + const msgs = await studioMessages(wu.id); + expect(msgs.some(m => m.content.includes('转人工'))).toBe(true); + }); + + it('skill 不可用 → 不 spawn LLM,直接回退 abort + 转人工', async () => { + mockLoadSingleSkill.mockReturnValue(null); + mockExecSh.mockImplementation(async (cmd: string) => { + if (cmd.includes('merge --no-ff')) throw new Error('CONFLICT: content'); + if (cmd.includes(`rebase '${BASE}'`)) throw new Error('rebase conflict'); + if (cmd.includes('diff --name-only --diff-filter=U')) return { stdout: 'src/a.ts\n', stderr: '' }; + return { stdout: '', stderr: '' }; + }); + const wu = await createWu(worktreeMeta()); + + const outcome = await mergeWorktreeBranchOnReviewPass(wuService, wu, fileStore); + + expect(outcome.merged).toBe(false); + expect(mockSystemExecRun).not.toHaveBeenCalled(); + expect(calledCommands().some(c => c.includes('rebase --abort'))).toBe(true); + const meta = JSON.parse((await wuService.getById(wu.id))!.metadata!) as WorkUnitMetadata; + expect(meta.mergeResolution!.ok).toBe(false); + expect(meta.mergeResolution!.error).toContain('resolving-merge-conflicts 不可用'); + expect((await wuService.getById(wu.id))!.status).toBe('blocked'); + }); + + it('合并后 verify 红 → 转人工(merge-verify-failed):不删 worktree/分支、不落 mergedAt', async () => { + mockExecSh.mockImplementation(async (cmd: string) => { + if (cmd === 'pnpm run typecheck') { + throw Object.assign(new Error('exit 1'), { stderr: 'TS2322: type mismatch in merged code' }); + } + if (cmd.includes('rev-parse HEAD')) return { stdout: `${HEAD}\n`, stderr: '' }; + return { stdout: '', stderr: '' }; + }); + const wu = await createWu(worktreeMeta({ verifyCommands: ['pnpm run typecheck'] })); + + const outcome = await mergeWorktreeBranchOnReviewPass(wuService, wu, fileStore); + + expect(outcome).toEqual({ attempted: true, merged: false, conflictFiles: [], reason: 'merge-verify-failed' }); + const cmds = calledCommands(); + // merge 成功 + verify 在合并目标目录跑过;红 → 不删 worktree、不删分支 + expect(cmds.some(c => c.includes('merge --no-ff'))).toBe(true); + expect(mockExecSh).toHaveBeenCalledWith('pnpm run typecheck', expect.objectContaining({ cwd: REPO })); + expect(cmds.some(c => c.includes('worktree remove'))).toBe(false); + expect(cmds.some(c => c.includes('branch -d'))).toBe(false); + // 无冲突 → 不走 LLM 解冲突 + expect(mockSystemExecRun).not.toHaveBeenCalled(); + + const updated = await wuService.getById(wu.id); + expect(updated!.status).toBe('blocked'); + const meta = JSON.parse(updated!.metadata!) as WorkUnitMetadata; + expect(meta.mergedAt).toBeUndefined(); + expect(meta.blockReason).toContain('merge-verify-failed'); + expect(meta.blockReason).toContain('pnpm run typecheck'); + + const msgs = await studioMessages(wu.id); + expect(msgs).toHaveLength(1); + expect(msgs[0].content).toContain('合并后验证失败'); + expect(msgs[0].content).toContain('TS2322'); + }); + + it('合并后 verify:无冲突直合也重跑(合并目标目录),全绿 → merged', async () => { + const wu = await createWu(worktreeMeta({ verifyCommands: ['pnpm run test', 'pnpm run typecheck'] })); + + const outcome = await mergeWorktreeBranchOnReviewPass(wuService, wu, fileStore); + + expect(outcome).toEqual({ attempted: true, merged: true, mergeCommit: HEAD }); + // verify 命令在合并目标目录(非 worktree)依次跑过 + expect(mockExecSh).toHaveBeenCalledWith('pnpm run test', expect.objectContaining({ cwd: REPO })); + expect(mockExecSh).toHaveBeenCalledWith('pnpm run typecheck', expect.objectContaining({ cwd: REPO })); + const meta = JSON.parse((await wuService.getById(wu.id))!.metadata!) as WorkUnitMetadata; + expect(meta.mergedAt).toBeDefined(); + }); + it('防重:metadata.mergedAt 已存在 → 跳过,无任何 git 调用', async () => { const wu = await createWu(worktreeMeta({ mergedAt: new Date().toISOString(), mergeCommit: HEAD })); diff --git a/apps/api/src/modules/workunit/merge-on-review-pass.ts b/apps/api/src/modules/workunit/merge-on-review-pass.ts index 7d2974137..67f785dfa 100644 --- a/apps/api/src/modules/workunit/merge-on-review-pass.ts +++ b/apps/api/src/modules/workunit/merge-on-review-pass.ts @@ -15,12 +15,17 @@ * 流程: * 0. 数据防丢闸:worktree 有未提交改动(或 git status 失败)→ 不合并不强删, * WU 置 blocked + 频道列清单转人工(worktree/分支保留) - * 1. git merge --no-ff task/(baseRepo) - * 2. 失败 → merge --abort,重试一次:先在 worktree 把 task 分支 rebase 到 - * baseBranch,成功则回 baseRepo 再 merge - * 3. 仍失败 → 清理 rebase/merge 现场,取冲突文件清单(diff-filter=U), - * 频道发 Studio 系统消息转人工,WU 置 blocked(metadata.mergeConflict/conflictFiles) - * 4. 成功 → 移除 worktree、删除已合并 task 分支、metadata 记 mergedAt/mergeCommit, + * 1. git merge --no-ff task/(baseRepo 或 PMO 集成交合) + * 2. 失败 → abort 首次 merge 现场,重试一次:先在 worktree 把 task 分支 rebase 到 + * baseBranch,成功则回合并目标再 merge + * 3. 仍失败 → **保留冲突现场**(不再先 abort),spawn 一次性 LLM 会话解冲突 + * (复用 system-executor,skill = resolving-merge-conflicts;prompt 带 mergeContext + * + verify 命令来源说明);LLM 失败/超时/解完仍有残留冲突 → 回退现有 abort + + * markMergeConflict 转人工(兜底不变)。审计落 metadata.mergeResolution + * 4. 合并成功(含 LLM 解完)→ **落 mergedAt 前在合并目标目录重跑 verify** + * (runWuVerification;无 verify 命令可解析时放行,与执行环节同策略); + * 红 → 转人工(markMergeConflict 同款 blocked 路径,blockReason 前缀 merge-verify-failed) + * 5. 全绿 → 移除 worktree、删除已合并 task 分支、metadata 记 mergedAt/mergeCommit, * 频道发一条简短系统消息 * * 防重:metadata.mergedAt 存在即跳过(人工重复触发 / 事件重放均不再合并)。 @@ -38,7 +43,7 @@ import { execSh } from '@dommaker/studio-shared/node'; import { ensurePmoIntegrationWorktree } from '@dommaker/studio-agent'; import * as os from 'node:os'; import * as path from 'node:path'; -import type { WorkUnitService, WorkUnitData } from './workunit.service.js'; +import type { WorkUnitService, WorkUnitData, WorkUnitMetadata } from './workunit.service.js'; import { postWuSystemMessage } from './wu-messenger.js'; import { parseWuMetadata } from './wu-metadata.js'; import { getErrorMessage } from '../../utils/errors.js'; @@ -56,7 +61,19 @@ const GIT_OP_TIMEOUT_MS = 15_000; export type MergeOnReviewPassOutcome = | { attempted: false; reason: 'no-worktree' | 'already-merged' | 'analysis-bypass' } | { attempted: true; merged: true; mergeCommit: string } - | { attempted: true; merged: false; conflictFiles: string[]; reason?: 'conflict' | 'uncommitted-changes' }; + | { attempted: true; merged: false; conflictFiles: string[]; reason?: 'conflict' | 'uncommitted-changes' | 'merge-verify-failed' }; + +/** 冲突现场(保留给 LLM 解,不再先 abort):merge = 合并目标目录;rebase = worktree */ +interface ConflictScene { + cwd: string; + state: 'merge' | 'rebase'; +} + +/** LLM 解冲突审计(落 metadata.mergeResolution;类型正本在 workunit.types.ts) */ +export type MergeResolutionAudit = NonNullable; + +/** LLM 输出摘要截断(mergeResolution.summary) */ +const MERGE_LLM_SUMMARY_CHARS = 500; /** shell 单引号转义(branch/路径/提交信息统一过它进 bash -c) */ function shq(s: string): string { @@ -237,20 +254,41 @@ export async function mergeWorktreeBranchOnReviewPass( const merged = await tryMergeWithRebaseRetry(mergeContext.cmd, mergeContext.cwd, mergeContext.targetBranch, branch, worktreePath); // 注:本包 tsconfig 未开 strict,真值判断不收窄可辨识联合,须用 === false 字面量比较 if (merged.ok === false) { - // 转人工:WU 置 blocked + 频道系统消息(冲突文件清单) - await wuService.markMergeConflict(wu.id, merged.conflictFiles); - const fileList = merged.conflictFiles.length > 0 - ? `\n冲突文件:\n${merged.conflictFiles.map(f => `- ${f}`).join('\n')}` - : '(未能获取冲突文件清单)'; - await postSystemMessage( - fileStore, + // 冲突现场已保留(tryMergeWithRebaseRetry 不再先 abort)→ spawn 一次性 LLM 会话解冲突; + // LLM 失败/超时/解完仍有残留冲突 → 回退现有 abort + markMergeConflict 转人工(兜底不变) + const { audit, resolved } = await resolveConflictSceneWithLlm({ wu, - `任务「${title}」自动合并到 ${mergeContext.targetBranch} 失败(重试后仍冲突),已转人工处理${fileList}`, - ).catch(err => logger.warn('[MergeOnReviewPass] post conflict message failed', { wuId: wu.id, error: String(err) })); - logger.warn('[MergeOnReviewPass] merge conflict escalated to human', { - wuId: wu.id, branch, targetBranch: mergeContext.targetBranch, conflictFiles: merged.conflictFiles, + meta, + scene: merged.scene, + conflictFiles: merged.conflictFiles, + mergeCmd: mergeContext.cmd, + mergeCwd: mergeContext.cwd, + sourceBranch: branch, + targetBranch: mergeContext.targetBranch, + scopeSummary, + }); + await recordMergeResolution(wuService, wu.id, audit); + if (!resolved) { + await abortMergeAndRebase(mergeContext.cwd, merged.scene.state === 'rebase' ? worktreePath : undefined); + // 转人工:WU 置 blocked + 频道系统消息(冲突文件清单) + await wuService.markMergeConflict(wu.id, merged.conflictFiles); + const fileList = merged.conflictFiles.length > 0 + ? `\n冲突文件:\n${merged.conflictFiles.map(f => `- ${f}`).join('\n')}` + : '(未能获取冲突文件清单)'; + await postSystemMessage( + fileStore, + wu, + `任务「${title}」自动合并到 ${mergeContext.targetBranch} 失败(重试后仍冲突),已转人工处理${fileList}`, + ).catch(err => logger.warn('[MergeOnReviewPass] post conflict message failed', { wuId: wu.id, error: String(err) })); + logger.warn('[MergeOnReviewPass] merge conflict escalated to human (LLM resolution failed)', { + wuId: wu.id, branch, targetBranch: mergeContext.targetBranch, conflictFiles: merged.conflictFiles, + llmError: audit.error, + }); + return { attempted: true, merged: false, conflictFiles: merged.conflictFiles }; + } + logger.info('[MergeOnReviewPass] conflict resolved by LLM', { + wuId: wu.id, state: merged.scene.state, durationMs: audit.durationMs, }); - return { attempted: true, merged: false, conflictFiles: merged.conflictFiles }; } // 合并成功:记录 mergeCommit(PMO-b:读集成交合 HEAD)→ 清理 worktree/分支 → 落档 metadata → 频道通知 @@ -264,6 +302,27 @@ export async function mergeWorktreeBranchOnReviewPass( logger.warn('[MergeOnReviewPass] rev-parse HEAD failed (mergeCommit 落空字符串)', { wuId: wu.id, error: String(err) }); } + // 合并后 verify(落 mergedAt 前):在合并目标目录重跑——原 worktree 下方即被 remove, + // 绝不能跑那边。无 verify 命令可解析时放行(与执行环节同策略,ran 为空即无命令)。 + // 红 → 转人工(markMergeConflict 同款 blocked 路径,blockReason 前缀 merge-verify-failed), + // 不删 worktree/分支、不落 mergedAt(合并 commit 已入目标分支,留人工处置)。 + const verify = await runMergeVerification(wu, meta, mergeContext.cwd); + if (verify.failure) { + await wuService.markMergeConflict(wu.id, [], { + blockReason: `merge-verify-failed: 合并后验证失败(${verify.failure.command})`.slice(0, 300), + }); + await postSystemMessage( + fileStore, + wu, + `任务「${title}」已合并到 ${mergeContext.targetBranch},但合并后验证失败(${verify.failure.command}),已转人工处理\n失败输出尾部:\n${verify.failure.tail.slice(0, 1000)}`, + ).catch(err => logger.warn('[MergeOnReviewPass] post verify-failed message failed', { wuId: wu.id, error: String(err) })); + logger.warn('[MergeOnReviewPass] post-merge verification failed, escalated to human', { + wuId: wu.id, branch, targetBranch: mergeContext.targetBranch, + failedCommand: verify.failure.command, ran: verify.ran, + }); + return { attempted: true, merged: false, conflictFiles: [], reason: 'merge-verify-failed' }; + } + if (worktreePath) { try { await execSh(`git -C ${shq(baseRepo)} worktree remove --force ${shq(worktreePath)}`, { @@ -300,47 +359,180 @@ export async function mergeWorktreeBranchOnReviewPass( return { attempted: true, merged: true, mergeCommit }; } -/** merge 一次;失败则 abort 后在 worktree rebase 到 baseBranch,再 merge 一次 */ +/** + * merge 一次;失败则 abort 首次现场后在 worktree rebase 到 baseBranch,再 merge 一次。 + * 终态失败**保留冲突现场**(不 abort)交 LLM 解——scene 指明现场目录与 merge/rebase 状态; + * 兜底清理由调用方在 LLM 解失败后做(abortMergeAndRebase)。 + */ async function tryMergeWithRebaseRetry( mergeCmd: string, - baseRepo: string, + mergeCwd: string, baseBranch: string, branch: string, worktreePath?: string, -): Promise<{ ok: true } | { ok: false; conflictFiles: string[] }> { +): Promise<{ ok: true } | { ok: false; conflictFiles: string[]; scene: ConflictScene }> { try { - await execSh(mergeCmd, { cwd: baseRepo, timeoutMs: MERGE_TIMEOUT_MS }); + await execSh(mergeCmd, { cwd: mergeCwd, timeoutMs: MERGE_TIMEOUT_MS }); return { ok: true }; } catch { /* 进入重试 */ } - // 清理首次 merge 现场;无 worktree 无法 rebase → 直接按冲突转人工 - await abortMergeAndRebase(baseRepo); + // 无 worktree 无法 rebase:保留 merge 冲突现场(合并目标目录)交 LLM 解 if (!worktreePath) { - const conflictFiles = await listConflictFiles(baseRepo, baseRepo); - return { ok: false, conflictFiles }; + const conflictFiles = await listConflictFiles(mergeCwd, mergeCwd); + return { ok: false, conflictFiles, scene: { cwd: mergeCwd, state: 'merge' } }; } + // 清理首次 merge 现场(rebase 重试的前提;终态失败现场在下方保留) + await abortMergeAndRebase(mergeCwd); try { await execSh(`git -C ${shq(worktreePath)} rebase ${shq(baseBranch)}`, { - cwd: baseRepo, timeoutMs: REBASE_TIMEOUT_MS, + cwd: mergeCwd, timeoutMs: REBASE_TIMEOUT_MS, }); } catch { - // rebase 冲突:先取清单(abort 后现场消失),再清理 - const conflictFiles = await listConflictFiles(worktreePath, baseRepo); - await abortMergeAndRebase(baseRepo, worktreePath); - logger.warn('[MergeOnReviewPass] rebase onto base failed', { branch, baseBranch, conflictFiles }); - return { ok: false, conflictFiles }; + // rebase 冲突:取清单后保留现场(worktree)交 LLM 解,不再 abort + const conflictFiles = await listConflictFiles(worktreePath, mergeCwd); + logger.warn('[MergeOnReviewPass] rebase onto base failed (scene kept for LLM)', { branch, baseBranch, conflictFiles }); + return { ok: false, conflictFiles, scene: { cwd: worktreePath, state: 'rebase' } }; } try { - await execSh(mergeCmd, { cwd: baseRepo, timeoutMs: MERGE_TIMEOUT_MS }); + await execSh(mergeCmd, { cwd: mergeCwd, timeoutMs: MERGE_TIMEOUT_MS }); return { ok: true }; } catch { - const conflictFiles = await listConflictFiles(baseRepo, baseRepo); - await abortMergeAndRebase(baseRepo); - logger.warn('[MergeOnReviewPass] merge after rebase still failed', { branch, baseBranch, conflictFiles }); - return { ok: false, conflictFiles }; + // 二次 merge 冲突:取清单后保留现场(合并目标目录)交 LLM 解,不再 abort + const conflictFiles = await listConflictFiles(mergeCwd, mergeCwd); + logger.warn('[MergeOnReviewPass] merge after rebase still failed (scene kept for LLM)', { branch, baseBranch, conflictFiles }); + return { ok: false, conflictFiles, scene: { cwd: mergeCwd, state: 'merge' } }; + } +} + +/** + * 冲突现场交 LLM 解(对齐 resolving-merge-conflicts skill:保留双方意图、永不 --abort、 + * 解完跑项目验证命令全绿才算完成)。spawn 复用蒸馏管线同款 system-executor 一次性会话 + * (skill 全文作 systemPrompt,mergeContext + verify 命令来源作 prompt,cwd = 冲突现场)。 + * 确定性校验收尾(不信 LLM 自报):冲突标记清零才算解完;rebase 现场解完后重放 merge。 + */ +async function resolveConflictSceneWithLlm(args: { + wu: WorkUnitData; + meta: WorkUnitMetadata; + scene: ConflictScene; + conflictFiles: string[]; + mergeCmd: string; + mergeCwd: string; + sourceBranch: string; + targetBranch: string; + scopeSummary: string; +}): Promise<{ audit: MergeResolutionAudit; resolved: boolean }> { + const { wu, meta, scene, conflictFiles, mergeCmd, mergeCwd, sourceBranch, targetBranch, scopeSummary } = args; + const startMs = Date.now(); + const base = { + attemptedAt: new Date().toISOString(), + state: scene.state, + cwd: scene.cwd, + conflictFiles, + }; + const fail = (error: string): { audit: MergeResolutionAudit; resolved: false } => ({ + audit: { ...base, ok: false, durationMs: Date.now() - startMs, error }, + resolved: false, + }); + + // skill 全文作 systemPrompt(lazy import 同下方循环依赖规避口径);拿不到不乱来—— + // 无流程卡约束 LLM 可能 --abort 毁现场,直接回退人工 + const { skillLoader } = await import('@dommaker/studio-skill'); + const skill = skillLoader.loadSingle('resolving-merge-conflicts'); + if (!skill?.prompt) return fail('skill resolving-merge-conflicts 不可用(未 seed?)'); + + // verify 命令来源说明(与执行环节同策略:metadata.verifyCommands > 目录 package.json 惯例)。 + // lazy import:本模块头部依赖说明——不静态引入 agent-loop 侧模块,避免重依赖链 + const { resolveVerifyCommands } = await import('../agents/loop/wu-verification.js'); + const { commands: verifyCommands, source: verifySource } = await resolveVerifyCommands(wu, meta, scene.cwd); + + // spawn 一次性解冲突会话(lazy import 同上;超时走 eventSource 注册表 600s) + const { getSystemExecutor } = await import('../agents/system-executor.js'); + let output: string; + let durationMs: number; + try { + const result = await getSystemExecutor().run( + buildMergeConflictPrompt({ scene, conflictFiles, sourceBranch, targetBranch, scopeSummary, verifyCommands, verifySource }), + { systemPrompt: skill.prompt, cwd: scene.cwd, eventSource: 'merge-conflict-resolution' }, + ); + output = result.output; + durationMs = result.durationMs; + } catch (err) { + return fail(`LLM 解冲突失败/超时: ${getErrorMessage(err).slice(0, 200)}`); + } + + // 确定性校验:冲突标记清零才算解完(不信 LLM 自报) + const remaining = await listConflictFiles(scene.cwd, scene.cwd); + if (remaining.length > 0) { + return fail(`LLM 解完仍有 ${remaining.length} 个冲突文件: ${remaining.join(', ').slice(0, 200)}`); + } + + // rebase 现场:LLM 完成 rebase(--continue 直到落完)后重放 merge(rebase 后应为干净合并) + if (scene.state === 'rebase') { + try { + await execSh(mergeCmd, { cwd: mergeCwd, timeoutMs: MERGE_TIMEOUT_MS }); + } catch (err) { + return fail(`rebase 解完后重放 merge 仍失败: ${getErrorMessage(err).slice(0, 200)}`); + } } + + return { + audit: { ...base, ok: true, durationMs, summary: output.slice(-MERGE_LLM_SUMMARY_CHARS) }, + resolved: true, + }; +} + +/** LLM 解冲突 prompt:mergeContext + verify 命令来源说明(skill 全文走 systemPrompt) */ +function buildMergeConflictPrompt(args: { + scene: ConflictScene; + conflictFiles: string[]; + sourceBranch: string; + targetBranch: string; + scopeSummary: string; + verifyCommands: string[]; + verifySource: 'override' | 'convention'; +}): string { + const { scene, conflictFiles, sourceBranch, targetBranch, scopeSummary, verifyCommands, verifySource } = args; + return [ + '自动合并产生冲突,请按系统提示词中的流程在现场解冲突并完成合并。', + '', + '## mergeContext', + `- cwd: ${scene.cwd}(冲突现场目录,在此工作)`, + `- 现场状态: ${scene.state === 'merge' ? 'merge 进行中(git merge 冲突)' : 'rebase 进行中(git rebase 冲突,解完后 rebase --continue 直到全部 commit 落完)'}`, + `- 目标分支: ${targetBranch}`, + `- 来源分支: ${sourceBranch}`, + `- 任务: ${scopeSummary}`, + '- 冲突文件:', + ...(conflictFiles.length > 0 ? conflictFiles.map(f => ` - ${f}`) : [' (未能获取清单,用 git status 确认)']), + '', + '## 验证命令(解完后在 cwd 依次跑,全绿才算完成)', + ...(verifyCommands.length > 0 + ? verifyCommands.map(c => `- ${c}`) + : ['- (未能解析出验证命令;至少确认 git status 干净、无残留冲突标记)']), + `命令来源: ${verifySource === 'override' ? 'WU metadata.verifyCommands 覆盖' : '目录 package.json scripts 惯例'}`, + ].join('\n'); +} + +/** LLM 解冲突审计落 metadata.mergeResolution(仿 distill runs.jsonl 审计思路,落 WU metadata;best-effort) */ +async function recordMergeResolution( + wuService: WorkUnitService, + wuId: string, + audit: MergeResolutionAudit, +): Promise { + try { + const fresh = await wuService.getById(wuId); + const freshMeta = parseWuMetadata(fresh?.metadata); + await wuService.update(wuId, { metadata: { ...freshMeta, mergeResolution: audit } }); + } catch (err) { + logger.warn('[MergeOnReviewPass] record mergeResolution failed (non-blocking)', { wuId, error: String(err) }); + } +} + +/** 合并后 verify(lazy import 同 pmo-branch-resolver 的循环依赖规避口径) */ +async function runMergeVerification(wu: WorkUnitData, meta: WorkUnitMetadata, mergeCwd: string) { + const { runWuVerification } = await import('../agents/loop/wu-verification.js'); + return runWuVerification(wu, meta, mergeCwd); } // ── #157(T6):analysis 原型单 reviewPassed 收尾 ── diff --git a/apps/api/src/modules/workunit/workunit.service.ts b/apps/api/src/modules/workunit/workunit.service.ts index e3b666407..1005f9c65 100644 --- a/apps/api/src/modules/workunit/workunit.service.ts +++ b/apps/api/src/modules/workunit/workunit.service.ts @@ -399,15 +399,16 @@ export class WorkUnitService extends WorkUnitCrudService { const updated = await this.persistSnapshot(current, metadata, { eventType: 'updated' }); return snapshotToData(updated); } - async markMergeConflict(id: string, conflictFiles: string[]): Promise { + async markMergeConflict(id: string, conflictFiles: string[], opts?: { blockReason?: string }): Promise { const current = (await this.fileStore.getIndex({ id }))[0]; if (!current) throw new Error('WorkUnit not found'); const metadata: WorkUnitMetadata = parseWuMetadata(current.metadata); metadata.mergeConflict = true; metadata.conflictFiles = conflictFiles; - // B4: blocked 原因落盘(2026-08-03 token-burn issue P0-2) - metadata.blockReason = `merge-conflict: 自动合并冲突(${conflictFiles.length} 个文件)`; + // B4: blocked 原因落盘(2026-08-03 token-burn issue P0-2); + // opts.blockReason 覆盖默认文案(如合并后 verify 失败走同路径但前缀区分 merge-verify-failed) + metadata.blockReason = opts?.blockReason ?? `merge-conflict: 自动合并冲突(${conflictFiles.length} 个文件)`; const now = new Date(); const isoNow = now.toISOString(); @@ -487,6 +488,49 @@ export class WorkUnitService extends WorkUnitCrudService { return snapshotToData(updated); } + /** + * 决策 14 认领前适任判断:全员不适任转人工——频道全部 active 成员都被判不适任, + * unassigned WU 直接置 blocked(unassigned → blocked 不在 VALID_TRANSITIONS, + * 语义方法直写,形态同 blockForManualRelease/markMergeConflict 先例)。 + * 仅 unassigned 可转(active/blocked 各有既有出口);metadata 只补 blockReason/blockedAt, + * unfitRoles 已由调用方(agent-loop recordClaimUnfit)先行落档。 + */ + async blockForAllUnfit(id: string, reason: string): Promise { + const current = (await this.fileStore.getIndex({ id }))[0]; + if (!current) throw new Error('WorkUnit not found'); + if (current.status !== 'unassigned') return null; + + const metadata: WorkUnitMetadata = parseWuMetadata(current.metadata); + // B4: blocked 原因落盘(2026-08-03 token-burn issue P0-2) + metadata.blockReason = reason; + + const now = new Date(); + const isoNow = now.toISOString(); + metadata.blockedAt = isoNow; // #176(决策 #57 D4):死信计时基准 + const updated: WorkUnitSnapshot = { + ...current, + status: 'blocked', + metadata: JSON.stringify(metadata), + updatedAt: isoNow, + }; + + const event: WorkUnitEvent = { + type: 'blocked', + wuId: id, + timestamp: isoNow, + data: updated as unknown as Record, + }; + await this.fileStore.commitSnapshot(event, updated); + + await this.publishStatusChanged(updated); + + this.aggregateParentStatus(id).catch(err => + logger.warn('[WorkUnit] aggregateParentStatus failed', { workUnitId: id, error: String(err) }) + ); + + return snapshotToData(updated); + } + /** * Review rejected: in_review → active (or → blocked after 3 consecutive rejections). * Emits workunit.review.rejected. diff --git a/apps/api/src/modules/workunit/workunit.types.ts b/apps/api/src/modules/workunit/workunit.types.ts index f0c89dc84..356cbf852 100644 --- a/apps/api/src/modules/workunit/workunit.types.ts +++ b/apps/api/src/modules/workunit/workunit.types.ts @@ -131,12 +131,30 @@ export interface WorkUnitMetadata { mergeCommit?: string; // 合并后 baseRepo HEAD(merge commit 全哈希) mergeConflict?: boolean; // 自动合并(含 rebase 重试)仍冲突,已转人工(WU 置 blocked) conflictFiles?: string[]; // 合并冲突文件清单(diff-filter=U) + mergeResolution?: { // 合并冲突 LLM 解审计(merge/rebase 冲突现场交 LLM 解时落档,仿 distill runs 审计思路) + attemptedAt: string; // 尝试开始时间 ISO 8601 + ok: boolean; // 是否解完(确定性校验:冲突标记清零;rebase 现场含重放 merge 成功) + state: 'merge' | 'rebase';// 冲突现场类型(merge = 合并目标目录;rebase = worktree) + cwd: string; // 冲突现场目录(LLM 会话 cwd) + conflictFiles: string[]; // 交给 LLM 时的冲突文件清单 + durationMs: number; // LLM 会话耗时 + summary?: string; // LLM 输出摘要(截断,成功时落) + error?: string; // 失败原因(spawn 失败/超时/仍有残留冲突/重放 merge 失败) + }; knowledgeExtractedAt?: string; // R3: 会话知识提取已触达时间戳(去重——同一 WorkUnit 只提取一次) memoryExtractedAt?: string; // #99: WU 收尾角色记忆批量提取已触达时间戳(去重——同一 WorkUnit 只提取一次;区别于 R3 的 knowledgeExtractedAt) matchedSkills?: string[]; // 决策 7: step 时域匹配命中并实际注入的 skill 名(agent-loop 落盘,度量用) lastCommitHash?: string; // §10.5: PROGRESS 无提交监视 — 上次观察到的 worktree HEAD noCommitSteps?: number; // §10.5: 连续无新提交步数(满 3 步频道提醒一次并归零) commitGuardHint?: string; // §10.5: COMPLETE 被提交守卫打回时的提示(注入下一轮 prompt 后清除) + // 收口闸(产出实)闸 1:代码类 COMPLETE 但 base..HEAD 无任何提交 → 打回(hint 注入下轮 prompt 后清除), + // diffEmptyCount ≥3 → blocked 转人工(模式同 verifyFailCount/verifyFailHint) + diffEmptyCount?: number; // 空 diff 连续打回计数 + diffEmptyHint?: string; // 空 diff 打回提示 + // 收口闸(产出实)闸 2:非代码类契约产物锚点缺失(reviewReport/analysisTasks/specTasks/decisionSuggestion/调研报告落盘) + // → 打回(hint 注入下轮 prompt 后清除),contractArtifactCount ≥3 → blocked 转人工 + contractArtifactCount?: number; // 契约产物缺失连续打回计数 + contractArtifactHint?: string; // 契约产物缺失打回提示 // A2A 协作(2026-07-agent-to-agent-collab-design §5) collab?: { // 协作树追踪(DELEGATE 派生的 WU 携带;根 WU 首次委派后补记) rootId: string; // 协作树根 WU id @@ -207,6 +225,10 @@ export interface WorkUnitMetadata { traceId?: string; // P0 修复 6: 链路追踪 id(频道消息 req → WU → agent-loop 日志;与 audit requestId 同值) // F4 reviewer 解锚(2026-07-28 分析文档,决策 5):评审 WU 未指派走 claim 涌现时的约束/标记 excludeAssignee?: string; // 禁止认领的 profile id(评审排除实现者;agent-loop observe 未指派过滤据此剔除) + // 决策 14 认领前适任判断:被判不适任的角色名单(约束挂 WU 不挂角色身份,合规 ADR D2/决策 10)—— + // 写入方:agent-loop claim 前一次性 LLM 判断(agents/loop/claim-fitness.ts);消费方: + // observe 第 7 道过滤(含本 role.id 即不可见)+ 全员不适任转 blocked 判定 + unfitRoles?: UnfitRoleEntry[]; selfReview?: boolean; // 本评审 WU 未排除实现者(频道内无其他 active 成员)→ 可能是自评,台账/提醒据此标记 reviewInput?: { mode: string; skill: string }; // R3: 评审输入契约落档(diff-only + code-review),审计用 reviewRedispatchAttempts?: number; // #183(#66 决议①):review 对账重跑连续失败次数,≥3 停跑并升 critical @@ -305,6 +327,17 @@ export interface WorkUnitData { claimable?: boolean; } +/** + * 决策 14 认领前适任判断的不适任名单条目。 + * roleId = profile id(与 observe 过滤的 this.role.id 同口径);reason = 判断器一行理由; + * at = 落档时间 ISO 8601。 + */ +export interface UnfitRoleEntry { + roleId: string; + reason: string; + at: string; +} + /** Valid status transitions map */ export const VALID_TRANSITIONS: Record = { pending: ['unassigned', 'closed'], diff --git a/docs/adr/2026-08-25-review-independence-trust-model.md b/docs/adr/2026-08-25-review-independence-trust-model.md index 7c2adf2a9..cfd47c193 100644 --- a/docs/adr/2026-08-25-review-independence-trust-model.md +++ b/docs/adr/2026-08-25-review-independence-trust-model.md @@ -50,3 +50,25 @@ review 架构的这组承重决策此前只活在 plans 过程文档与代码注 - 后来者评估「要不要独立 reviewer」时以本 ADR 为准;推导细节回 plans 文档。 - 任何弱化 D1 三机制、恢复角色锚点、或取消自评标记的改动 = 治理变更,需先过决策再动手。 + +## 补充:决策 14 认领前适任判断(2026-09-15) + +> 编号说明:决策 10~13 无统一登记处(`docs/adr/decisions.md` 是另一套 D-XXX 旧注册表, +> 不承载本编号序列;10~13 散见代码注释/CONTEXT.md/本 ADR),本决策沿序列顺延为 14, +> 登记于此(与本决策合规依据 D2/决策 10 同文,最贴合现有惯例)。 + +频道角色认领是「代码代抢」:observe(零 LLM)过滤 → resolveTarget(纯代码排序)→ claim +抢占,agent 第一次看到 scope 全文是认领后的第一步 prompt,错误认领是结构性必然,且协议 +没有退回出口(只有 NEED_INPUT 甩给人)。定稿方案 = **认领前适任判断**(做不了的不抢), +不做「认领后退回」: + +- 插入点:resolveTarget 选中 unassigned 候选之后、claimAndAnnounce 之前;一次性轻量 LLM + 调用(复用 system-executor,与 distill/wu-completion-extraction 同款机制),只问 + 「能否胜任、是否属于你」。不进 observe 轮询,成本与认领次数成正比。 +- **合规 D2**:适任结果落档 WU 侧 `metadata.unfitRoles` 名单({roleId, reason, at}, + 约束挂 WU 不挂角色身份);observe 过滤链新增第 7 道(含本 role.id 即不可见)。 + 恢复 acceptedTypes 静态类型过滤仍是治理变更,禁止。 +- 从宽哲学:判断调用失败/超时/解析不出 → 视为适任(宁可误抢不可漏抢,错抢有 NEED_INPUT 兜底)。 +- 全员不适任(unfitRoles 覆盖频道全部 active 成员)→ WU 置 blocked + 频道消息转人工 + (unassigned → blocked 不在状态机表内,走 `WorkUnitService.blockForAllUnfit` 语义方法直写, + 仿 blockForManualRelease 先例,状态机表不动)。 diff --git a/packages/studio-skill/skills/resolving-merge-conflicts/SKILL.md b/packages/studio-skill/skills/resolving-merge-conflicts/SKILL.md new file mode 100644 index 000000000..ed1287469 --- /dev/null +++ b/packages/studio-skill/skills/resolving-merge-conflicts/SKILL.md @@ -0,0 +1,27 @@ +--- +name: resolving-merge-conflicts +description: "在 studio 自动合并(review 通过后的 task 分支合并)冲突现场解 merge/rebase 冲突:保留双方意图、跑项目验证命令全绿、完成合并,永不 --abort。" +agentTypes: [implement] +triggers: [合并冲突, 解冲突, merge conflict, resolve merge conflict, rebase 冲突, 自动合并失败, merge-on-review-pass] +status: published +--- + +# 解合并冲突(studio 自动合并现场) + +你在一个**进行中的 merge/rebase 冲突现场**工作。工作目录(cwd)由调用方传入,是合并目标目录(baseRepo 或 PMO 集成 worktree),不是任务 worktree。prompt 会附带 mergeContext:cwd、冲突文件清单、目标分支、来源分支、当前是 merge 还是 rebase 阶段。 + +## 流程 + +1. **看现状**:`git status`、`git log --oneline -5`(双方分支)、逐个读冲突文件的冲突块(`<<<<<<<` / `=======` / `>>>>>>>`)。 + +2. **找一手来源**:理解每一处冲突两边各自为什么这样改、原意图是什么。读相关 commit message(`git log` / `git show`),来源分支是 `task/`,任务上下文(WU scope)在 prompt 里。 + +3. **逐个 hunk 解**:能兼容就同时保留双方意图;不兼容时选与本次合并目标一致的一方,并在 commit message 里记取舍。**不发明新行为**——只裁决已有改动,不乘机重构、不顺手"优化"。**永远解完,永不 `--abort`**——abort 会销毁现场并把工单打回人工。 + +4. **跑项目验证命令**:prompt 会给出验证命令清单(来源:WU metadata.verifyCommands 覆盖 > 目标目录 package.json scripts 惯例:typecheck → test → lint,按 lockfile 选 pnpm/npm)。在冲突 cwd 依次跑,**全绿才算完成**;有红就修到绿(修的是合并引入的问题,不是既有债务)。 + +5. **完成合并**:`git add` 所有已解文件后 commit(merge 场景);rebase 场景 `git rebase --continue` 直到全部 commit 落完。结束前 `git status` 确认无残留冲突标记、无未合路径。 + +## 输出 + +结束时用简短文字汇报:解了哪些文件、关键取舍(如有)、验证命令结果(跑了哪几条、是否全绿)。这段汇报会落进 WU metadata.mergeResolution 审计。 From 558932fa96d6e15d4a160b703dd51c130693029e Mon Sep 17 00:00:00 2001 From: dommaker Date: Tue, 15 Sep 2026 21:11:41 +0800 Subject: [PATCH 002/116] =?UTF-8?q?feat(skills):=20=E5=BA=A6=E9=87=8F?= =?UTF-8?q?=E5=9C=B0=E5=9F=BA=E7=A5=A8A=E2=80=94=E2=80=94loadSkill=20cache?= =?UTF-8?q?=20=E5=91=BD=E4=B8=AD=E8=B7=AF=E5=BE=84=E8=A1=A5=E5=8F=91?= =?UTF-8?q?=E5=B0=84=20skill=5Fused=EF=BC=88level=3Dinfo=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cache 命中是 loadSkill 常态路径(包级 loader 预热覆盖全部磁盘 skill),此前仅 文件回退路径发射 skill_used → 事件结构性为 0。发射点上移到工具边界,cache 路径补发射(channel=loadSkill),文件路径维持 skill-loader.ts 既有发射, 每调用恰好一次。 --- .../modules/mcp/__tests__/skill.tools.test.ts | 40 +++++++++++++++++++ apps/api/src/modules/mcp/skill.tools.ts | 10 +++++ 2 files changed, 50 insertions(+) diff --git a/apps/api/src/modules/mcp/__tests__/skill.tools.test.ts b/apps/api/src/modules/mcp/__tests__/skill.tools.test.ts index e2cddd340..8088da1a5 100644 --- a/apps/api/src/modules/mcp/__tests__/skill.tools.test.ts +++ b/apps/api/src/modules/mcp/__tests__/skill.tools.test.ts @@ -3,11 +3,13 @@ * * 覆盖 loadSkill 的两级加载(包内缓存 → 文件加载)与未命中分支。 * skillLoader / skillLoaderService 动态 import 被 mock。 + * skill 度量地基票 A:cache 命中路径也发射 knowledge:skill_used(level=info signal)。 */ import { describe, it, expect, vi, beforeEach } from 'vitest'; const mockGetFullPrompt = vi.fn(); const mockLoadSkill = vi.fn(); +const mockWriteStudioEvent = vi.fn().mockResolvedValue(true); vi.mock('@dommaker/studio-skill', () => ({ skillLoader: { getFullPrompt: mockGetFullPrompt }, @@ -17,6 +19,11 @@ vi.mock('../../skills/skill-loader.js', () => ({ skillLoaderService: { loadSkill: mockLoadSkill }, })); +vi.mock('@dommaker/studio-shared', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, writeStudioEvent: mockWriteStudioEvent }; +}); + import { skillTools } from '../skill.tools.js'; const loadSkill = skillTools[0]; @@ -65,4 +72,37 @@ describe('skill.tools', () => { workUnitId: 'wu-42', })); }); + + it('票A: cache 命中也发射 skill_used(level=info signal,channel=loadSkill,带 workUnitId)', async () => { + mockGetFullPrompt.mockReturnValue('# TDD Workflow\n...'); + await loadSkill.handler({ skillName: 'tdd-workflow', workUnitId: 'wu-7' }); + expect(mockWriteStudioEvent).toHaveBeenCalledWith('knowledge:skill_used', { + skillName: 'tdd-workflow', + workUnitId: 'wu-7', + channel: 'loadSkill', + }, expect.objectContaining({ source: 'skill-tools', level: 'info' })); + }); + + it('票A: cache 命中无 workUnitId 也发射(legacy 口径,聚合侧每条计 1)', async () => { + mockGetFullPrompt.mockReturnValue('# TDD Workflow\n...'); + await loadSkill.handler({ skillName: 'tdd-workflow' }); + expect(mockWriteStudioEvent).toHaveBeenCalledWith('knowledge:skill_used', { + skillName: 'tdd-workflow', + channel: 'loadSkill', + }, expect.objectContaining({ source: 'skill-tools', level: 'info' })); + }); + + it('票A: 两级均未命中 → 不发射 skill_used', async () => { + mockGetFullPrompt.mockReturnValue(null); + mockLoadSkill.mockResolvedValue(null); + await loadSkill.handler({ skillName: 'ghost' }); + expect(mockWriteStudioEvent).not.toHaveBeenCalled(); + }); + + it('票A: 文件加载路径不在本层重复发射(skill-loader.ts 已发射,每调用恰好一次)', async () => { + mockGetFullPrompt.mockReturnValue(null); + mockLoadSkill.mockResolvedValue({ prompt: 'file prompt' }); + await loadSkill.handler({ skillName: 'custom' }); + expect(mockWriteStudioEvent).not.toHaveBeenCalled(); + }); }); diff --git a/apps/api/src/modules/mcp/skill.tools.ts b/apps/api/src/modules/mcp/skill.tools.ts index 14277508e..d9c96652c 100644 --- a/apps/api/src/modules/mcp/skill.tools.ts +++ b/apps/api/src/modules/mcp/skill.tools.ts @@ -30,6 +30,16 @@ const loadSkill: RegisteredTool = { const { skillLoader } = await import('@dommaker/studio-skill'); const fullPrompt = skillLoader.getFullPrompt(skillName); if (fullPrompt) { + // skill 度量地基票 A:cache 命中是 loadSkill 常态路径(包级 loader 预热后覆盖全部 + // 磁盘 skill),此前只有路径 2 发射 skill_used → 事件结构性为 0。发射点上移到 + // 工具边界:cache 路径在本层补发射(level=info 提为 signal,不归噪声 7 天滚); + // 路径 2 维持 skill-loader.ts 既有发射,每调用恰好一次。 + const { writeStudioEvent } = await import('@dommaker/studio-shared'); + void writeStudioEvent('knowledge:skill_used', { + skillName, + ...(typeof input.workUnitId === 'string' && input.workUnitId ? { workUnitId: input.workUnitId } : {}), + channel: 'loadSkill', + }, { source: 'skill-tools', level: 'info' }).catch(() => {}); return { skillName, content: fullPrompt, source: 'cache' }; } From 2a1292fc86af06294fd6f27eb98e5f2de8ed3ac5 Mon Sep 17 00:00:00 2001 From: dommaker Date: Tue, 15 Sep 2026 21:15:14 +0800 Subject: [PATCH 003/116] =?UTF-8?q?feat(skills):=20=E5=BA=A6=E9=87=8F?= =?UTF-8?q?=E5=9C=B0=E5=9F=BA=E7=A5=A8B=E2=80=94=E2=80=94transcript=20?= =?UTF-8?q?=E5=90=8E=E9=AA=8C=E4=BD=BF=E7=94=A8=E6=89=AB=E6=8F=8F=EF=BC=88?= =?UTF-8?q?usage=20=E4=B8=BB=E5=8F=A3=E5=BE=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「使用」= 获取 skill 全文、渠道无关:agent Read/cat SKILL.md 会绕开 loadSkill, 但读取必在归档 transcript 的 rawOutput 留痕(provider 无关)。WU done 时纯 确定性扫描路径痕迹,每命中 skill 发射一条 knowledge:skill_used (channel=transcript,level=info);聚合侧 (skill, workUnitId) 去重吸收 重复触发,幂等无哨兵。index.ts 挂载同 completion-extraction 形态。 --- apps/api/src/index.ts | 7 + .../skills/__tests__/skill-usage-scan.test.ts | 125 ++++++++++++++++++ .../src/modules/skills/skill-usage-scan.ts | 89 +++++++++++++ 3 files changed, 221 insertions(+) create mode 100644 apps/api/src/modules/skills/__tests__/skill-usage-scan.test.ts create mode 100644 apps/api/src/modules/skills/skill-usage-scan.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 8370fe643..97efcacbc 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -328,6 +328,13 @@ async function start() { logger.info('[RoleMemory] Completion extraction subscribed (workunit.status_changed → done)'); } catch (e) { logger.warn('[RoleMemory] Completion extraction init failed', { error: String(e) }); } + // skill 度量地基票 B:WU done → transcript 后验扫描 skill 使用痕迹 → skill_used 事件(纯确定性零 LLM) + try { + const { initSkillUsageScan } = await import('./modules/skills/skill-usage-scan.js'); + initSkillUsageScan(); + logger.info('[SkillUsageScan] Subscribed to workunit.status_changed (done → transcript scan)'); + } catch (e) { logger.warn('[SkillUsageScan] Failed to subscribe', { error: String(e) }); } + // #143 蒸馏主链路:WU done → 门槛检测(纯计数零 LLM)→ distill_proposal 人审卡 try { const { initDistillLoop } = await import('./modules/distill/distill-runtime.js'); diff --git a/apps/api/src/modules/skills/__tests__/skill-usage-scan.test.ts b/apps/api/src/modules/skills/__tests__/skill-usage-scan.test.ts new file mode 100644 index 000000000..e2f76975b --- /dev/null +++ b/apps/api/src/modules/skills/__tests__/skill-usage-scan.test.ts @@ -0,0 +1,125 @@ +/** + * skill-usage-scan(skill 度量地基票 B)单测 + * + * transcript 后验使用扫描:WU done → readTranscript → 扫 rawOutput 中的 + * skills//SKILL.md 痕迹 → 每 skill 一条 knowledge:skill_used + * (channel=transcript,level=info signal)。纯确定性零 LLM。 + * eventBus / writeStudioEvent / readTranscript 均 mock,不落盘。 + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { mockWriteStudioEvent, mockReadTranscript, mockSubscribe, state } = vi.hoisted(() => ({ + mockWriteStudioEvent: vi.fn().mockResolvedValue(true), + mockReadTranscript: vi.fn(), + mockSubscribe: vi.fn(), + state: { statusChangedHandler: null as null | ((payload: { workunit: unknown }) => void) }, +})); + +vi.mock('@dommaker/studio-shared', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + writeStudioEvent: mockWriteStudioEvent, + eventBus: { + subscribe: (event: string, handler: (payload: { workunit: unknown }) => void) => { + mockSubscribe(event, handler); + if (event === 'workunit.status_changed') state.statusChangedHandler = handler; + }, + }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + }; +}); + +vi.mock('../../transcripts/transcript-archive.js', () => ({ + readTranscript: mockReadTranscript, +})); + +import { SkillUsageScanner, extractUsedSkillNames, initSkillUsageScan } from '../skill-usage-scan.js'; + +describe('extractUsedSkillNames', () => { + it('命中绝对/相对路径,按 skill 名去重', () => { + const text = [ + 'Read /root/.studio/skills/code-review/SKILL.md 成功', + 'cat ~/.studio/skills/code-review/SKILL.md', + '见 skills/tdd-implement/SKILL.md 全文', + ].join('\n'); + expect(extractUsedSkillNames(text)).toEqual(['code-review', 'tdd-implement']); + }); + + it('无命中 → 空数组', () => { + expect(extractUsedSkillNames('读取了 src/index.ts 和 README.md')).toEqual([]); + }); + + it('_ 前缀目录(_deprecated 等)不计入', () => { + expect(extractUsedSkillNames('skills/_deprecated/old-skill/SKILL.md')).toEqual([]); + }); +}); + +describe('SkillUsageScanner', () => { + beforeEach(() => { + vi.clearAllMocks(); + state.statusChangedHandler = null; + }); + + it('done WU → 每命中 skill 恰好一条 skill_used(多步读同一 skill 只一条)', async () => { + mockReadTranscript.mockResolvedValue([ + { workUnitId: 'wu-1', step: 1, rawOutput: '读取 skills/code-review/SKILL.md' }, + { workUnitId: 'wu-1', step: 2, rawOutput: '再次引用 /root/.studio/skills/code-review/SKILL.md 和 skills/research/SKILL.md' }, + ]); + const scanner = new SkillUsageScanner(); + const used = await scanner.scan({ id: 'wu-1' } as never); + expect(used).toEqual(['code-review', 'research']); + expect(mockWriteStudioEvent).toHaveBeenCalledTimes(2); + expect(mockWriteStudioEvent).toHaveBeenCalledWith('knowledge:skill_used', { + skillName: 'code-review', workUnitId: 'wu-1', channel: 'transcript', + }, { source: 'skill-usage-scan', level: 'info' }); + }); + + it('transcript 无命中 → 不发射事件', async () => { + mockReadTranscript.mockResolvedValue([{ workUnitId: 'wu-2', step: 1, rawOutput: '普通输出' }]); + const scanner = new SkillUsageScanner(); + expect(await scanner.scan({ id: 'wu-2' } as never)).toEqual([]); + expect(mockWriteStudioEvent).not.toHaveBeenCalled(); + }); + + it('transcript 缺失(readTranscript 返回 [])→ 不发射不抛错', async () => { + mockReadTranscript.mockResolvedValue([]); + const scanner = new SkillUsageScanner(); + expect(await scanner.scan({ id: 'wu-ghost' } as never)).toEqual([]); + expect(mockWriteStudioEvent).not.toHaveBeenCalled(); + }); + + it('订阅 workunit.status_changed:done 触发扫描,非 done 不触发', async () => { + mockReadTranscript.mockResolvedValue([ + { workUnitId: 'wu-3', step: 1, rawOutput: 'skills/tdd-implement/SKILL.md' }, + ]); + const scanner = new SkillUsageScanner(); + scanner.subscribeToEvents(); + expect(state.statusChangedHandler).not.toBeNull(); + + state.statusChangedHandler!({ workunit: { id: 'wu-skip', status: 'active' } }); + expect(mockReadTranscript).not.toHaveBeenCalled(); + + state.statusChangedHandler!({ workunit: { id: 'wu-3', status: 'done' } }); + await vi.waitFor(() => { + expect(mockWriteStudioEvent).toHaveBeenCalledWith('knowledge:skill_used', + expect.objectContaining({ skillName: 'tdd-implement', workUnitId: 'wu-3' }), + expect.objectContaining({ level: 'info' })); + }); + }); + + it('subscribeToEvents 幂等(重复订阅只挂一次)', () => { + const scanner = new SkillUsageScanner(); + scanner.subscribeToEvents(); + const first = state.statusChangedHandler; + scanner.subscribeToEvents(); + expect(state.statusChangedHandler).toBe(first); + }); + + it('initSkillUsageScan 返回单例并完成订阅', () => { + const a = initSkillUsageScan(); + const b = initSkillUsageScan(); + expect(a).toBe(b); + expect(state.statusChangedHandler).not.toBeNull(); + }); +}); diff --git a/apps/api/src/modules/skills/skill-usage-scan.ts b/apps/api/src/modules/skills/skill-usage-scan.ts new file mode 100644 index 000000000..ec3daffba --- /dev/null +++ b/apps/api/src/modules/skills/skill-usage-scan.ts @@ -0,0 +1,89 @@ +/** + * skill-usage-scan — transcript 后验使用扫描(skill 度量地基票 B,usage 主口径)。 + * + * 背景:skill_used 仅靠 loadSkill 发射测不准——注入段/MANIFEST 直接给 SKILL.md + * 路径,agent 可 Read/cat 绕开 MCP 工具(渠道旁路);且 cache 命中路径历史上 + * 不发射(票 A 已修)。本扫描把「使用」定义为**获取全文、渠道无关**:agent 读 + * 文件必在 rawOutput 留痕(Read 调用 / cat 命令 / 路径引用),rawOutput 是 + * provider 无关的既有归档产物(transcript-archive.ts)。 + * + * 形态:eventBus 钩子(仿 completion-extraction / distill-runtime), + * 纯确定性、零 LLM、无预算熔断;fire-and-forget 不阻断收尾订阅链。 + * + * 幂等:WU done 重发只产重复事件,聚合侧(skill-demotion.ts)按 + * (skill, workUnitId) 去重吸收,不加哨兵。 + * + * 宽口径说明:agent 在文本中提及路径而未真读也算命中(罕见,接受);数据积累后 + * 如需收紧另议。 + */ +import { eventBus, logger, writeStudioEvent } from '@dommaker/studio-shared'; +import { readTranscript } from '../transcripts/transcript-archive.js'; +import type { WorkUnitData } from '../workunit/workunit.service.js'; + +/** + * skill 全文路径痕迹:匹配 `skills//SKILL.md`(绝对/相对路径均可命中)。 + * name 首字符限 [a-z0-9]:_ 前缀目录(_deprecated 等,manifest-loader 同口径)不计入。 + */ +const SKILL_PATH_RE = /skills\/([a-z0-9][a-z0-9-]*)\/SKILL\.md/g; + +/** 从文本中提取使用过的 skill 名(按名去重,保序) */ +export function extractUsedSkillNames(text: string): string[] { + const names = new Set(); + for (const m of text.matchAll(SKILL_PATH_RE)) { + names.add(m[1]); + } + return [...names]; +} + +export class SkillUsageScanner { + private subscribed = false; + + /** 订阅 workunit.status_changed(done 触发)。幂等。 */ + subscribeToEvents(): void { + if (this.subscribed) return; + this.subscribed = true; + + eventBus.subscribe('workunit.status_changed', (payload: { workunit: WorkUnitData }) => { + const wu = payload.workunit; + if (!wu || wu.status !== 'done') return; + // fire-and-forget:扫描/写事件绝不 await 阻断收尾订阅链 + void this.scan(wu).catch(err => + logger.warn('[SkillUsageScan] scan failed (non-blocking)', { wuId: wu.id, error: String(err) }), + ); + }); + } + + /** + * 扫一个 WU 的归档 transcript,每命中 skill 发射一条 knowledge:skill_used + * (channel=transcript,level=info signal——提级后归信号档:热 30 天 → 月度 gz 归档, + * 不再随噪声 7 天滚动删除)。返回命中名单(测试/观测用)。 + */ + async scan(wu: WorkUnitData): Promise { + const entries = await readTranscript(wu.id); + const text = entries.map(e => e.rawOutput ?? '').join('\n'); + const names = extractUsedSkillNames(text); + for (const skillName of names) { + await writeStudioEvent('knowledge:skill_used', { + skillName, + workUnitId: wu.id, + channel: 'transcript', + }, { source: 'skill-usage-scan', level: 'info' }).catch(err => { + logger.warn('[SkillUsageScan] emit skill_used failed', { wuId: wu.id, skillName, error: String(err) }); + return false; + }); + } + if (names.length > 0) { + logger.info('[SkillUsageScan] skill usage emitted', { wuId: wu.id, skills: names }); + } + return names; + } +} + +// 单例(懒初始化,形态同 initWuCompletionExtraction) +let _scanner: SkillUsageScanner | null = null; + +export function initSkillUsageScan(): SkillUsageScanner { + if (!_scanner) _scanner = new SkillUsageScanner(); + _scanner.subscribeToEvents(); + return _scanner; +} From 4e405822c0ac15814d22b5d07a0eb5827023a26f Mon Sep 17 00:00:00 2001 From: dommaker Date: Tue, 15 Sep 2026 21:19:55 +0800 Subject: [PATCH 004/116] =?UTF-8?q?feat(skills):=20=E5=BA=A6=E9=87=8F?= =?UTF-8?q?=E5=9C=B0=E5=9F=BA=E7=A5=A8C=E2=80=94=E2=80=94skill=5Fused=20?= =?UTF-8?q?=E6=8F=90=E7=BA=A7=20signal=EF=BC=88level=3Dinfo=EF=BC=8C?= =?UTF-8?q?=E5=BD=92=E6=A1=A3=E4=BF=9D=E7=95=99=E4=B8=8D=207=20=E5=A4=A9?= =?UTF-8?q?=E6=BB=9A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 发射点(skill-loader/skill-tools/skill-usage-scan)显式 level=info,不改 knowledge:* 大类默认(其余维持 noise);轮转归 signal:热 30 天 → 月度 gz 归档 - 根修 writeStudioEvent:显式传 level(含 'info')必须落 envelope 字段——此前 「info 不落字段」优化会让提级语义在落盘时丢失,轮转分类回退 type 默认 debug,提级静默失效 - 同步 studio-events / studio-events-rotation 头注释与既有断言 --- .../__tests__/skill-extraction-events.test.ts | 4 ++-- apps/api/src/modules/skills/skill-loader.ts | 10 +++++++--- .../__tests__/studio-events-rotation.test.ts | 18 ++++++++++++++++++ apps/api/src/utils/studio-events-rotation.ts | 2 ++ .../src/__tests__/studio-events.test.ts | 6 ++++++ packages/studio-shared/src/studio-events.ts | 10 ++++++++-- 6 files changed, 43 insertions(+), 7 deletions(-) diff --git a/apps/api/src/modules/skills/__tests__/skill-extraction-events.test.ts b/apps/api/src/modules/skills/__tests__/skill-extraction-events.test.ts index 74ee90436..216f1c6e8 100644 --- a/apps/api/src/modules/skills/__tests__/skill-extraction-events.test.ts +++ b/apps/api/src/modules/skills/__tests__/skill-extraction-events.test.ts @@ -139,7 +139,7 @@ describe('Skill event emission', () => { expect(payload.skillName).toBe('test-skill'); }); - test('#172(#60 决策):loadSkill 的 skill_used 携带 workUnitId(传入时)+ envelope level=debug', async () => { + test('#172(#60 决策)+ 度量地基票 C:loadSkill 的 skill_used 携带 workUnitId(传入时)+ envelope level=info(signal 提级)', async () => { const { SkillLoaderService } = await import('../../skills/skill-loader.js'); const loader = new SkillLoaderService(); @@ -151,7 +151,7 @@ describe('Skill event emission', () => { expect(result).not.toBeNull(); const row = await waitForEvent('knowledge:skill_used'); - expect(row.level).toBe('debug'); // knowledge:* 默认 debug 分级(写口按 type 赋级) + expect(row.level).toBe('info'); // 票 C:skill_used 显式提级 signal(归档保留,不 7 天滚) const payload = JSON.parse(row!.payload); expect(payload.skillName).toBe('test-skill'); expect(payload.workUnitId).toBe('wu-42'); diff --git a/apps/api/src/modules/skills/skill-loader.ts b/apps/api/src/modules/skills/skill-loader.ts index 2b9d7c035..fdecb21b2 100644 --- a/apps/api/src/modules/skills/skill-loader.ts +++ b/apps/api/src/modules/skills/skill-loader.ts @@ -8,8 +8,11 @@ * * #75: loadSkill lifecycle * #172(#60 决策 Q2):knowledge:skill_used 唯一语义 = Skill 加载(本文件发射点), - * 携带 workUnitId(调用方已知时);经 writeStudioEvent 落盘(envelope level=debug), + * 携带 workUnitId(调用方已知时);经 writeStudioEvent 落盘, * 替代模块加载期固化的直连路径(修复测试期假 id 写入生产事件文件的污染漏洞)。 + * skill 度量地基票 C:skill_used 自本票起显式 level=info(signal——热 30 天 → 月度 + * gz 归档永久保留),不再随 knowledge:* 大类默认 debug 归噪声 7 天滚动删除; + * 大类默认不动(其余 knowledge:* 维持 noise)。 */ import { logger, writeStudioEvent } from '@dommaker/studio-shared'; @@ -103,12 +106,13 @@ export class SkillLoaderService { state.loaded.set(skillName, loaded); // S3 Gap 3c + #172(#60 决策 Q2): skill_used 唯一语义 = Skill 加载,补 workUnitId; - // 经 writeStudioEvent 统一入口落盘(envelope level=debug,测试期走隔离事件文件) + // 经 writeStudioEvent 统一入口落盘(测试期走隔离事件文件); + // skill 度量地基票 C:显式 level=info 提为 signal(归档保留,不 7 天滚) void writeStudioEvent('knowledge:skill_used', { skillName, skillId, ...(options.workUnitId ? { workUnitId: options.workUnitId } : {}), - }, { source: 'skill-loader' }).catch(() => {}); + }, { source: 'skill-loader', level: 'info' }).catch(() => {}); logger.info('[SkillLoader] Loaded skill', { sessionId, diff --git a/apps/api/src/utils/__tests__/studio-events-rotation.test.ts b/apps/api/src/utils/__tests__/studio-events-rotation.test.ts index 369de22af..730ca80dc 100644 --- a/apps/api/src/utils/__tests__/studio-events-rotation.test.ts +++ b/apps/api/src/utils/__tests__/studio-events-rotation.test.ts @@ -77,6 +77,10 @@ describe('classifyStudioEventForRetention(#60 决议口径:level=debug 为 expect(classifyStudioEventForRetention({ type: 'knowledge:outcome', level: 'warning' })).toBe('signal'); }); + it('票C: skill_used 显式 level=info → signal(发射点提级,type 大类默认不变)', () => { + expect(classifyStudioEventForRetention({ type: 'knowledge:skill_used', level: 'info' })).toBe('signal'); + }); + it('type 缺失/非字符串 → signal(宁可永久留,不静默丢数据)', () => { expect(classifyStudioEventForRetention({})).toBe('signal'); expect(classifyStudioEventForRetention({ type: 42 })).toBe('signal'); @@ -121,6 +125,20 @@ describe('rotateStudioEvents', () => { expect(readHotLines(file)).toEqual([]); }); + it('票C: level=info 的 skill_used 超 7 天不滚(归 signal 热 30 天),超 30 天进归档不删除', async () => { + const hotSignal = eventLine('knowledge:skill_used', isoDaysAgo(NOISE_RETENTION_DAYS + 1), { level: 'info' }); + const oldSignal = eventLine('knowledge:skill_used', isoDaysAgo(SIGNAL_HOT_DAYS + 1), { level: 'info' }); + writeHot([hotSignal, oldSignal]); + + const result = await rotateStudioEvents({ file, now: NOW }); + + expect(result.noiseDropped).toBe(0); + expect(result.signalArchived).toBe(1); + expect(readHotLines(file)).toEqual([hotSignal]); + const month = new Date(NOW.getTime() - (SIGNAL_HOT_DAYS + 1) * DAY_MS).toISOString().slice(0, 7); + expect(readGzLines(path.join(archiveDir, `studio-events-${month}.jsonl.gz`))).toEqual([oldSignal]); + }); + it('7 天规则不伤信号:8 天前的信号事件保留在热文件', async () => { const signal = eventLine('workunit:execution_step', isoDaysAgo(NOISE_RETENTION_DAYS + 1)); writeHot([signal]); diff --git a/apps/api/src/utils/studio-events-rotation.ts b/apps/api/src/utils/studio-events-rotation.ts index d66d0b355..a4ac66205 100644 --- a/apps/api/src/utils/studio-events-rotation.ts +++ b/apps/api/src/utils/studio-events-rotation.ts @@ -5,6 +5,8 @@ * * - 分类口径 = #172 落地的 envelope level:level=debug(knowledge:*、tool:call 默认分级) * 为噪声;其余(缺省 info / warning / critical)为信号。显式 level 字段优先于 type 默认分级。 + * 例外:knowledge:skill_used 自 skill 度量地基票 C(2026-09-15)起发射点显式 + * level=info → 归 signal(skill 使用度量需归档保留);type 大类默认不变。 * - 噪声:热文件内超过 7 天(NOISE_RETENTION_DAYS)即滚动删除,不进归档。 * - 信号:热文件保留 30 天(SIGNAL_HOT_DAYS,趋势探测/复盘直读纯文本),超期按事件月份 * 切 `archive/studio-events-YYYY-MM.jsonl.gz`(相对热文件目录)永久保留——冷包只增不删; diff --git a/packages/studio-shared/src/__tests__/studio-events.test.ts b/packages/studio-shared/src/__tests__/studio-events.test.ts index 71ad77bc3..147f15cc6 100644 --- a/packages/studio-shared/src/__tests__/studio-events.test.ts +++ b/packages/studio-shared/src/__tests__/studio-events.test.ts @@ -79,6 +79,12 @@ describe('writeStudioEvent', () => { expect(defaultStudioEventLevel('session:start')).toBe('info'); }); + test('度量地基票 C:显式 level(含 info)必须落字段——否则轮转分类回退 type 默认分级,提级失效', async () => { + await writeStudioEvent('knowledge:skill_used', { skillName: 'x' }, { file: eventsFile, level: 'info' }); + const [row] = readEnvelope(eventsFile); + expect(row).toMatchObject({ type: 'knowledge:skill_used', level: 'info' }); + }); + test.each([ ['空串', ''], ['{} 对象', {}], diff --git a/packages/studio-shared/src/studio-events.ts b/packages/studio-shared/src/studio-events.ts index 7315cfdb5..636c3b91c 100644 --- a/packages/studio-shared/src/studio-events.ts +++ b/packages/studio-shared/src/studio-events.ts @@ -20,6 +20,9 @@ * #172(#60 决策 Q2):envelope 可选 level 字段(缺省 info —— 字段缺省即 info,不为 * info 冗余落字段)。默认分级按 type:knowledge:* 与 tool:call → debug(噪声), * 其余 → info;调用方可经 opts.level 显式覆盖(如 workunit:failed → warning)。 + * 例外:knowledge:skill_used 自 skill 度量地基票 C(2026-09-15)起由发射点显式 + * level=info 提为 signal(skill 使用度量需归档保留,不随噪声 7 天滚动删除); + * 本函数的大类默认不变。 * monitor:alert 由 emitMonitorEvent 透传 opts.level(#184 修复:读取侧 level 过滤只认 * envelope,payload-only 分级导致告警收件箱恒空);payload.level 保留兼容既有读者。 * 读取侧默认 ≥info(过滤归读取方,不在此处硬编码黑名单)。 @@ -110,8 +113,11 @@ export async function writeStudioEvent( await fileStore.appendJsonl(opts?.file ?? resolveStudioEventsFile(), { type, ...(opts?.source ? { source: opts.source } : {}), - // #172: level 为可选字段,缺省 info 不落字段(读取侧缺省即 info) - ...(level !== 'info' ? { level } : {}), + // #172: level 为可选字段,缺省 info 不落字段(读取侧缺省即 info)。 + // 度量地基票 C:调用方显式传 level 时(含 'info')必须落字段——显式 'info' 是 + // 提级语义(如 knowledge:skill_used 脱离 type 默认 debug 归 signal),不落字段 + // 会让轮转/读取分类回退 type 默认分级,提级静默失效。 + ...(opts?.level !== undefined || level !== 'info' ? { level } : {}), payload: typeof payload === 'string' ? payload : JSON.stringify(payload), createdAt: opts?.createdAt ?? new Date().toISOString(), }); From 14c7b3088127bbe4ea2ddaf2f23b1f124035daa3 Mon Sep 17 00:00:00 2001 From: dommaker Date: Tue, 15 Sep 2026 21:25:30 +0800 Subject: [PATCH 005/116] =?UTF-8?q?feat(skills):=20=E5=BA=A6=E9=87=8F?= =?UTF-8?q?=E5=9C=B0=E5=9F=BA=E7=A5=A8D=E2=80=94=E2=80=94=E8=81=9A?= =?UTF-8?q?=E5=90=88=E5=8F=A3=E5=BE=84=E9=87=8D=E5=AE=9A=E4=B9=89=EF=BC=9A?= =?UTF-8?q?successRate=20=E4=BD=BF=E7=94=A8=E5=BD=92=E5=9B=A0=20+=20exposu?= =?UTF-8?q?res=20=E6=9B=9D=E5=85=89=E5=88=86=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - successRate 归因从 matchedSkills(注入名单/曝光侧)改为 skill_used 事件 (使用侧):注入了没被读的 skill 不再背 WU 成败;legacy 无 workUnitId 事件 无法归因 → successRate=null(未知不编造) - 新增 exposures 字段:matchedSkills 命中计数(含未终态 WU),仅人审参考, 不参与 demote 判定;降级阈值与「只产提案」原则不变 --- .../skills/__tests__/skill-demotion.test.ts | 82 ++++++++++++++----- apps/api/src/modules/skills/skill-demotion.ts | 70 ++++++++++------ 2 files changed, 107 insertions(+), 45 deletions(-) diff --git a/apps/api/src/modules/skills/__tests__/skill-demotion.test.ts b/apps/api/src/modules/skills/__tests__/skill-demotion.test.ts index 0fa0e1d39..3be3871b6 100644 --- a/apps/api/src/modules/skills/__tests__/skill-demotion.test.ts +++ b/apps/api/src/modules/skills/__tests__/skill-demotion.test.ts @@ -3,7 +3,8 @@ * * tmp 目录构造:fixture events jsonl + FileStore WU 索引 + 磁盘 skill(SKILLS_DIR 风格 tmp 目录)。 * 覆盖: - * - 聚合:uses / lastUsedAt / successRate(done=成功,closed/blocked=不成功,未终态不计) + * - 聚合:uses / lastUsedAt / exposures;票 D 起 successRate 使用归因 + * (有 skill_used 的 WU 才计入:done=成功,closed/blocked=不成功,未终态不计) * - 规则边界:恰好 5 次使用 + 成功率恰好 0.3 → 不提案;29 天零使用 → 不提案 * - 幂等:重复扫描不重复产提案 * - approve:frontmatter status 改写且正文逐字节保留(hash 对比) @@ -118,29 +119,67 @@ afterEach(() => { }); describe('§10.6 aggregateSkillUsage', () => { - it('聚合 uses / lastUsedAt / successRate(done=成功,closed=不成功,in_review 不计)', async () => { + it('聚合 uses / lastUsedAt(channel 字段仅记录来源,不影响计数)', async () => { writeEvents([ skillUsedEvent('skill-a', '2026-07-19T10:00:00.000Z'), skillUsedEvent('skill-a', '2026-07-20T10:00:00.000Z'), - skillUsedEvent('skill-b', '2026-07-18T10:00:00.000Z'), - ]); - writeIndex([ - makeWu('wu-1', 'done', ['skill-a']), - makeWu('wu-2', 'closed', ['skill-a']), - makeWu('wu-3', 'in_review', ['skill-a']), // 未终态不计入 - makeWu('wu-4', 'blocked', ['skill-b']), - makeWu('wu-5', 'done'), // 无 matchedSkills + skillUsedEventForWu('skill-b', 'wu-4', '2026-07-18T10:00:00.000Z'), ]); + writeIndex([]); const usage = await aggregateSkillUsage({ eventsFile, fileStore }); const a = usage.get('skill-a')!; expect(a.uses).toBe(2); expect(a.lastUsedAt).toBe('2026-07-20T10:00:00.000Z'); - expect(a.successRate).toBe(0.5); // 1 done / 2 终态 + expect(a.exposures).toBe(0); const b = usage.get('skill-b')!; expect(b.uses).toBe(1); - expect(b.successRate).toBe(0); // 0 done / 1 终态(blocked) + }); + + it('票D: successRate 使用归因——有 skill_used 的 WU 才计入(done=成功,closed/blocked=不成功,未终态不计)', async () => { + writeEvents([ + skillUsedEventForWu('skill-a', 'wu-1', '2026-07-19T10:00:00.000Z'), // wu-1 done → 成功 + skillUsedEventForWu('skill-a', 'wu-2', '2026-07-19T11:00:00.000Z'), // wu-2 closed → 不成功 + skillUsedEventForWu('skill-a', 'wu-3', '2026-07-19T12:00:00.000Z'), // wu-3 in_review → 未终态不计 + skillUsedEventForWu('skill-b', 'wu-4', '2026-07-18T10:00:00.000Z'), // wu-4 blocked → 不成功 + ]); + writeIndex([ + makeWu('wu-1', 'done'), // 无 matchedSkills,但真用了 skill-a → 计入 + makeWu('wu-2', 'closed'), + makeWu('wu-3', 'in_review'), + makeWu('wu-4', 'blocked', ['skill-b']), + makeWu('wu-5', 'closed', ['skill-a']), // 注入了 skill-a 但没读(无 skill_used)→ 不计入成功率 + ]); + + const usage = await aggregateSkillUsage({ eventsFile, fileStore }); + expect(usage.get('skill-a')!.successRate).toBe(0.5); // 1 done / 2 终态(wu-5 不算) + expect(usage.get('skill-b')!.successRate).toBe(0); // 0 done / 1 终态(blocked) + }); + + it('票D: 仅 legacy 事件(无 workUnitId)→ successRate=null(无法归因,未知不编造)', async () => { + writeEvents([skillUsedEvent('skill-a', '2026-07-19T10:00:00.000Z')]); + writeIndex([makeWu('wu-1', 'done', ['skill-a'])]); + + const usage = await aggregateSkillUsage({ eventsFile, fileStore }); + expect(usage.get('skill-a')!.successRate).toBeNull(); + }); + + it('票D: exposures = matchedSkills 命中计数(曝光侧,仅展示;含未终态 WU)', async () => { + writeEvents([skillUsedEventForWu('skill-a', 'wu-1', '2026-07-19T10:00:00.000Z')]); + writeIndex([ + makeWu('wu-1', 'done', ['skill-a']), + makeWu('wu-2', 'in_review', ['skill-a']), + makeWu('wu-3', 'closed'), // 无 matchedSkills + makeWu('wu-4', 'done', ['skill-b']), // skill-b 仅曝光未使用 + ]); + + const usage = await aggregateSkillUsage({ eventsFile, fileStore }); + expect(usage.get('skill-a')!.exposures).toBe(2); + const b = usage.get('skill-b')!; + expect(b.exposures).toBe(1); + expect(b.uses).toBe(0); + expect(b.successRate).toBeNull(); }); it('uses 口径:带 workUnitId 按 (skill, WU) 去重;legacy 无 workUnitId 每条计 1', async () => { @@ -177,16 +216,14 @@ describe('§10.6 scanSkillDemotions 规则边界', () => { expect(result.proposals[0].suggestedStatus).toBe('archived'); }); - it('恰好 5 次使用 + 成功率恰好 0.3 → 不提案;成功率 < 0.3 → demote 提案', async () => { - // skill-edge: 5 次使用,10 个终态 WU 3 成功 → 0.3,不满足 < 0.3 - // skill-bad: 5 次使用,10 个终态 WU 2 成功 → 0.2,满足 + it('恰好 5 次使用 + 成功率恰好 0.3 → 不提案;成功率 < 0.3 → demote 提案(票D: 使用归因)', async () => { + // skill-edge: 10 个使用 WU(终态)3 成功 → 0.3,不满足 < 0.3 + // skill-bad: 10 个使用 WU(终态)2 成功 → 0.2,满足 const events: string[] = []; const wus: WorkUnitSnapshot[] = []; - for (let i = 0; i < 5; i++) { - events.push(skillUsedEvent('skill-edge', new Date(NOW - i * 1000).toISOString())); - events.push(skillUsedEvent('skill-bad', new Date(NOW - i * 1000).toISOString())); - } for (let i = 0; i < 10; i++) { + events.push(skillUsedEventForWu('skill-edge', `edge-${i}`, new Date(NOW - i * 1000).toISOString())); + events.push(skillUsedEventForWu('skill-bad', `bad-${i}`, new Date(NOW - i * 1000).toISOString())); wus.push(makeWu(`edge-${i}`, i < 3 ? 'done' : 'closed', ['skill-edge'])); wus.push(makeWu(`bad-${i}`, i < 2 ? 'done' : 'closed', ['skill-bad'])); } @@ -202,12 +239,13 @@ describe('§10.6 scanSkillDemotions 规则边界', () => { expect(result.proposals[0].stats.successRate).toBe(0.2); }); - it('uses < 5 即使成功率 0 → 不提案;successRate=null(无终态 WU)→ 不提案', async () => { - writeIndex([makeWu('wu-1', 'closed', ['skill-few'])]); + it('uses < 5 即使成功率 0 → 不提案;successRate=null(legacy 事件无法归因)→ 不提案', async () => { + // skill-few: 4 个使用 WU 全 closed → successRate=0 但 uses=4 < 5 + writeIndex(Array.from({ length: 4 }, (_, i) => makeWu(`few-${i}`, 'closed'))); writeSkill('skill-few', { ageDays: 5 }); writeSkill('skill-no-outcome', { ageDays: 5 }); writeEvents([ - ...Array.from({ length: 4 }, (_, i) => skillUsedEvent('skill-few', new Date(NOW - i * 1000).toISOString())), + ...Array.from({ length: 4 }, (_, i) => skillUsedEventForWu('skill-few', `few-${i}`, new Date(NOW - i * 1000).toISOString())), ...Array.from({ length: 6 }, (_, i) => skillUsedEvent('skill-no-outcome', new Date(NOW - i * 1000).toISOString())), ]); diff --git a/apps/api/src/modules/skills/skill-demotion.ts b/apps/api/src/modules/skills/skill-demotion.ts index 62491d514..408151381 100644 --- a/apps/api/src/modules/skills/skill-demotion.ts +++ b/apps/api/src/modules/skills/skill-demotion.ts @@ -1,14 +1,18 @@ /** * §10.6 skill 生命周期降级通路(聚合 + 降级提案)。 * - * 数据流: - * knowledge:skill_used 事件(payload: { skillName, workUnitId? },agent-loop.ts step 注入时发射) - * → 每 skill 使用次数 / lastUsedAt。口径(2026-07-27 校准):uses = 使用 WU 数—— - * 带 workUnitId 的事件按 (skill, workUnitId) 去重(同一 WU 多 step 注入只计一次); - * legacy 事件(无 workUnitId,source=skill-loader)每条计 1 次。 + * 数据流(skill 度量地基票 D,2026-09-15 口径重定义:曝光 vs 使用分离): + * knowledge:skill_used 事件(payload: { skillName, workUnitId?, channel? }) + * 发射点:skill-loader.ts(loadSkill 文件路径)/ skill-tools.ts(loadSkill cache + * 路径)/ skill-usage-scan.ts(WU done transcript 后验扫描,主口径)。 + * → uses = 使用 WU 数:带 workUnitId 的事件按 (skill, workUnitId) 去重(同一 WU + * 多渠道/多步只计一次);legacy 事件(无 workUnitId)每条计 1 次。 + * → successRate 使用归因:终态 WU 关联 = 该 WU 有该 skill 的 skill_used 事件 + * (注入了没被读的 skill 不背 WU 成败);done = 成功,closed/blocked = 不成功, + * 其余(active/in_review/unassigned 等)未终态不计入。 * WU 索引 metadata.matchedSkills(决策 7:agent-loop step 时匹配并落盘) - * → skill ↔ WU 关联;终态口径:done = 成功,closed/blocked = 不成功, - * 其余(active/in_review/unassigned 等)未终态不计入成功率。 + * → exposures 曝光计数(注入名单命中次数,含未终态 WU),仅人审参考, + * 不参与 demote 判定。 * 无关联终态 WU 时 successRate = null(未知,不参与 demote 判定,不编造)。 * * 降级规则(只产提案,绝不自动生效;人审通过才改 frontmatter): @@ -39,9 +43,11 @@ export const DEMOTE_MAX_SUCCESS_RATE = 0.3; export interface SkillUsageStats { uses: number; - /** 终态 WU 成功率;无关联终态 WU → null(未知) */ + /** 终态 WU 成功率(使用归因:有 skill_used 的 WU 才计入);无关联终态 WU → null(未知) */ successRate: number | null; lastUsedAt: string | null; + /** 曝光次数(注入名单 matchedSkills 命中计数,含未终态 WU);仅人审参考,不参与 demote 判定 */ + exposures: number; } export type DemotionKind = 'archive' | 'demote'; @@ -144,7 +150,7 @@ export interface AggregateOptions { } /** - * 聚合 knowledge:skill_used 事件 + WU 终态 → 每 skill { uses, successRate, lastUsedAt }。 + * 聚合 knowledge:skill_used 事件 + WU 终态 → 每 skill { uses, successRate, lastUsedAt, exposures }。 * 事件文件/索引不可读 → 返回空 Map,不抛错。 */ export async function aggregateSkillUsage(opts?: AggregateOptions): Promise> { @@ -155,7 +161,7 @@ export async function aggregateSkillUsage(opts?: AggregateOptions): Promise { let s = stats.get(name); if (!s) { - s = { uses: 0, successRate: null, lastUsedAt: null }; + s = { uses: 0, successRate: null, lastUsedAt: null, exposures: 0 }; stats.set(name, s); } return s; @@ -168,8 +174,10 @@ export async function aggregateSkillUsage(opts?: AggregateOptions): Promise(); + const usedWuIds = new Map>(); for (const row of rows) { if (row?.type !== 'knowledge:skill_used') continue; @@ -189,18 +197,24 @@ export async function aggregateSkillUsage(opts?: AggregateOptions): Promise s.lastUsedAt)) s.lastUsedAt = tsRaw; } - // WU 终态关联:metadata.matchedSkills 命中的 skill 记一次终态结果 - const outcomes = new Map(); + // WU 索引:终态状态表(successRate 使用归因)+ 曝光计数(matchedSkills 命中,仅展示) + const wuStatus = new Map(); const wus = await fileStore.getIndex().catch(() => []); for (const wu of wus) { - if (wu.status !== 'done' && wu.status !== 'closed' && wu.status !== 'blocked') continue; + wuStatus.set(wu.id, wu.status); if (!wu.metadata) continue; let matched: unknown; try { @@ -211,15 +225,25 @@ export async function aggregateSkillUsage(opts?: AggregateOptions): Promise 0) stats.get(name)!.successRate = success / final; } return stats; @@ -293,7 +317,7 @@ export async function scanSkillDemotions(opts?: ScanOptions): Promise<{ // 已归档的 skill 不再产提案 if (skill.status === 'archived') continue; - const stats = usage.get(skill.name) ?? { uses: 0, successRate: null, lastUsedAt: null }; + const stats = usage.get(skill.name) ?? { uses: 0, successRate: null, lastUsedAt: null, exposures: 0 }; const ageDays = (now - skill.mtimeMs) / 86_400_000; const statsWithAge = { ...stats, ageDays: Math.round(ageDays * 10) / 10 }; From 15af29bbfa45c6bd4414ce2dd2fbd227259f0fb0 Mon Sep 17 00:00:00 2001 From: dommaker Date: Tue, 15 Sep 2026 21:28:10 +0800 Subject: [PATCH 006/116] =?UTF-8?q?docs(skills):=20=E5=BA=A6=E9=87=8F?= =?UTF-8?q?=E5=9C=B0=E5=9F=BA=E7=A5=A8=E6=94=B6=E5=B0=BE=E2=80=94=E2=80=94?= =?UTF-8?q?CONTEXT.md=20=E5=90=8C=E6=AD=A5=EF=BC=88=E4=BD=BF=E7=94=A8/?= =?UTF-8?q?=E6=9B=9D=E5=85=89=E5=8F=A3=E5=BE=84=E3=80=81skill-usage-scan?= =?UTF-8?q?=E3=80=81level=20=E6=8F=90=E7=BA=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/modules/mcp/CONTEXT.md | 1 + apps/api/src/modules/skills/CONTEXT.md | 7 ++++--- apps/api/src/modules/transcripts/CONTEXT.md | 4 ++-- apps/api/src/modules/transcripts/transcript-archive.ts | 3 ++- packages/studio-shared/src/CONTEXT.md | 2 +- 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/api/src/modules/mcp/CONTEXT.md b/apps/api/src/modules/mcp/CONTEXT.md index 830e09582..3b7257c09 100644 --- a/apps/api/src/modules/mcp/CONTEXT.md +++ b/apps/api/src/modules/mcp/CONTEXT.md @@ -33,6 +33,7 @@ > #149(2026-08-15):`knowledge.tools.ts`(5 个知识工具,全是 document-store CRUD)随 document-store 退役删除。 > 2026-08-19:checkGuardrail / getSandboxLevel 随 harness 1.2.0 删除 InputGuardrail/OutputGuardrail/Sandbox(ADR-0003)移除(21 → 19)。 > #172(2026-08-15):`loadSkill` 入参加可选 `workUnitId`(透传 skill-loader,skill_used 事件补 WU 归属,#60 决策 Q2)。 +> 度量地基票 A(2026-09-15):`loadSkill` cache 命中路径(常态路径)也发射 `knowledge:skill_used`(channel=loadSkill,level=info)——此前仅文件回退路径发射,事件结构性为 0;文件路径维持 skill-loader.ts 发射,每调用恰好一次。 ### 核心导出 diff --git a/apps/api/src/modules/skills/CONTEXT.md b/apps/api/src/modules/skills/CONTEXT.md index c931a07a7..0a1bb93d4 100644 --- a/apps/api/src/modules/skills/CONTEXT.md +++ b/apps/api/src/modules/skills/CONTEXT.md @@ -6,8 +6,8 @@ skills 模块负责技能(Skill)的完整生命周期管理,包括基于 ### 词汇表 -- **Skill 加载(loaded)**:agent 经 MCP 工具 `loadSkill` 显式拉取 SKILL.md 正文。`knowledge:skill_used` 事件的唯一语义(#60 决策,2026-08-09;#172 落地):发射点 = `skill-loader.ts` 的 `loadSkill`,payload 携带 `workUnitId`(调用方已知时),envelope level=debug。 -- **Skill 曝光(exposed)**:已废除的概念。旧口径把「skill 索引条目进入 prompt」记为 skill_used,实测为常量集合(14/20 skill,零信息量),发射已删除(#60 决策,#172 落地,prompt-composer.ts)。基于曝光口径的降级提案统计无效,修复归知识飞轮 handoff。 +- **Skill 使用(used)**:agent 在 WU 执行期间获取 SKILL.md 全文,渠道无关(loadSkill / Read / cat 均算)。`knowledge:skill_used` 事件(#60 决策,2026-08-09;#172 落地)发射点三处:`skill-loader.ts`(loadSkill 文件路径)、`mcp/skill-tools.ts`(loadSkill cache 路径,度量地基票 A 补)、`skill-usage-scan.ts`(WU done transcript 后验扫描,主口径——agent 直读文件绕开 loadSkill 时仍能在 rawOutput 路径痕迹中测到)。payload 携带 `workUnitId`(已知时)+ `channel`;envelope level=info(度量地基票 C 提为 signal:热 30 天 → 月度 gz 归档,不再随噪声 7 天滚)。 +- **Skill 曝光(exposed)**:曝光事件发射已废除(旧口径把「skill 索引条目进入 prompt」记为 skill_used,实测为常量集合零信息量,#60 决策,#172 落地)。度量地基票 D 起曝光以 `exposures` 统计字段恢复(matchedSkills 命中计数),仅降级提案人审参考,不参与判定;successRate 归因同步从曝光改为使用(有 skill_used 的 WU 才背成败)。 ### 核心导出 @@ -25,7 +25,8 @@ skills 模块负责技能(Skill)的完整生命周期管理,包括基于 | GET /skills/manifest | routes.ts | #462:skills MANIFEST 只读清单(name/description/agentTypes/triggers),角色编辑 UI 的 skill 多选数据源;loop-consumer 不进候选(与注入口径一致);注册在 /:id 之前 | | SkillRecord, SkillCreateInput, SkillUpdateInput | skill-store.ts | 技能元数据的类型定义及文件型 CRUD | | LoadedSkill, SessionSkillState, LoadSkillOptions | skill-loader.ts | 技能加载相关的类型定义;#361 起磁盘加载归 `@dommaker/studio-skill` 包加载器(loadSingle),本文件只留会话级 load 缓存 + skill_used 事件发射(第三份 frontmatter 解析器已删) | -| aggregateSkillUsage, scanSkillDemotions, approveDemotion, rejectDemotion, DemotionProposalStore | skill-demotion.ts | §10.6 降级通路:skill_used 事件 + WU 终态聚合 → 降级提案(只提案不自动生效;approve 改 frontmatter status,正文逐字节保留);提案存 ~/.studio/data/skills/demotion-proposals.json | +| aggregateSkillUsage, scanSkillDemotions, approveDemotion, rejectDemotion, DemotionProposalStore | skill-demotion.ts | §10.6 降级通路:skill_used 事件 + WU 终态聚合 → 降级提案(只提案不自动生效;approve 改 frontmatter status,正文逐字节保留);票 D 起 successRate 使用归因(usedWuIds ∩ 终态)+ exposures 曝光计数仅展示;提案存 ~/.studio/data/skills/demotion-proposals.json | +| SkillUsageScanner, initSkillUsageScan, extractUsedSkillNames | skill-usage-scan.ts | 度量地基票 B:WU done → readTranscript 扫 rawOutput 中 `skills//SKILL.md` 痕迹 → 每 skill 一条 skill_used(channel=transcript,level=info);纯确定性零 LLM,eventBus 钩子(index.ts 挂载),聚合侧 (skill, WU) 去重吸收重复触发 | | router | skill-demotion-routes.ts | 降级提案列表(?scan=true 触发扫描)/ 审批路由,挂载至 /api/v1/skills/demotion-proposals(先于 /api/v1/skills 注册) | ### 依赖关系 diff --git a/apps/api/src/modules/transcripts/CONTEXT.md b/apps/api/src/modules/transcripts/CONTEXT.md index bb8008f45..816f3ed12 100644 --- a/apps/api/src/modules/transcripts/CONTEXT.md +++ b/apps/api/src/modules/transcripts/CONTEXT.md @@ -2,7 +2,7 @@ ### 职责 -transcript 归档器(#97,#88 子票):把会话原文落盘到数据区(经 `studioDir()`/`studioPath()`),供三个消费方共用——#99 WU 收尾批量提取(要全文)、handoff 摘要(要对话)、#85 执行质量评估(要执行痕迹)。本模块只建归档器 + 读取接口,不实现消费方提取逻辑。 +transcript 归档器(#97,#88 子票):把会话原文落盘到数据区(经 `studioDir()`/`studioPath()`),供四个消费方共用——#99 WU 收尾批量提取(要全文)、handoff 摘要(要对话)、#85 执行质量评估(要执行痕迹)、skill 度量地基票 B skill 使用扫描(skills/skill-usage-scan.ts,扫 rawOutput 中 SKILL.md 路径痕迹)。本模块只建归档器 + 读取接口,不实现消费方提取逻辑。 另提供 HTTP 只读查看路由(#174,#60 C5):`GET /api/v1/transcripts/:workUnitId`(认证,query `offset`/`limit` 分页,上限 100 — #359 起统一 `parsePagination`,原上限 50),经 `readTranscript` 读全文后 slice,文件不存在返回 200 空列表;workUnitId 拒绝含 `/`、`..` 的 id(防路径穿越)。注册见 `route-registry.ts`;前端查看器 `apps/web/src/components/workunit/TranscriptViewer.tsx`。 @@ -40,7 +40,7 @@ session:start/end 事件链路(#174):agent-loop 把 `transcriptPath(wu.id) - `apps/api/src/modules/agents/loop/agent-loop.ts`(写入方:每步成功执行后 `appendTranscriptStep`) **下游**: -- #99 WU 收尾批量提取(`readTranscript` 读取方,已落地:role-memory/completion-extraction.ts)、handoff 摘要、#85 执行质量评估(后续实现) +- #99 WU 收尾批量提取(`readTranscript` 读取方,已落地:role-memory/completion-extraction.ts)、handoff 摘要、#85 执行质量评估(后续实现)、skill 使用扫描(已落地:skills/skill-usage-scan.ts,度量地基票 B) ### 注意事项 diff --git a/apps/api/src/modules/transcripts/transcript-archive.ts b/apps/api/src/modules/transcripts/transcript-archive.ts index 6d846b3be..fe864d340 100644 --- a/apps/api/src/modules/transcripts/transcript-archive.ts +++ b/apps/api/src/modules/transcripts/transcript-archive.ts @@ -1,10 +1,11 @@ /** * transcript-archive — transcript 归档器(#97,#88 子票) * - * 会话原文落盘到数据区(经 studioDir()/studioPath()),供三个消费方共用: + * 会话原文落盘到数据区(经 studioDir()/studioPath()),供四个消费方共用: * - #99 WU 收尾批量提取(要全文) * - handoff 摘要(要对话) * - #85 执行质量评估(要执行痕迹) + * - skill 度量地基票 B:skill 使用扫描(skills/skill-usage-scan.ts) * * 数据源选型:agent-loop 每步 `result.rawOutput`(raw CLI stdout,provider 无关)。 * 单一来源同时满足三方:rawOutput 含完整对话 + 工具调用/执行痕迹,非摘要级截断, diff --git a/packages/studio-shared/src/CONTEXT.md b/packages/studio-shared/src/CONTEXT.md index 0c1af16b7..a82e064b5 100644 --- a/packages/studio-shared/src/CONTEXT.md +++ b/packages/studio-shared/src/CONTEXT.md @@ -22,7 +22,7 @@ | `studioDir`, `studioPath`, `defaultStudioDir`, `warnIfNonProdUsesProdRoot` | config/studio-dir(`./studio-dir` 子路径入口) | 数据根解析单入口(issue #89):STUDIO_HOME 优先,缺省 ~/.studio;全部数据区读写必须经此,禁止新增 `os.homedir() + '.studio'` 硬编码 | | `createSettledTracker` / `SettledTracker` | utils/settled-tracker.ts | #228 确定性等待原语(#158 先例抽取):fire-and-forget 异步链(事件订阅消费 / best-effort 收尾)的在途登记 `track` + `waitForSettled` 等待(while 循环兜底级联),供测试替代盲等;消费方:pmo/progress-rollup、workunit.service(reviewPassed 收尾)、agents/loop/review-dispatcher、pmo/analysis-handoff、pmo/map-opening、pmo/spec-materialization(后两者 2026-08-26 补:测试删目录前等在途链落定,消 /tmp 复活竞态) | | `setReadMetricsSink`, `runWithLoopLabel`, `readMetricsBegin`, `emitReadMetric` | read-metrics.ts | #323 读口量化测量 sink:模块级 sink 默认 null(关闭 = 读口一次 if 外零开销);`runWithLoopLabel` 基于 AsyncLocalStorage 做循环归因(无 label → 'unlabeled');file-store 四读口(readJson/readJsonl/readIndexForQuery/readdirCached)内计时埋点(statMs/readParseMs/cloneMs/cacheHit),锁内裸读路径不埋 | -| `writeStudioEvent`, `readStudioEvents`, `parseStudioEventPayload`, `getStudioEventTime`, `resolveStudioEventsFile`, `isEmptyEventPayload`, `defaultStudioEventLevel` | studio-events.ts | #361 D18 事件唯一写口(自 apps/api utils 下沉):StudioEvent envelope `{ type, source?, payload(JSON string), createdAt, level? }`;空 payload 拒收、永不抛出、knowledge:*/tool:call 缺省 debug 级;apps/api utils/studio-events.ts 是兼容薄壳,packages 直用本入口。消费方:studio-agent output-capture 5 emit、api 30+ 模块 | +| `writeStudioEvent`, `readStudioEvents`, `parseStudioEventPayload`, `getStudioEventTime`, `resolveStudioEventsFile`, `isEmptyEventPayload`, `defaultStudioEventLevel` | studio-events.ts | #361 D18 事件唯一写口(自 apps/api utils 下沉):StudioEvent envelope `{ type, source?, payload(JSON string), createdAt, level? }`;空 payload 拒收、永不抛出、knowledge:*/tool:call 缺省 debug 级;显式传 level(含 'info')必落 envelope 字段(度量地基票 C:否则轮转分类回退 type 默认分级,提级失效——skill_used 即靠显式 info 提为 signal);apps/api utils/studio-events.ts 是兼容薄壳,packages 直用本入口。消费方:studio-agent output-capture 5 emit、api 30+ 模块 | | `isTestEnv`, `testTmpRoot`, `resolveStudioLogsDir`, `resolveStudioLogFile` | log-path.ts | #361 测试/生产日志路径隔离规则唯一属主(自 apps/api utils 下沉):VITEST/NODE_ENV=test → os.tmpdir()/studio-test-logs,生产 → ~/.studio/logs;apps/api utils/studio-log-path.ts 为兼容薄壳 | | `matchResolutionPatterns`, `isActionableMaturity`, `formatRkbHint` | resolutions.ts | #361 RKB 匹配核心(原 studio-agent runner-output 与 api resolution.service 逐字重复段收一):regex(i) 失败回退小写子串包含、verified/canonical 成熟度闸门、RKB hint markdown 格式化;文档扫描与 fix 提取口径留在各调用方 | | `readJsonlTail`, `iterateJsonlLinesBackward` | jsonl-tail.ts | 通用 JSONL 尾部倒读原语(架构评审候选 2,泛化自 apps/api studio-events-tail):「取尾部 N 行 / 倒扫早停 / 游标翻旧页」的 O(增量) 读口,替代「取尾部摊成全量 readJsonl」;字节级行切分(跨块多字节安全),直读磁盘不进 jsonlCache(真源唯一);消费方:getChannelVersion / getMessagesSince(file-store.ts)、apps/api events 薄封装 | From 388e9dfbc6abd0521a7e7b8fa459f72d2fa43b54 Mon Sep 17 00:00:00 2001 From: dommaker Date: Tue, 15 Sep 2026 21:57:30 +0800 Subject: [PATCH 007/116] =?UTF-8?q?docs(adr):=20=E6=9E=B6=E6=9E=84?= =?UTF-8?q?=E8=AF=84=E5=AE=A1=20grilling=20=E5=AE=9A=E6=A1=88=E2=80=94?= =?UTF-8?q?=E2=80=94C1=20=E5=86=99=E8=B7=AF=E5=BE=84=E5=8D=95=E5=8F=A3(#53?= =?UTF-8?q?8)=20+=20B1=20web=20=E9=97=B8=E9=97=A8=E5=86=99=E6=A8=A1?= =?UTF-8?q?=E5=9D=97(#545)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- docs/adr/2026-09-15-web-gate-write-module.md | 28 ++++++++++++++ .../2026-09-15-workunit-write-single-mouth.md | 38 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 docs/adr/2026-09-15-web-gate-write-module.md create mode 100644 docs/adr/2026-09-15-workunit-write-single-mouth.md diff --git a/docs/adr/2026-09-15-web-gate-write-module.md b/docs/adr/2026-09-15-web-gate-write-module.md new file mode 100644 index 000000000..dcf6bc96f --- /dev/null +++ b/docs/adr/2026-09-15-web-gate-write-module.md @@ -0,0 +1,28 @@ +# web 闸门写路径单模块(2026-09-15) + +> 来源:架构评审候选 B1(#545)grilling 定案。 +> 状态:**accepted**(实现未落地——按票执行)。 + +## 背景 + +闸门动作(reviewPassed / reviewRejected / confirmPending→transitionStatus)的写路径 =「一次 API 调用 + 响应体写回宿主状态 + SSE 兜底」,五处宿主各抄一份、写回机制三样并存:ChannelDetailPage(channelWorkStore.applyGateResult 直替)、WorkUnitDrawer(setWu 本地直替)、WorkUnitDetailPage(reloadWu 全量重拉)、WorkUnitListPage(workunitStore 三动作 + loadWorkUnits 全量重拉)、DeliveryPanel(onRefresh 刷 PMO 台账)。WuGateActions 是共享壳,但写路径刻意经 props 注入(E2-4 当时决策)——响应加一个字段要动五处。 + +## 决策 + +1. **唯一写模块 = 纯函数核心**(非 hook、非组件内置):调闸门 API → 响应快照落点分发。WuGateActions 组件直接消费(onReviewPassed / onReviewRejected / onConfirmPending 三个注入 props 删除),DeliveryPanel 直调同一核心。 +2. **落点集合(冻结契约)**:响应快照双写——`channelWorkStore.applyWorkunitSnapshot(wu.channelId, wu)` + `markSuggestionsDirty(channelId)`(精确镜像 useChannelWorkStoreSync 对 status_changed 的路由)+ `workunitStore.applyWorkunitEvent(wu, { insertIfMissing: false })`(仅存量行 upsert;闸门作用于已存在 WU,不插行)。主动写(API 响应,本票)与被动写(SSE 事件,#549)收敛到同一组 store action——这就是「单份 upsert」的完整含义。 +3. **宿主本地落点 = 单一可选 sink `onUpdated?(wu)`**:store 写完后调用。drawer 的 setWu、详情页本地 wu、PMO 的 onRefresh 都在各自 onUpdated 里做。错误 rethrow——toast / 弹窗错误展示留在 WuGateActions 与 DeliveryPanel(现状各自内聚,不搬家)。 +4. **旧路径全删不留委托**:workunitStore 的 reviewPassed / reviewRejected / confirmPending(API + 全量重拉三件套)、channelWorkStore.applyGateResult(applyWorkunitSnapshot 的裸副本,统一走后者,顺带白赚坏负载守卫)。ListPage 闸门动作从全量重拉变存量行 upsert(页码/total 不重算;SSE 兜底与重连 refetch 不变)——此行为变化即收口本体。 +5. **B1/B5 分工**:B1(#545)= 主动写路径;B5(#549)= 被动写路径(SSE 接线收口)+ ListPage/AuditLogsPage 页面收口。两票并行可做(动 ListPage 不同区域),实际撞车退化为 B5 先。两票 brief 均以本 ADR 决策 2 为落点口径——先做完的不锁死另一个。 + +## 否决的备选(勿再提) + +- **hook 壳**:DeliveryPanel 在事件回调里调(非渲染期),hook 覆盖不了;4/5 宿主本就经 WuGateActions 渲染。 +- **WuGateActions 保留可选 handler override**:无 override 场景,留着 = 双路径。 +- **单写一个 store + 靠 SSE 兜底**:SSE 断连窗口内无落点,且与被动写口径分叉。 +- **onUpdated + onSettled 双 sink**:PMO 刷新在 onUpdated 里顺手做,一个够。 +- **旧 store 动作留薄委托**:留双路径,违 no_facade_without_migration。 + +## SSE 双保险保持 + +闸门 API 后 status_changed 事件照常到达(E2-4 决策 8 口径),与响应体写回同构 upsert 幂等(markSuggestionsDirty 双触发由 store 既有防抖窗口合并);写模块不依赖 SSE 在场,SSE 也不取代响应体写回。重连 refetch 自愈不变。 diff --git a/docs/adr/2026-09-15-workunit-write-single-mouth.md b/docs/adr/2026-09-15-workunit-write-single-mouth.md new file mode 100644 index 000000000..c835b8949 --- /dev/null +++ b/docs/adr/2026-09-15-workunit-write-single-mouth.md @@ -0,0 +1,38 @@ +# WorkUnit 写路径单口(2026-09-15) + +> 来源:架构评审候选 C1(写路径 seam 泄漏收口)grilling 定稿。 +> 状态:**accepted**(实现未落地——按票执行,见「实施切票」)。 + +## 背景 + +4 个写点绕过 WorkUnitService 直摸 FileStore 写原语:discord/routes(按钮 retry/abandon + `/studio stop`)、agents/monitor/monitor-lifecycle(90 天 TTL)、agents/ops/system-health(30 天 GC,**无生产接线,仅测试可达**)、triggers/trigger-action(UPDATE 动作,**当前零注册消费者**)。 + +#170 已统一锁内成对原语(commitSnapshot/commitRemoval 同一把 workunits flock),这四点绕过的是 **service 层语义**:状态机校验、workunit.status_changed 广播、wu-closure 双出声。已发生的实际损害:discord closeAndEmit 的 metadata 整写覆盖摧毁全部既有 metadata、不写 closedAt、不发 status_changed;墓碑事件行(closed + deleted:true)在三个调用方手抄;删除零事件 → 前端列表/抽屉悬挂到 SSE 重连 refetch。 + +## 决策 + +1. **WU 全部写路径唯一出口 = WorkUnitService 语义方法**(create/transitionStatus/update/delete/unclaim/claim + wu-closure 统一关闭 + updateMetadata 增量)。业务模块直调 FileStore 写原语即违规。#523 auditor 建单收口(消灭第二个建单口)的推广。**语义化入口,不开通用 safeCommit**——通用口过不了删除测试(删掉它复杂度只是搬家),且不知道调用意图就挂不住校验。 +2. **discord**:abandon / `/studio stop` → `closeWorkUnitWithNotice`(原因走 opts,补 closedAt,落 workunit:closed 记录,频道出声);retry → `unclaim`(回 unassigned + status_changed)+ 额外字段 updateMetadata 合并。停发 legacy `events:goal-execution` 事件(Goal 体系已退役)。 +3. **GC / TTL**:筛选逻辑留调用方,删除循环调 `service.delete(id, { reason })`;delete 加 reason 参数。**不开批量方法**——当前只有一个活调用方(monitor TTL),一个调用方不成缝,等第二个真调用方出现再议。 +4. **墓碑格式归存储层**:events 流墓碑事件行(closed + deleted:true)由 service.delete 单点构造,调用方不自拼(现状三处手抄收一处)。 +5. **删除出声**:service.delete 发 `workunit:removed`(eventBus → SSE 桥转发白名单加条目),负载带 id + channelId(SSE 事件负载契约)。**只走事件流/SSE,不进频道**——GC 是数据卫生不是对账修复,不适用 #523「系统自作主张改了就该说」的频道口径。web 消费:workunitStore / channelWorkStore 各加删行分支。 +6. **trigger UPDATE**:禁改 status(TriggerStore 注册校验拒 + executeUpdateAction 执行守卫,双保险),其余字段走 patchSnapshot 白名单,metadata 走 updateMetadata 合并。理由:当前零消费者(default-triggers 全 EXECUTE/CREATE,trigger store 空),YAGNI;将来真需要状态变更再转 transitionStatus,成本不变。 +7. **TTL 筛选语义不动**(90 天无状态过滤照旧)——本收口只搬家不改行为,语义问题独立开票。 + +## 否决的备选(勿再提) + +- **通用 safeWrite / commitSnapshot 包装口**:浅模块换名,删掉后复杂度只是搬家,校验挂不住意图。 +- **批量 removeStale service 方法**:只有一个活调用方,不成缝。 +- **GC 删除时频道出声**:数据卫生 ≠ 对账修复,频道出声口径(#516/#523)不适用。 +- **trigger UPDATE 支持状态变更**:零消费者先收紧,将来按需开。 + +## 不修(另议,勿并进收口票) + +- **关闭路径不广播**:wu-closure 不发 status_changed → 前端列表不实时刷新(discord abandon 收口后同现状,不回退也不提前修)= 架构评审候选 C2(双关闭路径合并)的开放问题。 +- **monitor TTL「满 90 天删任何状态(含 active/blocked)」是否合理**:needs-triage。 +- **system-health runGC 无生产接线**:接线 or 删除,needs-triage(收口票只迁它的写路径,不动接线状态)。 + +## 实施切票 + +1. **C4 前置票(机械)**:workunit-api.test 的 CI skip 根因排查 + 复活;workunit.types.ts L244-306 死 DTO 副本删除(三轴 grep 验证零消费);两个占位桩测试文件处理。 +2. **C1 主票**:四点按点 phase commit。测试面:service.delete 不变式(墓碑 + workunit:removed + reason);trigger-update 既有「可写 status」断言随决策 6 反转(语义变更非删测试);discord 按钮两路径从零补测试;system-health 测试补 WU 删除分支断言(现状未断言)。 From 2ca76cfd985547f9ccd777bfdb597ed4e1986ca6 Mon Sep 17 00:00:00 2001 From: dommaker Date: Tue, 15 Sep 2026 22:32:31 +0800 Subject: [PATCH 008/116] =?UTF-8?q?refactor(workunit):=20=E5=88=A0?= =?UTF-8?q?=E9=99=A4=20types.ts=20=E6=AD=BB=20DTO=20=E5=89=AF=E6=9C=AC=20+?= =?UTF-8?q?=20=E5=A4=8D=E6=B4=BB=20workunit-api=20CI=20=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=20+=20=E5=88=A0=E5=8D=A0=E4=BD=8D=E6=A1=A9=20(#537)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types.ts 的 CreateWorkUnitInput/UpdateWorkUnitInput/WorkUnitData 漂移副本删除, 唯一正本收口到 workunit-crud.ts(经 workunit.service.js re-export 不变); 漂移差量(WorkUnitData.closedAt #327 / claimable #318)并入正本; 模块内两个消费方(mappers/wu-dependencies)type import 重指向 workunit-crud.js - workunit-api.test.ts 移除 isCI describe.skip——跳过根因(75e0ba8a,Prisma 时代 CI 文件系统不可写)已随 FileStore tmpDir 隔离消除,CI=1 下 50/50 全绿 - 删除两个 158 字节占位桩(workunit.routes.test.ts / workunit.service.test.ts), 真实覆盖由 workunit-api.test.ts 承担;manual-release 测试注释同步 - CONTEXT.md 同步 DTO 唯一定义点 零业务逻辑改动(纯删除 + 测试恢复 + type import 重指向) --- apps/api/src/modules/workunit/CONTEXT.md | 4 +- .../workunit/__tests__/workunit-api.test.ts | 5 +- .../__tests__/workunit-manual-release.test.ts | 2 +- .../__tests__/workunit.routes.test.ts | 5 -- .../__tests__/workunit.service.test.ts | 5 -- .../api/src/modules/workunit/workunit-crud.ts | 5 ++ .../src/modules/workunit/workunit.mappers.ts | 2 +- .../src/modules/workunit/workunit.types.ts | 67 +------------------ .../src/modules/workunit/wu-dependencies.ts | 2 +- 9 files changed, 13 insertions(+), 84 deletions(-) delete mode 100644 apps/api/src/modules/workunit/__tests__/workunit.routes.test.ts delete mode 100644 apps/api/src/modules/workunit/__tests__/workunit.service.test.ts diff --git a/apps/api/src/modules/workunit/CONTEXT.md b/apps/api/src/modules/workunit/CONTEXT.md index 2596f27f3..13b5bb34e 100644 --- a/apps/api/src/modules/workunit/CONTEXT.md +++ b/apps/api/src/modules/workunit/CONTEXT.md @@ -7,9 +7,9 @@ WorkUnit 核心域: 任务单元 CRUD、认领与状态机; F5 双向沟通的 N ### 核心导出 - `workunit.service.ts` - Service 门面: CRUD + Claim + 状态机, claim 写 5min 租约(持有方 30s 心跳推前), 收口 reviewPassed/reviewRejected/attestation 幂等补写/markMergeConflict/blockForManualRelease。导出 `waitForReviewPassSettled()` 供测试等待异步自动合并收尾。 -- `workunit.types.ts` - 类型契约 + 状态机表: WorkUnitMetadata / DTO / VALID_TRANSITIONS / TYPE_VALID_TRANSITIONS + resolveValidTransitions / WU_LEASE_TTL_MS(5min) / ANALYSIS_TASKS_MAX(8) / INSPECTION_OPPORTUNITIES_MAX / MANUAL_GATE_TYPES(#471: 人工 L3 验收类 = analysis/decision/spec/plan, 不派自动评审 + 豁免 l2 + 不出评审建议片的单一事实源) / PLAN_STEP_LIMIT(#471: plan 步数额度 60, agent-loop 前置守卫与 waiting-input 续期双消费)。 +- `workunit.types.ts` - 类型契约 + 状态机表: WorkUnitMetadata / VALID_TRANSITIONS / TYPE_VALID_TRANSITIONS + resolveValidTransitions / WU_LEASE_TTL_MS(5min) / ANALYSIS_TASKS_MAX(8) / INSPECTION_OPPORTUNITIES_MAX / MANUAL_GATE_TYPES(#471: 人工 L3 验收类 = analysis/decision/spec/plan, 不派自动评审 + 豁免 l2 + 不出评审建议片的单一事实源) / PLAN_STEP_LIMIT(#471: plan 步数额度 60, agent-loop 前置守卫与 waiting-input 续期双消费)。DTO(CreateWorkUnitInput/UpdateWorkUnitInput/WorkUnitData)唯一正本在 workunit-crud.ts(#537 删漂移副本)。 - `workunit.mappers.ts` - 快照 <-> DTO 转换: snapshotToData / inputToSnapshot / patchSnapshot。 -- `workunit-crud.ts` - WorkUnitCrudService(基类): CRUD + Claim(flock 悲观互斥锁) + 快照转换 + publishStatusChanged + aggregateParentStatus。#464 出声: create 落 pending 且带频道 → 发「待确认」milestone 系统消息(atHuman + 行动中心通知双写); expandRoutingHead 未配置 implement 路由也发频道提示(此前仅「配错」出声, 未配置静默);#525 P2-5 起该提示挂 #497 同款 1h 冷却闸(同频道同档首次照出、窗内不重复, 闸实现在 channels/routing.ts)。 +- `workunit-crud.ts` - WorkUnitCrudService(基类): CRUD + Claim(flock 悲观互斥锁) + 快照转换 + publishStatusChanged + aggregateParentStatus。DTO 三件套(CreateWorkUnitInput/UpdateWorkUnitInput/WorkUnitData)唯一定义点, 经 workunit.service.js re-export 供跨模块消费。#464 出声: create 落 pending 且带频道 → 发「待确认」milestone 系统消息(atHuman + 行动中心通知双写); expandRoutingHead 未配置 implement 路由也发频道提示(此前仅「配错」出声, 未配置静默);#525 P2-5 起该提示挂 #497 同款 1h 冷却闸(同频道同档首次照出、窗内不重复, 闸实现在 channels/routing.ts)。 - `in-review-inbox.ts` - #464 无频道 in_review(非 analysis) → Web「需要处理」收件箱: 订阅 workunit.status_changed, 命中即 dispatchMonitorAlerts(warning, source=in_review_orphan——与 #181 review_stagnation 滞留探针区分, relatedTaskIds 带 wuId → 行动中心直链); analysis 走 analysis-handoff 既有路径不重复。index.ts 启动注册, 幂等。 - `workunit.routes.ts` - API 路由。 - `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 复用原语。 diff --git a/apps/api/src/modules/workunit/__tests__/workunit-api.test.ts b/apps/api/src/modules/workunit/__tests__/workunit-api.test.ts index 34905e53e..d722ec6d7 100644 --- a/apps/api/src/modules/workunit/__tests__/workunit-api.test.ts +++ b/apps/api/src/modules/workunit/__tests__/workunit-api.test.ts @@ -2,9 +2,6 @@ // Tests: CRUD + Claim + State machine + Review + from-message import { describe, it, expect, afterAll, beforeAll, vi } from 'vitest'; -const isCI = !!process.env.CI; -const describeIf = isCI ? describe.skip : describe; - import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; @@ -12,7 +9,7 @@ import { eventBus, FileStore } from '@dommaker/studio-shared'; import { WorkUnitService } from '../workunit.service.js'; import { channelMessageService } from '../../channels/channel-message.service.js'; -describeIf('WorkUnit API service', () => { +describe('WorkUnit API service', () => { let tmpDir: string; let fileStore: FileStore; let service: WorkUnitService; diff --git a/apps/api/src/modules/workunit/__tests__/workunit-manual-release.test.ts b/apps/api/src/modules/workunit/__tests__/workunit-manual-release.test.ts index 0cbcc221b..476b0983b 100644 --- a/apps/api/src/modules/workunit/__tests__/workunit-manual-release.test.ts +++ b/apps/api/src/modules/workunit/__tests__/workunit-manual-release.test.ts @@ -1,7 +1,7 @@ // 2026-07 PMO-flow UX(§4 terminate 语义修正):WorkUnitService.blockForManualRelease // 覆盖:active/unassigned → blocked(assigneeId/claimedAt 清空 + manualRelease 留痕 + 既有 metadata 保留)、 // 终态(done/closed)不动、WU 不存在抛错。 -// 模式同 workunit.service.test.ts:真实 FileStore(tmpdir)+ 真实 WorkUnitService。 +// 模式同 workunit-api.test.ts:真实 FileStore(tmpdir)+ 真实 WorkUnitService。 import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; diff --git a/apps/api/src/modules/workunit/__tests__/workunit.routes.test.ts b/apps/api/src/modules/workunit/__tests__/workunit.routes.test.ts deleted file mode 100644 index cf7d868d2..000000000 --- a/apps/api/src/modules/workunit/__tests__/workunit.routes.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { describe, it } from 'vitest'; - -describe('workunit routes', () => { - it.skip('placeholder — see workunit-api.test.ts for coverage', () => {}); -}); diff --git a/apps/api/src/modules/workunit/__tests__/workunit.service.test.ts b/apps/api/src/modules/workunit/__tests__/workunit.service.test.ts deleted file mode 100644 index 66c128abc..000000000 --- a/apps/api/src/modules/workunit/__tests__/workunit.service.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { describe, it } from 'vitest'; - -describe('workunit service', () => { - it.skip('placeholder — see workunit-api.test.ts for coverage', () => {}); -}); diff --git a/apps/api/src/modules/workunit/workunit-crud.ts b/apps/api/src/modules/workunit/workunit-crud.ts index 35f9af7d5..01674c474 100644 --- a/apps/api/src/modules/workunit/workunit-crud.ts +++ b/apps/api/src/modules/workunit/workunit-crud.ts @@ -77,6 +77,11 @@ export interface WorkUnitData { updatedAt: Date; claimedAt: Date | null; completedAt: Date | null; + /** #327(additive):关闭时刻——归档计龄锚点。仅 closed 状态有值,reopen 清除 */ + closedAt?: Date | null; + /** #318(additive,ADR D2):可认领标记——仅事件负载(workunit.created/status_changed)与 GET / 列表项附带; + unassigned 且无未了结依赖才 true,其余状态恒 false;snapshotToData 本体不产此字段 */ + claimable?: boolean; } /** diff --git a/apps/api/src/modules/workunit/workunit.mappers.ts b/apps/api/src/modules/workunit/workunit.mappers.ts index 527c4ee8c..70d083c38 100644 --- a/apps/api/src/modules/workunit/workunit.mappers.ts +++ b/apps/api/src/modules/workunit/workunit.mappers.ts @@ -4,7 +4,7 @@ */ import type { WorkUnitSnapshot } from '@dommaker/studio-shared'; -import type { CreateWorkUnitInput, UpdateWorkUnitInput, WorkUnitData } from './workunit.types.js'; +import type { CreateWorkUnitInput, UpdateWorkUnitInput, WorkUnitData } from './workunit-crud.js'; // ── 转换函数 ── diff --git a/apps/api/src/modules/workunit/workunit.types.ts b/apps/api/src/modules/workunit/workunit.types.ts index 356cbf852..b91f62e08 100644 --- a/apps/api/src/modules/workunit/workunit.types.ts +++ b/apps/api/src/modules/workunit/workunit.types.ts @@ -1,6 +1,7 @@ /** * WorkUnit 类型契约 + 状态机表/超时常量(工单 30 自 workunit.service.ts 头部抽出,纯搬运零逻辑变更)。 - * 内容:WorkUnitMetadata / 输入输出 DTO / VALID_TRANSITIONS(+ #108 按 type 覆盖表 DECISION_SPEC_TYPES/TYPE_VALID_TRANSITIONS)/ 租约常量(#178 WU_LEASE_TTL_MS)/ ANALYSIS_TASKS_MAX。 + * 内容:WorkUnitMetadata / VALID_TRANSITIONS(+ #108 按 type 覆盖表 DECISION_SPEC_TYPES/TYPE_VALID_TRANSITIONS)/ 租约常量(#178 WU_LEASE_TTL_MS)/ ANALYSIS_TASKS_MAX。 + * 输入输出 DTO(CreateWorkUnitInput / UpdateWorkUnitInput / WorkUnitData)唯一正本在 workunit-crud.ts(#537 删除本文件的漂移副本)。 * 零服务依赖,供 service 与跨模块类型级消费方直接引用。 */ @@ -263,70 +264,6 @@ export interface ReviewAttestationSource { summary?: string; } -export interface CreateWorkUnitInput { - type?: string; - scope: string; - assigneeId?: string; - status?: string; - channelId?: string | null; - parentId?: string | null; - projectPath?: string | null; - workspaceId?: string | null; // F6: 绑定工程(显式指定或频道默认) - reqId?: string | null; // REQ 需求编号(vision §5.3:显式/#REQ-XXXX/自动新建) - failureType?: string; - retryCount?: number; - timeoutAt?: Date | null; - completedAt?: Date | null; - metadata?: WorkUnitMetadata; -} - -export interface UpdateWorkUnitInput { - type?: string; - scope?: string; - assigneeId?: string | null; - channelId?: string | null; - parentId?: string | null; - projectPath?: string | null; - workspaceId?: string | null; - reqId?: string | null; // REQ 需求编号 - failureType?: string | null; - retryCount?: number; - timeoutAt?: Date | null; - completedAt?: Date | null; - metadata?: WorkUnitMetadata; -} - -/** - * WorkUnitData — 与 Prisma WorkUnit 类型兼容的平面字段(无 relations)。 - * 日期字段使用 Date 对象(与 Prisma 行为一致),来源是 FileStore 的字符串日期。 - */ -export interface WorkUnitData { - id: string; - parentId: string | null; - type: string; - scope: string; - assigneeId: string | null; - status: string; - failureType: string | null; - retryCount: number; - timeoutAt: Date | null; - channelId: string | null; - projectPath: string | null; - workspaceId?: string | null; // F6: 绑定工程(旧 WorkUnit 无此字段 → null) - reqId?: string | null; // REQ 需求编号(旧 WorkUnit 无此字段 → null) - assigneeRoleId?: string | null; // 认领时冗余的认领方 roleId 快照(旧 WorkUnit 无此字段 → null) - metadata: string | null; - createdAt: Date; - updatedAt: Date; - claimedAt: Date | null; - completedAt: Date | null; - /** #327(additive):关闭时刻——归档计龄锚点。仅 closed 状态有值,reopen 清除 */ - closedAt?: Date | null; - /** #318(additive,ADR D2):可认领标记——仅事件负载(workunit.created/status_changed)与 GET / 列表项附带; - unassigned 且无未了结依赖才 true,其余状态恒 false;snapshotToData 本体不产此字段 */ - claimable?: boolean; -} - /** * 决策 14 认领前适任判断的不适任名单条目。 * roleId = profile id(与 observe 过滤的 this.role.id 同口径);reason = 判断器一行理由; diff --git a/apps/api/src/modules/workunit/wu-dependencies.ts b/apps/api/src/modules/workunit/wu-dependencies.ts index b57df969b..5f9e5f33e 100644 --- a/apps/api/src/modules/workunit/wu-dependencies.ts +++ b/apps/api/src/modules/workunit/wu-dependencies.ts @@ -8,7 +8,7 @@ * 本模块是零运行时依赖的叶子(仅 type import),agent-loop 与 workunit 路由共用。 */ -import type { WorkUnitData } from './workunit.types.js'; +import type { WorkUnitData } from './workunit-crud.js'; /** * 容错解析 metadata.blockedBy:字符串/对象入参皆可;缺失/坏 JSON/非数组 → []; From f46aeb9f683bdfcd58a087e9c6f60364c2a56e5a Mon Sep 17 00:00:00 2001 From: dommaker Date: Tue, 15 Sep 2026 22:39:31 +0800 Subject: [PATCH 009/116] =?UTF-8?q?feat(workunit):=20service.delete=20?= =?UTF-8?q?=E5=8A=A0=20reason=20+=20=E5=8F=91=20workunit:removed=EF=BC=88?= =?UTF-8?q?=E5=A2=93=E7=A2=91=E5=8D=95=E7=82=B9=E6=9E=84=E9=80=A0=EF=BC=89?= =?UTF-8?q?(#538)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 2026-09-15-workunit-write-single-mouth 决策 3/4/5: - delete(id, { reason }):reason 落墓碑事件行(closed + deleted:true 单点构造,调用方不自拼) - 删除后发 workunit:removed(负载 id + channelId)——只走事件流/SSE,不进频道出声 --- .../workunit/__tests__/workunit-crud.test.ts | 35 +++++++++++++++++++ .../api/src/modules/workunit/workunit-crud.ts | 18 ++++++++-- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/apps/api/src/modules/workunit/__tests__/workunit-crud.test.ts b/apps/api/src/modules/workunit/__tests__/workunit-crud.test.ts index aedb1ecd0..711d995d6 100644 --- a/apps/api/src/modules/workunit/__tests__/workunit-crud.test.ts +++ b/apps/api/src/modules/workunit/__tests__/workunit-crud.test.ts @@ -544,6 +544,41 @@ describe('delete', () => { const closedEvt = events.find(e => e.type === 'closed' && e.wuId === wu.id); expect(closedEvt).toBeDefined(); }); + + // #538(ADR 2026-09-15 决策 3/4/5):delete 加 reason + 墓碑单点构造 + 发 workunit:removed + it('reason 落墓碑事件行 + 发布 workunit:removed(负载 id + channelId)', async () => { + const removed: Array<{ id: string; channelId: string | null }> = []; + const removedHandler = (payload: { id: string; channelId: string | null }) => { removed.push(payload); }; + eventBus.subscribe('workunit:removed', removedHandler); + try { + const wu = await service.create({ scope: 'TTL 待删', channelId: 'ch-1' }); + + await service.delete(wu.id, { reason: 'TTL: WorkUnit older than 90 days' }); + + const tombstone = readEvents().find(e => e.type === 'closed' && e.wuId === wu.id); + expect(tombstone?.data).toMatchObject({ deleted: true, reason: 'TTL: WorkUnit older than 90 days' }); + expect(removed).toEqual([{ id: wu.id, channelId: 'ch-1' }]); + } finally { + eventBus.unsubscribe('workunit:removed', removedHandler); + } + }); + + it('缺省 reason:墓碑仅 deleted:true,workunit:removed 仍发(channelId 缺省 null 透传)', async () => { + const removed: Array<{ id: string; channelId: string | null }> = []; + const removedHandler = (payload: { id: string; channelId: string | null }) => { removed.push(payload); }; + eventBus.subscribe('workunit:removed', removedHandler); + try { + const wu = await service.create({ scope: '无原因删除' }); + + await service.delete(wu.id); + + const tombstone = readEvents().find(e => e.type === 'closed' && e.wuId === wu.id); + expect(tombstone?.data).toEqual({ deleted: true }); + expect(removed).toEqual([{ id: wu.id, channelId: null }]); + } finally { + eventBus.unsubscribe('workunit:removed', removedHandler); + } + }); }); // ── claim / unclaim(#178:固定 5min 租约 timeoutAt)── diff --git a/apps/api/src/modules/workunit/workunit-crud.ts b/apps/api/src/modules/workunit/workunit-crud.ts index 01674c474..2b6d0cb92 100644 --- a/apps/api/src/modules/workunit/workunit-crud.ts +++ b/apps/api/src/modules/workunit/workunit-crud.ts @@ -423,8 +423,12 @@ export class WorkUnitCrudService { /** * Delete a WorkUnit. + * #538(ADR 2026-09-15 决策 3/4/5):墓碑事件行(closed + deleted:true)由本方法 + * 单点构造,调用方不自拼;reason 可选落墓碑(GC/TTL 死因留痕)。删除后发 + * workunit:removed(负载 id + channelId,经 SSE 桥转发前端删行)——只走事件流, + * 不进频道出声(数据卫生非对账修复)。 */ - async delete(id: string): Promise { + async delete(id: string, opts?: { reason?: string }): Promise { const existing = (await this.fileStore.getIndex({ id }))[0]; if (!existing) throw new Error(`WorkUnit not found: ${id}`); @@ -436,9 +440,19 @@ export class WorkUnitCrudService { type: 'closed', wuId: id, timestamp: now.toISOString(), - data: { deleted: true }, + data: { deleted: true, ...(opts?.reason ? { reason: opts.reason } : {}) }, }; await this.fileStore.commitRemoval(event, id); + + // best-effort:删除出声失败只记日志(同 publishStatusChanged 口径) + try { + eventBus.publish('workunit:removed', { id, channelId: existing.channelId ?? null }); + } catch (err) { + logger.warn('[WorkUnit] Failed to publish workunit:removed (non-blocking)', { + workUnitId: id, + error: String(err), + }); + } } /** From 1e3f7b22305f96d025bf3fe250c9dd0dd8e664e6 Mon Sep 17 00:00:00 2001 From: dommaker Date: Tue, 15 Sep 2026 22:42:28 +0800 Subject: [PATCH 010/116] =?UTF-8?q?feat(workunit):=20workunit:removed=20SS?= =?UTF-8?q?E=20=E8=BD=AC=E5=8F=91=20+=20web=20=E4=B8=A4=20store=20?= =?UTF-8?q?=E5=88=A0=E8=A1=8C=E5=88=86=E6=94=AF=20(#538)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 2026-09-15 决策 5:SSE 桥转发白名单加 workunit:removed(负载 id + channelId); channelWorkStore.applyWorkunitRemoved / workunitStore.removeWorkunit 删行分支 (WorkUnitListPage 与 useChannelWorkStoreSync 接线)——只走事件流,不进频道出声 --- .../__tests__/workunit-events-bridge.test.ts | 15 +++++++ .../modules/events/workunit-events-bridge.ts | 2 + .../__tests__/useChannelWorkStoreSync.test.ts | 17 +++++++ apps/web/src/hooks/useChannelWorkStoreSync.ts | 10 +++++ apps/web/src/pages/WorkUnitListPage.tsx | 9 +++- .../pages/__tests__/WorkUnitListPage.test.tsx | 13 ++++++ .../stores/__tests__/channelWorkStore.test.ts | 28 ++++++++++++ .../stores/__tests__/workunitStore.test.ts | 44 +++++++++++++++++++ apps/web/src/stores/channelWorkStore.ts | 13 ++++++ apps/web/src/stores/workunitStore.ts | 15 +++++++ 10 files changed, 165 insertions(+), 1 deletion(-) diff --git a/apps/api/src/modules/events/__tests__/workunit-events-bridge.test.ts b/apps/api/src/modules/events/__tests__/workunit-events-bridge.test.ts index 95199fe43..fdbaf7d64 100644 --- a/apps/api/src/modules/events/__tests__/workunit-events-bridge.test.ts +++ b/apps/api/src/modules/events/__tests__/workunit-events-bridge.test.ts @@ -28,6 +28,21 @@ describe('workunit-events-bridge', () => { expect(typeof (received[0] as unknown as { event_id: string }).event_id).toBe('string'); }); + it('workunit:removed 转发到 events 频道(负载 id + channelId,#538 决策 5)', async () => { + initWorkunitEventsBridge(); // 幂等 no-op(首个用例已注册),订阅仍生效 + + const received: Array<{ event_type: string; data: { id: string; channelId: string | null } }> = []; + eventBus.subscribe('events', (envelope: { event_type?: string; data: { id: string; channelId: string | null } }) => { + if (envelope.event_type === 'workunit:removed') received.push(envelope as (typeof received)[number]); + }); + + eventBus.publish('workunit:removed', { id: 'wu-gone', channelId: 'ch-1' }); + await new Promise(r => setTimeout(r, 20)); + + expect(received.map(e => e.event_type)).toEqual(['workunit:removed']); + expect(received[0].data).toEqual({ id: 'wu-gone', channelId: 'ch-1' }); + }); + it('requirement.created / updated 转发到 events 频道(data.requirement 含 id/channelId/title/status)', async () => { initWorkunitEventsBridge(); // 幂等 no-op(首个用例已注册),订阅仍生效 diff --git a/apps/api/src/modules/events/workunit-events-bridge.ts b/apps/api/src/modules/events/workunit-events-bridge.ts index 8f9d2aaac..bf62e92d6 100644 --- a/apps/api/src/modules/events/workunit-events-bridge.ts +++ b/apps/api/src/modules/events/workunit-events-bridge.ts @@ -13,6 +13,8 @@ import { v4 as uuidv4 } from 'uuid'; const FORWARDED_EVENTS = [ 'workunit.created', 'workunit.status_changed', + // #538(ADR 2026-09-15 决策 5):GC/TTL 删除出声——负载 { id, channelId },前端两 store 删行 + 'workunit:removed', // SSE 负载加深(2026-08-24 计划):REQ chips 改 SSE 驱动,负载 = { requirement }(含 id/title/status/channelId) 'requirement.created', 'requirement.updated', ] as const; diff --git a/apps/web/src/hooks/__tests__/useChannelWorkStoreSync.test.ts b/apps/web/src/hooks/__tests__/useChannelWorkStoreSync.test.ts index 78d7c1a87..e753e8ee8 100644 --- a/apps/web/src/hooks/__tests__/useChannelWorkStoreSync.test.ts +++ b/apps/web/src/hooks/__tests__/useChannelWorkStoreSync.test.ts @@ -116,6 +116,23 @@ describe('useChannelWorkStoreSync — SSE 路由(只处理活跃频道)', () } }); + it('workunit:removed 本频道 → 删行;非本频道 / 坏负载 → no-op(#538)', async () => { + await mount('ch-1'); + expect(useChannelWorkStore.getState().wus['ch-1']).toHaveLength(1); + + // 非本频道:不删本频道行 + act(() => handler!({ event_type: 'workunit:removed', data: { id: 'wu-1', channelId: 'ch-2' } })); + expect(useChannelWorkStore.getState().wus['ch-1']).toHaveLength(1); + + // 坏负载:缺 id → no-op + act(() => handler!({ event_type: 'workunit:removed', data: { channelId: 'ch-1' } })); + expect(useChannelWorkStore.getState().wus['ch-1']).toHaveLength(1); + + // 本频道:删行 + act(() => handler!({ event_type: 'workunit:removed', data: { id: 'wu-1', channelId: 'ch-1' } })); + expect(useChannelWorkStore.getState().wus['ch-1']).toHaveLength(0); + }); + it('requirement.created 本频道 → 就地 upsert + 标脏;channelId 不匹配的 REQ 事件跳过', async () => { await mount('ch-1'); const reqPayload = { id: 'REQ-1', seq: 1, title: '需求一', status: 'open', channelId: 'ch-1', createdAt: '2026-09-01T00:00:00Z', createdBy: 'human' }; diff --git a/apps/web/src/hooks/useChannelWorkStoreSync.ts b/apps/web/src/hooks/useChannelWorkStoreSync.ts index 64472e880..afb9fab31 100644 --- a/apps/web/src/hooks/useChannelWorkStoreSync.ts +++ b/apps/web/src/hooks/useChannelWorkStoreSync.ts @@ -31,6 +31,16 @@ function handleChannelWorkEvent(msg: WebSocketMessage) { return; } + if (msg.event_type === 'workunit:removed') { + // #538:负载 = { id, channelId }(GC/TTL 删除出声);缺 id 属畸形 fail-closed 跳过 + const data = msg.data as { id?: string; channelId?: string | null } | null | undefined; + if (!data || typeof data.id !== 'string' || !data.id) return; + // channelId 缺省(防御)放行按活跃频道处理;带值且不匹配跳过(对齐 status_changed 过滤语义) + if (data.channelId && data.channelId !== id) return; + store.applyWorkunitRemoved(id, data.id); + return; + } + if (msg.event_type === 'requirement.created' || msg.event_type === 'requirement.updated') { const req = parseRequirementPayload(msg.data); if (!req) return; diff --git a/apps/web/src/pages/WorkUnitListPage.tsx b/apps/web/src/pages/WorkUnitListPage.tsx index d6bc2be99..fcb394bd3 100644 --- a/apps/web/src/pages/WorkUnitListPage.tsx +++ b/apps/web/src/pages/WorkUnitListPage.tsx @@ -90,6 +90,7 @@ export function WorkUnitListPage() { // #318:WU SSE 负载直更(替代 eventTick 整页重拉)——status_changed 直替/移除行、created 插头部; // SSE 重连经 onReconnect 一次性 refetch 对齐(ADR D3) const applyWorkunitEvent = useWorkUnitStore(s => s.applyWorkunitEvent); + const removeWorkunit = useWorkUnitStore(s => s.removeWorkunit); const { onEvent, onReconnect } = useWebSocketContext(); // 批次 E-3:SSE 新 WU 行渐隐高亮(白名单③状态色切换)——created 事件插头部的新行挂 // .wu-row-new(accent-dim 底色),2s 后移类经 .wu-row 既有 background-color 过渡渐隐; @@ -98,6 +99,12 @@ export function WorkUnitListPage() { const freshWuTimersRef = useRef>>(new Map()); useEffect(() => () => { freshWuTimersRef.current.forEach(clearTimeout); }, []); useEffect(() => onEvent((msg) => { + // #538:GC/TTL 删除出声 → 删行分支(负载 { id, channelId },缺 id 属畸形跳过) + if (msg.event_type === 'workunit:removed') { + const data = msg.data as { id?: string } | null; + if (typeof data?.id === 'string' && data.id) removeWorkunit(data.id); + return; + } if (msg.event_type !== 'workunit.status_changed' && msg.event_type !== 'workunit.created') return; const data = msg.data as { workunit?: WorkUnit } | null; if (!data?.workunit) return; @@ -112,7 +119,7 @@ export function WorkUnitListPage() { setFreshWuIds(prev => { const next = new Set(prev); next.delete(wuId); return next; }); }, 2000)); } - }), [onEvent, applyWorkunitEvent]); + }), [onEvent, applyWorkunitEvent, removeWorkunit]); useEffect(() => onReconnect(() => { void loadWorkUnits(); void loadUnattributedCount(); void loadAllCount(); }), [onReconnect, loadWorkUnits, loadUnattributedCount, loadAllCount]); // 批次 E-2 空态分语境:过滤生效(状态/搜索/待人工/未归属)→ 「清除过滤」;全空 → 「新建任务」 diff --git a/apps/web/src/pages/__tests__/WorkUnitListPage.test.tsx b/apps/web/src/pages/__tests__/WorkUnitListPage.test.tsx index 78fd34071..ff643f028 100644 --- a/apps/web/src/pages/__tests__/WorkUnitListPage.test.tsx +++ b/apps/web/src/pages/__tests__/WorkUnitListPage.test.tsx @@ -44,6 +44,7 @@ const mockStore = { unattributedTotal: null as number | null, searchQuery: null as string | null, setSearchQuery: vi.fn(), + removeWorkunit: vi.fn(), error: null as string | null, }; @@ -72,6 +73,7 @@ vi.mock('../../stores/workunitStore', () => ({ setUnattributedOnly: mockStore.setUnattributedOnly, loadUnattributedCount: mockStore.loadUnattributedCount, applyWorkunitEvent: vi.fn(), + removeWorkunit: mockStore.removeWorkunit, }; return selector ? selector(state) : state; }, @@ -169,6 +171,17 @@ describe('WorkUnitListPage', () => { } }); + // #538:SSE workunit:removed → store 删行分支(GC/TTL 删除不再悬挂到重连 refetch);坏负载不调 + it('SSE workunit:removed → removeWorkunit(id);缺 id 负载不调', () => { + mockStore.workunits = [makeWu({ id: 'wu-1', scope: '存量行' })]; + render(); + act(() => { emitSse({ event_type: 'workunit:removed', data: { id: 'wu-1', channelId: null } }); }); + expect(mockStore.removeWorkunit).toHaveBeenCalledWith('wu-1'); + mockStore.removeWorkunit.mockClear(); + act(() => { emitSse({ event_type: 'workunit:removed', data: { channelId: 'ch-1' } }); }); + expect(mockStore.removeWorkunit).not.toHaveBeenCalled(); + }); + // 批次 E-2:全空空态 = 图标(去 emoji)+ 「新建任务」CTA(开创建表单) it('全空空态:去 emoji 图标 + 「新建任务」CTA 开创建表单', () => { render(); diff --git a/apps/web/src/stores/__tests__/channelWorkStore.test.ts b/apps/web/src/stores/__tests__/channelWorkStore.test.ts index 6745c35be..692c1f459 100644 --- a/apps/web/src/stores/__tests__/channelWorkStore.test.ts +++ b/apps/web/src/stores/__tests__/channelWorkStore.test.ts @@ -228,6 +228,34 @@ describe('channelWorkStore — applyWorkunitSnapshot(status_changed 全量快 }); }); +describe('channelWorkStore — applyWorkunitRemoved(workunit:removed 删行分支,#538)', () => { + beforeEach(() => { + mockWuList.mockResolvedValue({ data: { success: true, data: [wuFixture('wu-1'), wuFixture('wu-2')] } }); + }); + + it('已打底频道:按 id 删行,其余行不动', async () => { + await useChannelWorkStore.getState().ensureWus('ch-1'); + useChannelWorkStore.getState().applyWorkunitRemoved('ch-1', 'wu-1'); + const list = useChannelWorkStore.getState().wus['ch-1']!; + expect(list.map(w => w.id)).toEqual(['wu-2']); + }); + + it('未打底频道 / 未知 id:no-op', async () => { + useChannelWorkStore.getState().applyWorkunitRemoved('ch-9', 'wu-1'); + expect(useChannelWorkStore.getState().wus['ch-9']).toBeUndefined(); + + await useChannelWorkStore.getState().ensureWus('ch-1'); + useChannelWorkStore.getState().applyWorkunitRemoved('ch-1', 'wu-404'); + expect(useChannelWorkStore.getState().wus['ch-1']).toHaveLength(2); + }); + + it('坏负载(空 id)no-op', async () => { + await useChannelWorkStore.getState().ensureWus('ch-1'); + useChannelWorkStore.getState().applyWorkunitRemoved('ch-1', ''); + expect(useChannelWorkStore.getState().wus['ch-1']).toHaveLength(2); + }); +}); + describe('channelWorkStore — applyGateResult(闸门动作 write-through)', () => { it('直替命中条目;未打底频道 no-op 不建 slice', async () => { mockWuList.mockResolvedValue({ data: { success: true, data: [wuFixture('wu-1')] } }); diff --git a/apps/web/src/stores/__tests__/workunitStore.test.ts b/apps/web/src/stores/__tests__/workunitStore.test.ts index d720bedc1..d94c5f9fb 100644 --- a/apps/web/src/stores/__tests__/workunitStore.test.ts +++ b/apps/web/src/stores/__tests__/workunitStore.test.ts @@ -164,6 +164,50 @@ describe('workunitStore applyWorkunitEvent — SSE 负载驱动行更新(#318 }); }); +// #538(ADR 2026-09-15 决策 5):workunit:removed 删行分支——GC/TTL 删除不再悬挂到重连 refetch +describe('workunitStore removeWorkunit — workunit:removed 删行(#538)', () => { + const row = (id: string, overrides: Record = {}) => + ({ id, scope: `scope-${id}`, type: 'task', status: 'active', metadata: null, ...overrides }) as unknown as import('../../api/workunit').WorkUnit; + + beforeEach(() => { + vi.clearAllMocks(); + useWorkUnitStore.setState({ + workunits: [row('wu-1'), row('wu-2')], + total: 2, + allTotal: 10, + page: 1, + limit: 20, + statusFilter: null, + typeFilter: null, + loading: false, + error: null, + }); + }); + + it('已知行:移除且 total/allTotal 各 -1', () => { + useWorkUnitStore.getState().removeWorkunit('wu-1'); + const s = useWorkUnitStore.getState(); + expect(s.workunits.map(w => w.id)).toEqual(['wu-2']); + expect(s.total).toBe(1); + expect(s.allTotal).toBe(9); + }); + + it('未知行 / 空 id:no-op(计数不动)', () => { + useWorkUnitStore.getState().removeWorkunit('wu-404'); + useWorkUnitStore.getState().removeWorkunit(''); + const s = useWorkUnitStore.getState(); + expect(s.workunits).toHaveLength(2); + expect(s.total).toBe(2); + expect(s.allTotal).toBe(10); + }); + + it('allTotal 未拉取(null)时不编造', () => { + useWorkUnitStore.setState({ allTotal: null }); + useWorkUnitStore.getState().removeWorkunit('wu-1'); + expect(useWorkUnitStore.getState().allTotal).toBeNull(); + }); +}); + // E2-5(承接批次 B-3):追加式翻页——loadMoreWorkUnits 拉下一页拼接尾部(按 id 去重),page 随响应前进 describe('workunitStore loadMoreWorkUnits — 追加式分页(E2-5)', () => { const row = (id: string, overrides: Record = {}) => diff --git a/apps/web/src/stores/channelWorkStore.ts b/apps/web/src/stores/channelWorkStore.ts index e1a1fa0f6..b2f9c162c 100644 --- a/apps/web/src/stores/channelWorkStore.ts +++ b/apps/web/src/stores/channelWorkStore.ts @@ -54,6 +54,9 @@ interface ChannelWorkState { /** workunit.status_changed 全量快照直替 upsert(17 字段快照原样落库,删除编造时间戳); * 未打底的频道 no-op(等 REST 打底);坏负载 no-op */ applyWorkunitSnapshot: (channelId: string, wu: WorkUnit | null | undefined) => void; + /** #538(ADR 2026-09-15 决策 5):workunit:removed 删行分支——GC/TTL 删除即时下榜, + * 不再悬挂到 SSE 重连 refetch;未打底频道/未知 id/坏负载 no-op */ + applyWorkunitRemoved: (channelId: string, wuId: string) => void; /** requirement.created/updated 就地 upsert(#415:负载全量零补拉):created 追加去重 / * updated 全量覆盖已有条目(列表没有则不动,交由重连 refetch);未打底频道 no-op */ applyRequirementEvent: (channelId: string, kind: 'created' | 'updated', req: Requirement | null | undefined) => void; @@ -255,6 +258,16 @@ export const useChannelWorkStore = create((set, get) => ({ }); }, + applyWorkunitRemoved: (channelId, wuId) => { + if (!wuId) return; + set((st) => { + const list = st.wus[channelId]; + if (!list) return {}; // 未打底不建 slice:REST 打底/重连 refetch 自带终态 + if (!list.some((w) => w.id === wuId)) return {}; + return { wus: { ...st.wus, [channelId]: list.filter((w) => w.id !== wuId) } }; + }); + }, + applyRequirementEvent: (channelId, kind, req) => { if (!req || typeof req.id !== 'string' || !req.id) return; set((st) => { diff --git a/apps/web/src/stores/workunitStore.ts b/apps/web/src/stores/workunitStore.ts index 3c0d1a7b5..4e467ea85 100644 --- a/apps/web/src/stores/workunitStore.ts +++ b/apps/web/src/stores/workunitStore.ts @@ -56,6 +56,10 @@ interface WorkUnitState { * 与操作触发的 loadWorkUnits(docs/plans/2026-08-24-wu-events-payload-consumers.md)。 */ applyWorkunitEvent: (wu: WorkUnit, opts: { insertIfMissing: boolean }) => void; + /** #538(ADR 2026-09-15 决策 5):workunit:removed 删行分支——按 id 移除,total/allTotal + * 各 -1 近似维护(同 applyWorkunitEvent 取舍 a:页边界不追齐,重连 refetch 自愈); + * 未知行/空 id no-op */ + removeWorkunit: (id: string) => void; createWorkUnit: (data: { scope: string; type?: string }) => Promise; reviewPassed: (id: string, summary?: string, defaultAssigneeId?: string, confirm?: ReviewConfirmPayload) => Promise; reviewRejected: (id: string, reason?: string) => Promise; @@ -181,6 +185,17 @@ export const useWorkUnitStore = create((set, get) => ({ } }, + removeWorkunit: (id) => { + if (!id) return; + const { workunits, total, allTotal } = get(); + if (!workunits.some(w => w.id === id)) return; + set({ + workunits: workunits.filter(w => w.id !== id), + total: Math.max(0, total - 1), + ...(allTotal !== null ? { allTotal: Math.max(0, allTotal - 1) } : {}), + }); + }, + createWorkUnit: async (data) => { const { data: wu } = await workunitApi.create(data); // Refresh list From b47ca76ddc1a4e5b198fe520c6f5a4d05ea2f486 Mon Sep 17 00:00:00 2001 From: dommaker Date: Tue, 15 Sep 2026 22:47:02 +0800 Subject: [PATCH 011/116] =?UTF-8?q?refactor(agents):=20monitor=20TTL=20/?= =?UTF-8?q?=20ops=20GC=20=E5=88=A0=E9=99=A4=E5=BE=AA=E7=8E=AF=E6=94=B9?= =?UTF-8?q?=E8=B0=83=20service.delete=20(#538)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 2026-09-15 决策 3:筛选逻辑留调用方(90 天 TTL / 30 天 GC 口径照旧), 删除循环走 service.delete(id, { reason }) 单口——墓碑不再两处手抄, 删除经 workunit:removed 出声。system-health 测试补 WU 删除分支断言(现状未断言) --- .../__tests__/monitor-lifecycle.test.ts | 9 +++-- .../agents/__tests__/system-health.test.ts | 36 +++++++++++++++++++ .../agents/monitor/monitor-lifecycle.ts | 13 ++++--- .../src/modules/agents/ops/system-health.ts | 12 +++---- 4 files changed, 54 insertions(+), 16 deletions(-) diff --git a/apps/api/src/modules/agents/__tests__/monitor-lifecycle.test.ts b/apps/api/src/modules/agents/__tests__/monitor-lifecycle.test.ts index 16f6da3e4..abe8ada81 100644 --- a/apps/api/src/modules/agents/__tests__/monitor-lifecycle.test.ts +++ b/apps/api/src/modules/agents/__tests__/monitor-lifecycle.test.ts @@ -127,10 +127,15 @@ describe('dataLifecycle (每日 23:55 TTL)', () => { expect(state.lastDataLifecycleRun).not.toBe(''); expect(state.lastPrecipitateRun).not.toBe(''); // 闸门先于清理执行 - // #170:删除走锁内墓碑(closed + deleted:true)+ 索引移除成对原语 + // #538(ADR 2026-09-15 决策 3):删除循环改调 service.delete(id, { reason })—— + // 墓碑事件行由 service 单点构造(closed + deleted:true + reason),调用方不自拼 expect(fileStore.commitRemoval).toHaveBeenCalledTimes(1); expect(fileStore.commitRemoval).toHaveBeenCalledWith( - expect.objectContaining({ type: 'closed', wuId: 'wu-old', data: { deleted: true } }), + expect.objectContaining({ + type: 'closed', + wuId: 'wu-old', + data: expect.objectContaining({ deleted: true, reason: expect.stringContaining('90 days') }), + }), 'wu-old', ); diff --git a/apps/api/src/modules/agents/__tests__/system-health.test.ts b/apps/api/src/modules/agents/__tests__/system-health.test.ts index 61e9b0c5c..c6e2bbf85 100644 --- a/apps/api/src/modules/agents/__tests__/system-health.test.ts +++ b/apps/api/src/modules/agents/__tests__/system-health.test.ts @@ -21,6 +21,15 @@ vi.mock('@dommaker/studio-shared', () => { return { FileStore, logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } }; }); +// #538:runGC 的 WU 删除改走 service.delete 单口——本套件只钉「委托语义 + 筛选口径」, +// 墓碑/reason/workunit:removed 不变式由 workunit-crud.test.ts 锁定(模块边界单测,不重复钉)。 +// 真 chain 在本 mock 下不可达:studio-shared 整体替换后 workunit.service 的传递 import +//(routing.parseChannels / withAttestation 等)缺导出会在 import 期炸。 +const mockWuDelete = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +vi.mock('../../workunit/workunit.service.js', () => ({ + WorkUnitService: vi.fn(function () { return { delete: mockWuDelete }; }), +})); + // 数据根入口走真实现会撞上面的 os mock(无 homedir),这里钉一个 env 敏感的等价物: // 断言的边界是「runGC 经 studioPath/STUDIO_HOME 解析 sessions 目录」而非 studioDir 本身。 vi.mock('@dommaker/studio-shared/studio-dir', async () => { @@ -173,4 +182,31 @@ describe('runGC', () => { fs.rmSync(tmpRoot, { recursive: true, force: true }); } }); + + // #538(ADR 2026-09-15 决策 3):WU 删除分支断言——删除循环委托 service.delete(id, { reason }) + // 单口(不再直摸 commitRemoval),筛选逻辑(done/closed 且 completedAt >30 天)留调用方不变 + it('deletes completed WorkUnits older than 30 days via service.delete(委托单口 + 筛选口径不变)', async () => { + const { FileStore } = await import('@dommaker/studio-shared'); + const getIndex = (FileStore as unknown as { prototype: { getIndex: ReturnType } }).prototype.getIndex; + const old = new Date(Date.now() - 31 * 24 * 3600_000).toISOString(); + const fresh = new Date(Date.now() - 10 * 24 * 3600_000).toISOString(); + getIndex.mockResolvedValue([ + { id: 'wu-old', status: 'done', completedAt: old, channelId: 'ch-1' }, + { id: 'wu-fresh', status: 'done', completedAt: fresh, channelId: null }, + { id: 'wu-active', status: 'active', completedAt: null, channelId: null }, + ]); + mockWuDelete.mockClear(); + try { + const { runGC } = await import('../ops/system-health'); + const result = await runGC(); + + expect(result.details.some((d) => d.includes('wu-old'))).toBe(true); + expect(result.details.some((d) => d.includes('wu-fresh'))).toBe(false); + expect(result.details.some((d) => d.includes('wu-active'))).toBe(false); + expect(mockWuDelete).toHaveBeenCalledTimes(1); + expect(mockWuDelete).toHaveBeenCalledWith('wu-old', { reason: expect.stringContaining('30 days') }); + } finally { + getIndex.mockResolvedValue([]); // 不污染同文件其它用例(FileStore.prototype 共享) + } + }); }); diff --git a/apps/api/src/modules/agents/monitor/monitor-lifecycle.ts b/apps/api/src/modules/agents/monitor/monitor-lifecycle.ts index 58f404712..7eb405989 100644 --- a/apps/api/src/modules/agents/monitor/monitor-lifecycle.ts +++ b/apps/api/src/modules/agents/monitor/monitor-lifecycle.ts @@ -179,14 +179,13 @@ export async function dataLifecycle(fileStore: FileStore, state: LifecycleState) const execCutoffMs = Date.now() - 90 * 24 * 3600_000; const allWu = await fileStore.getIndex(); const toDelete = allWu.filter(s => new Date(s.createdAt).getTime() < execCutoffMs); + // #538(ADR 2026-09-15 决策 3):筛选逻辑留本调用方(90 天无状态过滤照旧), + // 删除循环走 service.delete 单口——墓碑单点构造 + workunit:removed 出声。 + // 动态引入避环(agents → workunit 静态链会经 channels 绕回 agents) + const { WorkUnitService } = await import('../../workunit/workunit.service.js'); + const workUnitService = new WorkUnitService(fileStore); for (const wu of toDelete) { - // #170:墓碑事件 + 索引移除同锁成对(对账/重建不复活已删 WU) - await fileStore.commitRemoval({ - type: 'closed', - wuId: wu.id, - timestamp: new Date().toISOString(), - data: { deleted: true }, - }, wu.id); + await workUnitService.delete(wu.id, { reason: 'monitor TTL: WorkUnit older than 90 days' }); } logger.info('[MonitorService] TTL: WorkUnit cleaned', { deleted: toDelete.length, cutoff: new Date(execCutoffMs).toISOString() }); } catch (e) { diff --git a/apps/api/src/modules/agents/ops/system-health.ts b/apps/api/src/modules/agents/ops/system-health.ts index bbe34e2a4..eeb8d12c5 100644 --- a/apps/api/src/modules/agents/ops/system-health.ts +++ b/apps/api/src/modules/agents/ops/system-health.ts @@ -303,18 +303,16 @@ export async function runGC(): Promise { // Clean completed WorkUnits older than 30 days try { const { FileStore } = await import('@dommaker/studio-shared'); + // #538(ADR 2026-09-15 决策 3):筛选逻辑留本调用方(done/closed 且 completedAt >30 天照旧), + // 删除循环走 service.delete 单口——墓碑单点构造 + workunit:removed 出声 + const { WorkUnitService } = await import('../../workunit/workunit.service.js'); const fileStore = new FileStore(); + const workUnitService = new WorkUnitService(fileStore); const snapshots = await fileStore.getIndex(); const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(); for (const s of snapshots) { if ((s.status === 'done' || s.status === 'closed') && s.completedAt && s.completedAt < cutoff) { - // #170:墓碑事件 + 索引移除同锁成对(对账/重建不复活已删 WU) - await fileStore.commitRemoval({ - type: 'closed', - wuId: s.id, - timestamp: new Date().toISOString(), - data: { deleted: true }, - }, s.id); + await workUnitService.delete(s.id, { reason: 'ops GC: completed WorkUnit older than 30 days' }); cleaned++; details.push(`Removed completed WorkUnit: ${s.id}`); } From d7301687da9affe190c0e2446d804d21380b9f50 Mon Sep 17 00:00:00 2001 From: dommaker Date: Tue, 15 Sep 2026 22:50:16 +0800 Subject: [PATCH 012/116] =?UTF-8?q?feat(triggers):=20UPDATE=20=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C=E7=A6=81=E6=94=B9=20status=20+=20=E7=99=BD=E5=90=8D?= =?UTF-8?q?=E5=8D=95/metadata=20=E5=90=88=E5=B9=B6=E5=86=99=20(#538)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 2026-09-15 决策 6:TriggerStore 注册校验拒 + executeUpdateAction 执行守卫 双保险;其余字段走 patchSnapshot 白名单(service.update),metadata 走 updateMetadata 增量合并。既有「可写 status」断言随语义变更反转(非删测试) --- .../triggers/__tests__/trigger-store.test.ts | 26 +++++ .../trigger-update-operators.test.ts | 6 +- .../triggers/__tests__/trigger-update.test.ts | 101 +++++++++++++----- .../src/modules/triggers/trigger-action.ts | 59 +++++++--- .../api/src/modules/triggers/trigger-store.ts | 6 ++ 5 files changed, 155 insertions(+), 43 deletions(-) diff --git a/apps/api/src/modules/triggers/__tests__/trigger-store.test.ts b/apps/api/src/modules/triggers/__tests__/trigger-store.test.ts index 42c8cad1b..62d1057e5 100644 --- a/apps/api/src/modules/triggers/__tests__/trigger-store.test.ts +++ b/apps/api/src/modules/triggers/__tests__/trigger-store.test.ts @@ -105,4 +105,30 @@ describe('TriggerStore', () => { } as TriggerConfig; expect(() => store.save(invalid)).toThrow(/cron/); }); + + // #538(ADR 2026-09-15 决策 6):UPDATE 禁改 status——注册校验拒(第一层双保险) + it('rejects UPDATE action whose update payload touches workunit status', () => { + const withStatus = { + ...sampleTrigger, + id: 'update-status', + action: { + type: 'UPDATE', + target: 'workunit', + config: { query: { status: 'active' }, update: { status: 'unassigned' } }, + }, + } as unknown as TriggerConfig; + expect(() => store.save(withStatus)).toThrow(/status/); + + // 白名单字段(非 status)不受影响,照常注册 + const benign = { + ...sampleTrigger, + id: 'update-benign', + action: { + type: 'UPDATE', + target: 'workunit', + config: { query: { status: 'active' }, update: { assigneeId: null } }, + }, + } as unknown as TriggerConfig; + expect(() => store.save(benign)).not.toThrow(); + }); }); diff --git a/apps/api/src/modules/triggers/__tests__/trigger-update-operators.test.ts b/apps/api/src/modules/triggers/__tests__/trigger-update-operators.test.ts index 4b8d09d39..9d5cb098c 100644 --- a/apps/api/src/modules/triggers/__tests__/trigger-update-operators.test.ts +++ b/apps/api/src/modules/triggers/__tests__/trigger-update-operators.test.ts @@ -111,12 +111,14 @@ describe('executeUpdateAction(P0:操作符查询集成)', () => { wuSnapshot('wu-no-timeout', null), ]); + // #538(ADR 决策 6):UPDATE 禁改 status——本用例聚焦操作符匹配,update 载荷 + // 改用白名单字段(assigneeId 释放);status 写拒绝由 trigger-update.test.ts 锁定 const action: TriggerAction = { type: 'UPDATE', target: 'workunit', config: { query: { status: 'active', timeoutAt: { lt: NOW_PLACEHOLDER } }, - update: { status: 'unassigned', assigneeId: null }, + update: { assigneeId: null }, }, }; @@ -125,7 +127,7 @@ describe('executeUpdateAction(P0:操作符查询集成)', () => { expect(mockFileStore.commitSnapshot).toHaveBeenCalledTimes(1); const updated = mockFileStore.commitSnapshot.mock.calls[0][1]; expect(updated.id).toBe('wu-expired'); - expect(updated.status).toBe('unassigned'); + expect(updated.status).toBe('active'); // status 不可经 UPDATE 改写 expect(updated.assigneeId).toBeNull(); }); }); diff --git a/apps/api/src/modules/triggers/__tests__/trigger-update.test.ts b/apps/api/src/modules/triggers/__tests__/trigger-update.test.ts index 8c2162513..e1a13261a 100644 --- a/apps/api/src/modules/triggers/__tests__/trigger-update.test.ts +++ b/apps/api/src/modules/triggers/__tests__/trigger-update.test.ts @@ -1,4 +1,6 @@ // AC-2: UPDATE action tests +// #538(ADR 2026-09-15 决策 6):禁改 status(执行守卫);其余字段走 patchSnapshot 白名单, +// metadata 走 updateMetadata 合并——原「可写 status」断言随语义变更反转(非删测试) import { describe, it, expect, vi, beforeEach } from 'vitest'; import { executeUpdateAction } from '../trigger-action'; import type { TriggerAction } from '../trigger.types'; @@ -12,6 +14,8 @@ const mockFileStore = vi.hoisted(() => ({ claimWorkUnit: vi.fn(), // #170:写路径改走锁内成对原语 commitSnapshot: vi.fn(), + // #538:metadata 增量合并写 + updateMetadata: vi.fn(), })); vi.mock('@dommaker/studio-shared', async (importOriginal) => { @@ -28,53 +32,94 @@ vi.mock('@dommaker/studio-shared', async (importOriginal) => { }; }); +function seedSnapshot(overrides: Record = {}) { + return { + id: 'test-wu-1', + parentId: null, + type: 'task', + scope: 'update-test', + assigneeId: 'agent-1', + status: 'active', + failureType: null, + retryCount: 0, + timeoutAt: null, + channelId: null, + projectPath: null, + metadata: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + claimedAt: new Date().toISOString(), + completedAt: null, + ...overrides, + }; +} + describe('Trigger UPDATE action', () => { beforeEach(() => { vi.clearAllMocks(); }); - it('executes FileStore update with static query/update', async () => { - const wuId = 'test-wu-1'; - - // Seed the mock FileStore with a WorkUnit snapshot - mockFileStore.getIndex.mockResolvedValue([ - { - id: wuId, - parentId: null, - type: 'task', - scope: 'update-test', - assigneeId: 'agent-1', - status: 'active', - failureType: null, - retryCount: 0, - timeoutAt: null, - channelId: null, - projectPath: null, - metadata: null, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - claimedAt: new Date().toISOString(), - completedAt: null, - }, - ]); + it('禁改 status:执行守卫拒绝且零写入(ADR 决策 6,原「可写 status」断言反转)', async () => { + mockFileStore.getIndex.mockResolvedValue([seedSnapshot()]); const action: TriggerAction = { type: 'UPDATE', target: 'workunit', config: { - query: { id: wuId }, + query: { id: 'test-wu-1' }, update: { status: 'unassigned', assigneeId: null }, }, }; + await expect(executeUpdateAction(action, {})).rejects.toThrow(/status/); + expect(mockFileStore.commitSnapshot).not.toHaveBeenCalled(); + expect(mockFileStore.updateMetadata).not.toHaveBeenCalled(); + }); + + it('白名单字段经 service.update 落盘;白名单外字段丢弃不进快照', async () => { + mockFileStore.getIndex.mockResolvedValue([seedSnapshot()]); + + const action: TriggerAction = { + type: 'UPDATE', + target: 'workunit', + config: { + query: { id: 'test-wu-1' }, + update: { scope: '改后标题', assigneeId: null, hack: 'not-a-field' }, + }, + }; + await executeUpdateAction(action, {}); // #170:更新经锁内成对写落盘(appendEvent + upsertSnapshot 同锁) - expect(mockFileStore.commitSnapshot).toHaveBeenCalled(); - + expect(mockFileStore.commitSnapshot).toHaveBeenCalledTimes(1); const updatedSnapshot = mockFileStore.commitSnapshot.mock.calls[0][1]; - expect(updatedSnapshot.status).toBe('unassigned'); + expect(updatedSnapshot.scope).toBe('改后标题'); expect(updatedSnapshot.assigneeId).toBeNull(); + expect(updatedSnapshot.status).toBe('active'); // 未被触碰 + expect((updatedSnapshot as Record).hack).toBeUndefined(); + }); + + it('metadata 走 updateMetadata 增量合并(不整写覆盖既有键)', async () => { + mockFileStore.getIndex.mockResolvedValue([seedSnapshot({ metadata: '{"a":1}' })]); + + const action: TriggerAction = { + type: 'UPDATE', + target: 'workunit', + config: { + query: { id: 'test-wu-1' }, + update: { metadata: { b: 2 } }, + }, + }; + + await executeUpdateAction(action, {}); + + expect(mockFileStore.updateMetadata).toHaveBeenCalledTimes(1); + const [wuId, mutator] = mockFileStore.updateMetadata.mock.calls[0]; + expect(wuId).toBe('test-wu-1'); + // mutator 基于锁内最新 metadata 合并:既有键保留,新键并入 + expect(mutator({ a: 1 })).toEqual({ a: 1, b: 2 }); + // 无白名单字段 → 不走整快照写 + expect(mockFileStore.commitSnapshot).not.toHaveBeenCalled(); }); }); diff --git a/apps/api/src/modules/triggers/trigger-action.ts b/apps/api/src/modules/triggers/trigger-action.ts index 45956f6b9..6c49baa9e 100644 --- a/apps/api/src/modules/triggers/trigger-action.ts +++ b/apps/api/src/modules/triggers/trigger-action.ts @@ -1,6 +1,6 @@ // Trigger Action — execute trigger actions (3.28c-4, AS-026 extended) // Supports: CREATE WorkUnit, EXECUTE handler, UPDATE entity -import { FileStore, logger, type WorkUnitEvent, type WorkUnitSnapshot } from '@dommaker/studio-shared'; +import { FileStore, logger } from '@dommaker/studio-shared'; import type { TriggerAction, TriggerExecuteHandler } from './trigger.types.js'; import { WorkUnitService } from '../workunit/workunit.service.js'; @@ -200,8 +200,27 @@ export async function executeExecuteAction( await handler(context); } +/** + * #538(ADR 2026-09-15 决策 6):UPDATE 可写字段白名单——与 service.update 的 + * UpdateWorkUnitInput 一一对应(patchSnapshot 白名单)。status 不在列: + * 状态机归 WorkUnitService,UPDATE 禁改(注册校验 + 执行守卫双保险)。 + */ +const UPDATE_FIELD_WHITELIST = [ + 'type', 'scope', 'assigneeId', 'channelId', 'parentId', 'projectPath', + 'workspaceId', 'reqId', 'failureType', 'retryCount', 'timeoutAt', 'completedAt', +] as const; + +/** YAML/JSON 配置里的日期是 ISO 字符串;UpdateWorkUnitInput 要 Date(patchSnapshot 调 toISOString) */ +function coerceDateField(value: unknown): Date | null { + if (value === null) return null; + if (value instanceof Date) return value; + return new Date(String(value)); +} + /** * Execute an UPDATE action — updates entity via FileStore. + * #538:workunit 目标禁改 status(执行守卫);其余字段走 patchSnapshot 白名单 + * (service.update),metadata 走 updateMetadata 增量合并(不整写覆盖)。 * @param action - The trigger action definition (must be UPDATE type) */ export async function executeUpdateAction( @@ -217,6 +236,18 @@ export async function executeUpdateAction( // Only support workunit entity for MVP if (action.target === 'workunit') { + if ('status' in update) { + throw new Error('UPDATE action must not modify workunit status (state machine owned by WorkUnitService)'); + } + const { metadata, ...fields } = update as Record & { metadata?: unknown }; + if (metadata !== undefined && (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata))) { + throw new Error('UPDATE action metadata must be an object (merged via updateMetadata)'); + } + const unknownKeys = Object.keys(fields).filter(k => !(UPDATE_FIELD_WHITELIST as readonly string[]).includes(k)); + if (unknownKeys.length > 0) { + logger.warn(`[TriggerAction] UPDATE workunit: dropping non-whitelisted fields: ${unknownKeys.join(', ')}`); + } + const snapshots = await fileStore.getIndex(); const now = new Date().toISOString(); @@ -232,18 +263,20 @@ export async function executeUpdateAction( } if (!matches) continue; - const updatedSnapshot: WorkUnitSnapshot = { - ...s, - ...update as Partial, - updatedAt: now, - }; - const event: WorkUnitEvent = { - type: 'updated', - wuId: s.id, - timestamp: now, - data: updatedSnapshot as unknown as Record, - }; - await fileStore.commitSnapshot(event, updatedSnapshot); + const input: Record = {}; + for (const key of UPDATE_FIELD_WHITELIST) { + if (!(key in fields)) continue; + const raw = (fields as Record)[key]; + input[key] = (key === 'timeoutAt' || key === 'completedAt') ? coerceDateField(raw) : raw; + } + if (Object.keys(input).length > 0) { + await workUnitService.update(s.id, input); + } + if (metadata !== undefined) { + // metadata 增量合并:mutator 基于锁内最新 metadata,既有键保留、新键并入 + const patch = metadata as Record; + await fileStore.updateMetadata(s.id, latest => ({ ...latest, ...patch })); + } } } else { logger.warn(`[TriggerAction] Unknown UPDATE target: ${action.target}`); diff --git a/apps/api/src/modules/triggers/trigger-store.ts b/apps/api/src/modules/triggers/trigger-store.ts index 1f9bea329..17689b421 100644 --- a/apps/api/src/modules/triggers/trigger-store.ts +++ b/apps/api/src/modules/triggers/trigger-store.ts @@ -49,6 +49,12 @@ function validateTrigger(config: TriggerConfig): void { if (!config.action.config?.query || !config.action.config?.update) { throw new Error('UPDATE action must have config with query and update'); } + // #538(ADR 2026-09-15 决策 6):禁改 status——状态机归 WorkUnitService, + // 注册校验拒(第一层);executeUpdateAction 执行守卫为第二层。零消费者先收紧, + // 将来真需要状态变更再转 transitionStatus + if (config.action.target === 'workunit' && 'status' in config.action.config.update) { + throw new Error('UPDATE action must not modify workunit status (state machine owned by WorkUnitService)'); + } } else { throw new Error(`Unknown action type: ${(config.action as { type: string }).type}`); } From 81fe7234b179f69cfb859ced9a42256d3656ec3f Mon Sep 17 00:00:00 2001 From: dommaker Date: Tue, 15 Sep 2026 22:53:52 +0800 Subject: [PATCH 013/116] =?UTF-8?q?refactor(discord):=20=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E5=9B=9E=E8=B0=83=E4=B8=8E=20/studio=20stop=20=E6=94=B6?= =?UTF-8?q?=E5=8F=A3=E5=88=B0=20service=20=E8=AF=AD=E4=B9=89=E6=96=B9?= =?UTF-8?q?=E6=B3=95=20(#538)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 2026-09-15 决策 2: - abandon / stop → closeWorkUnitWithNotice 统一关闭(human-command,补 closedAt + workunit:closed 记录 + 频道出声),停发 legacy events:goal-execution - retry / retry-new → unclaim 回池(status_changed)+ updateMetadata 锁内合并 (旧实现 metadata 整写覆盖摧毁既有键、不写 closedAt、不发事件) - 按钮两路径测试从零补齐(真 Ed25519 签名 + 真 server fetch) --- .../modules/discord/__tests__/routes.test.ts | 234 ++++++++++++++++++ apps/api/src/modules/discord/routes.ts | 86 ++----- 2 files changed, 261 insertions(+), 59 deletions(-) create mode 100644 apps/api/src/modules/discord/__tests__/routes.test.ts diff --git a/apps/api/src/modules/discord/__tests__/routes.test.ts b/apps/api/src/modules/discord/__tests__/routes.test.ts new file mode 100644 index 000000000..5e1dbc0fc --- /dev/null +++ b/apps/api/src/modules/discord/__tests__/routes.test.ts @@ -0,0 +1,234 @@ +/** + * Discord interactions 路由测试(#538 从零补齐——现状零覆盖) + * + * 钉住 ADR 2026-09-15 决策 2 的收口语义: + * - retry / retry-new 按钮 → unclaim 回池 + 重试标记经 updateMetadata 锁内合并 + * (旧实现整写 metadata 覆盖摧毁既有键、直摸 commitSnapshot 绕过 service 层) + * - abandon 按钮 / /studio stop → closeWorkUnitWithNotice 统一关闭出口 + * (closedBy: human-command,补 closedAt + workunit:closed 记录 + 频道出声) + * - 停发 legacy events:goal-execution 事件(Goal 体系已退役) + * + * 签名验证走真 Ed25519(测试内生成密钥对),路由层契约同 trigger-fire.routes.test.ts 模式: + * 真 express server + fetch;FileStore / WorkUnitService / wu-closure / agentRunner mock。 + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; +import express from 'express'; +import type { Server } from 'node:http'; +import { generateKeyPairSync, sign } from 'node:crypto'; + +const { mockFileStore, mockUnclaim, mockClose, mockAgentStop, mockEventBusPublish } = vi.hoisted(() => ({ + mockFileStore: { + getIndex: vi.fn(), + updateMetadata: vi.fn(), + commitSnapshot: vi.fn(), + commitRemoval: vi.fn(), + }, + mockUnclaim: vi.fn(), + mockClose: vi.fn(), + mockAgentStop: vi.fn(), + mockEventBusPublish: vi.fn(), +})); + +vi.mock('@dommaker/studio-shared', async (importOriginal) => { + const orig = await importOriginal() as Record; + return { + ...orig, + FileStore: vi.fn(function () { return mockFileStore; }), + eventBus: { publish: mockEventBusPublish, subscribe: vi.fn(), unsubscribe: vi.fn() }, + }; +}); + +vi.mock('../../workunit/workunit.service.js', () => ({ + WorkUnitService: vi.fn(function () { return { unclaim: mockUnclaim }; }), +})); + +vi.mock('../../workunit/wu-closure.js', () => ({ + closeWorkUnitWithNotice: mockClose, +})); + +vi.mock('@dommaker/studio-agent', () => ({ + agentRunner: { stop: mockAgentStop }, +})); + +import router from '../routes.js'; + +// 真 Ed25519 密钥对:公钥导出 raw 32 字节(spki der 尾 32B),与路由的 verifyDiscordSignature 同口径 +const { publicKey, privateKey } = generateKeyPairSync('ed25519'); +const PUBLIC_KEY_HEX = Buffer.from(publicKey.export({ format: 'der', type: 'spki' })).subarray(-32).toString('hex'); + +function signBody(body: string, timestamp: string): string { + return sign(null, Buffer.from(timestamp + body, 'utf-8'), privateKey).toString('hex'); +} + +function makeSnapshot(id: string, overrides: Record = {}) { + return { + id, + parentId: null, + type: 'task', + scope: `scope-${id}`, + assigneeId: 'agent-1', + status: 'active', + failureType: null, + retryCount: 0, + timeoutAt: null, + channelId: 'ch-1', + projectPath: null, + metadata: '{"keep":"me"}', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + claimedAt: new Date().toISOString(), + completedAt: null, + ...overrides, + }; +} + +describe('discord interactions 路由(#538 写路径收口)', () => { + let server: Server; + let base: string; + + beforeAll(async () => { + process.env.DISCORD_PUBLIC_KEY = PUBLIC_KEY_HEX; + const app = express(); + app.use(express.raw({ type: () => true })); + app.use('/discord', router); + await new Promise(resolve => { + server = app.listen(0, '127.0.0.1', () => resolve()); + }); + const addr = server.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + base = `http://127.0.0.1:${port}/discord`; + }); + + afterAll(async () => { + delete process.env.DISCORD_PUBLIC_KEY; + await new Promise(resolve => server.close(() => resolve())); + }); + + beforeEach(() => { + vi.clearAllMocks(); + mockClose.mockResolvedValue(true); + mockUnclaim.mockResolvedValue({}); + mockFileStore.updateMetadata.mockResolvedValue(true); + }); + + async function postInteraction(payload: unknown, opts?: { badSignature?: boolean }) { + const body = JSON.stringify(payload); + const timestamp = String(Math.floor(Date.now() / 1000)); + const signature = opts?.badSignature ? 'deadbeef' : signBody(body, timestamp); + return fetch(`${base}/interactions`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-signature-ed25519': signature, + 'x-signature-timestamp': timestamp, + }, + body, + }); + } + + const button = (customId: string) => ({ type: 3, data: { component_type: 2, custom_id: customId } }); + + it('签名无效 → 401(签名验证优先于业务逻辑)', async () => { + const res = await postInteraction(button('abandon:wu-1'), { badSignature: true }); + expect(res.status).toBe(401); + expect(mockClose).not.toHaveBeenCalled(); + }); + + it('retry 按钮 → unclaim 回池 + updateMetadata 合并(resumeAfterRetry + extraRounds),零直写', async () => { + const res = await postInteraction(button('retry:wu-1:3')); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.content).toContain('🔁'); + expect(body.data.content).toContain('3'); + + expect(mockUnclaim).toHaveBeenCalledWith('wu-1'); + expect(mockFileStore.updateMetadata).toHaveBeenCalledTimes(1); + const [wuId, mutator] = mockFileStore.updateMetadata.mock.calls[0]; + expect(wuId).toBe('wu-1'); + // 合并语义:既有键保留,重试标记并入(旧实现整写覆盖会摧毁 keep 键) + expect(mutator({ keep: 'me' })).toEqual({ keep: 'me', resumeAfterRetry: true, extraRounds: 3 }); + // 不直摸 FileStore 写原语 + expect(mockFileStore.commitSnapshot).not.toHaveBeenCalled(); + expect(mockFileStore.commitRemoval).not.toHaveBeenCalled(); + }); + + it('retry-new 按钮 → unclaim + updateMetadata 合并(resumeAfterRetry + freshPrompt)', async () => { + const res = await postInteraction(button('retry-new:wu-1')); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.content).toContain('🔄'); + + expect(mockUnclaim).toHaveBeenCalledWith('wu-1'); + const mutator = mockFileStore.updateMetadata.mock.calls[0][1]; + expect(mutator({ keep: 'me' })).toEqual({ keep: 'me', resumeAfterRetry: true, freshPrompt: true }); + }); + + it('abandon 按钮 → closeWorkUnitWithNotice 统一关闭(human-command),不直写不发 legacy 事件', async () => { + const snap = makeSnapshot('wu-1'); + mockFileStore.getIndex.mockResolvedValue([snap]); + + const res = await postInteraction(button('abandon:wu-1')); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.content).toContain('已放弃'); + + expect(mockClose).toHaveBeenCalledTimes(1); + const [fs, passedSnap, closeOpts] = mockClose.mock.calls[0]; + expect(fs).toBe(mockFileStore); + expect(passedSnap).toEqual(snap); + expect(closeOpts).toEqual({ reason: 'Abandoned by user via Discord', closedBy: 'human-command' }); + expect(mockFileStore.commitSnapshot).not.toHaveBeenCalled(); + // legacy events:goal-execution 停发(Goal 体系已退役) + expect(mockEventBusPublish).not.toHaveBeenCalledWith('events:goal-execution', expect.anything()); + }); + + it('abandon 目标不存在 → 不关闭,回 not found', async () => { + mockFileStore.getIndex.mockResolvedValue([]); + + const res = await postInteraction(button('abandon:wu-404')); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.content).toMatch(/not found/i); + expect(mockClose).not.toHaveBeenCalled(); + }); + + it('/studio stop → closeWorkUnitWithNotice 统一关闭 + agentRunner.stop,停发 legacy 事件', async () => { + const exec = makeSnapshot('exec-abc123', { status: 'active' }); + mockFileStore.getIndex.mockResolvedValue([exec]); + + const res = await postInteraction({ + type: 2, + data: { + name: 'studio', + options: [{ name: 'stop', options: [{ name: 'execution_id', value: 'exec-abc123' }] }], + }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.content).toContain('✅ Stopped'); + + expect(mockClose).toHaveBeenCalledTimes(1); + const [, , closeOpts] = mockClose.mock.calls[0]; + expect(closeOpts).toEqual({ reason: 'Stopped by user via Discord', closedBy: 'human-command' }); + expect(mockAgentStop).toHaveBeenCalledWith('exec-abc123'); + expect(mockFileStore.commitSnapshot).not.toHaveBeenCalled(); + expect(mockEventBusPublish).not.toHaveBeenCalledWith('events:goal-execution', expect.anything()); + }); + + it('/studio stop 终态 WU → 拒绝且不关闭', async () => { + const exec = makeSnapshot('exec-done', { status: 'done' }); + mockFileStore.getIndex.mockResolvedValue([exec]); + + const res = await postInteraction({ + type: 2, + data: { + name: 'studio', + options: [{ name: 'stop', options: [{ name: 'execution_id', value: 'exec-done' }] }], + }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.content).toContain('Cannot stop'); + expect(mockClose).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/discord/routes.ts b/apps/api/src/modules/discord/routes.ts index 356f174d8..33a0b8a1a 100644 --- a/apps/api/src/modules/discord/routes.ts +++ b/apps/api/src/modules/discord/routes.ts @@ -6,10 +6,13 @@ */ import express, { Router, Request, Response } from 'express'; -import { FileStore, eventBus, type WorkUnitSnapshot } from '@dommaker/studio-shared'; +import { FileStore } from '@dommaker/studio-shared'; import { logger } from '../../utils/logger.js'; +import { WorkUnitService } from '../workunit/workunit.service.js'; +import { closeWorkUnitWithNotice } from '../workunit/wu-closure.js'; const router = express.Router(); const fileStore = new FileStore(); +const workUnitService = new WorkUnitService(fileStore); // Discord Interaction Types const InteractionType = { @@ -207,15 +210,10 @@ router.post('/interactions', async (req: Request, res: Response): Promise res.json({ type: ResponseType.CHANNEL_MESSAGE_WITH_SOURCE, data: { content: `Cannot stop execution with status: ${match.status}` } }); return; } - await closeAndEmit(match.id, 'Stopped by user via Discord'); + await closeWorkUnit(match.id, 'Stopped by user via Discord'); // Try to kill running child process (agentRunner 持有真实的 runningProcesses) const { agentRunner } = await import('@dommaker/studio-agent'); await agentRunner.stop(match.id); - // Publish event - eventBus.publish('events:goal-execution', { - event_type: 'goal-execution.updated', - data: { executionId: match.id, status: 'failed', error: 'Stopped by user via Discord' }, - }); res.json({ type: ResponseType.CHANNEL_MESSAGE_WITH_SOURCE, data: { content: `✅ Stopped execution \`${match.id.slice(0, 8)}\`` } }); return; } @@ -228,18 +226,12 @@ router.post('/interactions', async (req: Request, res: Response): Promise return; } - await closeAndEmit(exec.id, 'Stopped by user via Discord'); + await closeWorkUnit(exec.id, 'Stopped by user via Discord'); // Try to kill running child process (agentRunner 持有真实的 runningProcesses) const { agentRunner } = await import('@dommaker/studio-agent'); await agentRunner.stop(exec.id); - // Publish event so GoalScheduler picks up the change - eventBus.publish('events:goal-execution', { - event_type: 'goal-execution.updated', - data: { executionId: exec.id, status: 'failed', error: 'Stopped by user via Discord' }, - }); - res.json({ type: ResponseType.CHANNEL_MESSAGE_WITH_SOURCE, data: { content: `✅ Stopped execution \`${exec.id.slice(0, 8)}\`` } }); } catch (err: any) { res.json({ type: ResponseType.CHANNEL_MESSAGE_WITH_SOURCE, data: { content: `❌ Failed to stop: ${err.message?.slice(0, 300) || String(err)}` } }); @@ -273,20 +265,23 @@ router.post('/interactions', async (req: Request, res: Response): Promise if (action === 'retry') { const extraRounds = parseInt(parts[2] || '2', 10); - await updateWorkUnitStatus(targetId, 'unassigned', { resumeAfterRetry: true, extraRounds }); + await resetForRetry(targetId, { extraRounds }); res.json({ type: ResponseType.CHANNEL_MESSAGE_WITH_SOURCE, data: { content: `🔁 已重置,再给 ${extraRounds} 轮` } }); return; } if (action === 'retry-new') { - await updateWorkUnitStatus(targetId, 'unassigned', { resumeAfterRetry: true, freshPrompt: true }); + await resetForRetry(targetId, { freshPrompt: true }); res.json({ type: ResponseType.CHANNEL_MESSAGE_WITH_SOURCE, data: { content: `🔄 已重置,换了新方向` } }); return; } if (action === 'abandon') { - await closeAndEmit(targetId, 'Abandoned by user via Discord'); - res.json({ type: ResponseType.CHANNEL_MESSAGE_WITH_SOURCE, data: { content: `❌ 已放弃` } }); + const closed = await closeWorkUnit(targetId, 'Abandoned by user via Discord'); + res.json({ + type: ResponseType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { content: closed ? `❌ 已放弃` : `WorkUnit not found: ${targetId}` }, + }); return; } @@ -301,53 +296,26 @@ router.post('/interactions', async (req: Request, res: Response): Promise }); /** - * 关闭 WorkUnit 并发布事件 + * 关闭 WorkUnit(#538,ADR 2026-09-15 决策 2):走 wu-closure 统一关闭出口—— + * 补 closedAt、落 workunit:closed 结构化记录、频道出声(原因走 opts)。 + * 旧实现(closeAndEmit)手拼快照整写:metadata 整写覆盖摧毁既有 metadata、 + * 不写 closedAt、不发任何事件;legacy events:goal-execution 随 Goal 体系退役停发。 + * @returns 是否找到目标并执行关闭 */ -async function closeAndEmit(wuId: string, reason: string): Promise { +async function closeWorkUnit(wuId: string, reason: string): Promise { const snap = (await fileStore.getIndex({ id: wuId }))[0]; - if (!snap) return; - - const now = new Date().toISOString(); - const updated: WorkUnitSnapshot = { - ...snap, - status: 'closed', - metadata: JSON.stringify({ error: reason }), - updatedAt: now, - }; - - await fileStore.commitSnapshot( - { type: 'closed', wuId, timestamp: now, data: updated as unknown as Record }, - updated, - ); - eventBus.publish('events:goal-execution', { - event_type: 'goal-execution.updated', - data: { executionId: wuId, status: 'failed', error: reason }, - }); + if (!snap) return false; + return closeWorkUnitWithNotice(fileStore, snap, { reason, closedBy: 'human-command' }); } /** - * 更新 WorkUnit 状态和 metadata + * 重置 WorkUnit 回池重试(#538,ADR 2026-09-15 决策 2):unclaim 回 unassigned + * (清 assignee + 发 status_changed)+ 重试标记经 updateMetadata 锁内合并 + * (mutator 基于锁内最新 metadata,既有键保留——旧实现整写覆盖摧毁它们)。 */ -async function updateWorkUnitStatus(wuId: string, status: string, extraMeta: Record): Promise { - const snap = (await fileStore.getIndex({ id: wuId }))[0]; - if (!snap) throw new Error(`WorkUnit not found: ${wuId}`); - - const now = new Date().toISOString(); - let meta: Record = {}; - try { meta = snap.metadata ? JSON.parse(snap.metadata) : {}; } catch {} - Object.assign(meta, extraMeta); - - const updated: WorkUnitSnapshot = { - ...snap, - status, - metadata: JSON.stringify(meta), - updatedAt: now, - }; - - await fileStore.commitSnapshot( - { type: 'updated', wuId, timestamp: now, data: updated as unknown as Record }, - updated, - ); +async function resetForRetry(wuId: string, retryMeta: Record): Promise { + await workUnitService.unclaim(wuId); + await fileStore.updateMetadata(wuId, latest => ({ ...latest, resumeAfterRetry: true, ...retryMeta })); } export default router; From 938d0e536eacc391dc1e7ced39a551600e334595 Mon Sep 17 00:00:00 2001 From: dommaker Date: Tue, 15 Sep 2026 22:57:32 +0800 Subject: [PATCH 014/116] =?UTF-8?q?docs(context):=20#538=20=E5=86=99?= =?UTF-8?q?=E5=8D=95=E5=8F=A3=E6=94=B6=E5=8F=A3=E8=90=BD=E5=9C=B0=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=85=AD=E5=A4=84=20CONTEXT.md=20(#538)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除事件与 reason 口径(workunit)、discord service 单口、trigger UPDATE 禁 status、TTL/GC service.delete、桥白名单 workunit:removed、web 删行分支 --- apps/api/src/modules/agents/CONTEXT.md | 1 + apps/api/src/modules/discord/CONTEXT.md | 5 +++-- apps/api/src/modules/events/CONTEXT.md | 4 ++-- apps/api/src/modules/triggers/CONTEXT.md | 2 +- apps/api/src/modules/workunit/CONTEXT.md | 2 +- apps/web/src/CONTEXT.md | 4 ++-- 6 files changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/api/src/modules/agents/CONTEXT.md b/apps/api/src/modules/agents/CONTEXT.md index 5af8ca38f..8e40ca175 100644 --- a/apps/api/src/modules/agents/CONTEXT.md +++ b/apps/api/src/modules/agents/CONTEXT.md @@ -39,6 +39,7 @@ Agent 配置(profile)、运行实例(instance)、决策循环(loop) - **Idle 心跳 45s**,超时扫描 5min;**isOnline** = loop 存活+心跳新鲜(≤5min);#345 起 isOnline/每角色最新 error 聚合单源 `summarizeRoleStates`(agent-instance.service),agent-profile list 与 instance-timeout-scan 的 5min 窗口同源(INSTANCE_ALIVE_TIMEOUT_MS) - **系统探测 /proc 单出口(#344)**:新探测先落 `ops/proc-probes.ts`(零子进程)。磁盘 = statfs (blocks−bavail)/blocks(含 root 保留块,比 df Use% 偏高 1–5pp);内存 = MemTotal−MemAvailable(比 free -m used 列偏小,现代可用口径);僵尸 = /proc//stat state=Z。monitor systemHealthCheck 阈值不变(disk>90 warning / mem>95 critical / zombie>0 warning),details 的 dfOutput/freeOutput 已随 shell 解析删除,保留 usagePercent/zombieCount;ops 残留 ss/systemctl 走 async exec。#418 起命令行类探测同出口:readProcessCmdline 按 NUL→空格还原 ps aux 渲染口径(僵尸/内核线程空 cmdline → null 不计),countProcessesByCmdline 子串计数、listPidsByCmdline 按 JS RegExp 列 pid(ops-rules processes_to_clean 契约)、listZombieProcesses 列 pid+comm;ops.service isCloudflaredRunning/cleanStaleProcesses(kill 改 process.kill SIGKILL,失败不计口径不变)/countProcesses、triage 僵尸列举+claude 计数、env-snapper cloudflared 探测均委托此单出口 - **monitor-round 零同步子进程(#374)**:全部同步 exec 收口(review 补收票面漏列的第四处)——gcStaleWorktrees `git worktree prune`(cwd/5s timeout 语义不变)与 checkKnowledgeHealth 用户模型更新(30s timeout、`|| echo "{}"` shell 兜底不变)改 `monitor/exec-async.ts` 异步 exec;该用户模型命令已于 studio#501(2026-09-14)随 user-model 链路整体退役删除;dailyReflection git log/diff 改 execFileAsync(数组参数不经 shell 语义不变);ops preflight 磁盘 df 解析第三副本删除,委托 proc-probes.readDiskUsage(message 保真 Use% + available,statfs 不可用 → skip 不 fail)。preflight 其余同步 exec(vite build/lsof/cloudflared nohup spawn)为启动一次性路径,#374 方案未列,保留;进程清理与 cloudflared 探测的 ps aux 探测部分 #418 已收口;#454 补收 triage.service 这条遗漏同胞——diagnose 的 curl /health 与 tmux ls、act() 的修复命令与 service_down 验证 curl 全部改 exec-async 异步出口(timeout/shell 兜底语义不变),df/free 探测删除改 readDiskUsage/readMemoryUsage 同源(findings 前缀 `Disk:`/`Memory:`/`Tmux:` 不变,文案钉在 triage-agent.test.ts),ops/system-health.ts collectDisk 死代码里的同步 df 同清(statfs 口径,仅测试引用的模块) +- **WU 删除走 service.delete 单口(#538,ADR 2026-09-15 决策 3)**:monitor-lifecycle 每日 TTL(90 天无状态过滤照旧)与 ops/system-health runGC(done/closed 且 completedAt >30 天,无生产接线仅测试可达)的筛选逻辑留调用方,删除循环改调 `service.delete(id, { reason })`——墓碑事件行不再两处手抄(service 单点构造),删除经 workunit:removed 事件出声(SSE 转发前端删行,不进频道);TTL「满 90 天删任何状态」语义是否合理、runGC 接线与否各为独立 needs-triage 票 - **多实例单活**:`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 唤醒 -> runLoop observe(15s 轮询纯兜底,#523 起非主通道)-> 过滤 -> 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) diff --git a/apps/api/src/modules/discord/CONTEXT.md b/apps/api/src/modules/discord/CONTEXT.md index a2e5d8566..f857861b2 100644 --- a/apps/api/src/modules/discord/CONTEXT.md +++ b/apps/api/src/modules/discord/CONTEXT.md @@ -14,9 +14,9 @@ ### 依赖关系 **上游(本目录依赖):** -- `@dommaker/studio-shared`:提供 `FileStore`、`eventBus`、`WorkUnitSnapshot`、`logger` +- `@dommaker/studio-shared`:提供 `FileStore`、`logger` - `../channels/channel-message.service.ts`:`channelMessageService` -- `../workunit/workunit.service.ts`:`WorkUnitService` +- `../workunit/workunit.service.ts`:`WorkUnitService`;`../workunit/wu-closure.ts`:`closeWorkUnitWithNotice` - `../../utils/logger.ts`:logger - `express`、`crypto` 等标准库 @@ -29,3 +29,4 @@ - 必须配置环境变量 `DISCORD_PUBLIC_KEY`,否则交互端点返回 500 - `triggerRequirement` 依赖 `#研发` 频道存在,否则抛出错误 - WorkUnit 创建时 `creationMode` 标记为 `'discord'`,用于区分来源 +- **WU 写路径走 service 单口(#538,ADR 2026-09-15 决策 2)**:按钮 retry/retry-new = `unclaim` 回池 + 重试标记(resumeAfterRetry/extraRounds/freshPrompt)经 `updateMetadata` 锁内合并;abandon 与 `/studio stop` = `closeWorkUnitWithNotice`(closedBy: human-command,补 closedAt + workunit:closed 记录 + 频道出声)。旧 closeAndEmit/updateWorkUnitStatus 直写原语已删(metadata 整写覆盖、不写 closedAt、不发事件三宗罪),legacy `events:goal-execution` 事件随 Goal 体系退役停发。测试:`__tests__/routes.test.ts`(按钮两路径 + stop 收口)与 `__tests__/routes-stop.test.ts`(stop 委托 agentRunner) diff --git a/apps/api/src/modules/events/CONTEXT.md b/apps/api/src/modules/events/CONTEXT.md index f41d80619..c09ea1742 100644 --- a/apps/api/src/modules/events/CONTEXT.md +++ b/apps/api/src/modules/events/CONTEXT.md @@ -14,7 +14,7 @@ | 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 启动时调用,幂等 | +| workunit-events-bridge.ts | initWorkunitEventsBridge() | eventBus 的 workunit.created/status_changed + workunit:removed(#538 决策 5:service.delete 删除出声,负载 { id, channelId },前端 workunitStore/channelWorkStore 删行,不进频道)+ 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 回放 | | (agent-loop 直发) | workunit.execution.stream | WU 步内流式 chunk(Layer B,2026-07-30):step 执行中 CLI stdout 按行提炼 thinking/text/tool/result 直发,**SSE-only 不落盘**(行级体量防膨胀;步级归档走 execution.step);同前缀落 workunits topic | @@ -37,7 +37,7 @@ |------|--------|---------| | `__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__/workunit-events-bridge.test.ts` | 3 | workunit.* + workunit:removed + requirement.* 事件转发 'events' 频道(信封形状);桥 started 幂等是模块态,同文件后续用例 init 为 no-op 靠订阅残留生效 | | `__tests__/sse-routes.test.ts` | 11 | getTopicFromEventType 映射表锁定;#324 背压断开慢客户端;#491 重连 replay(按序补发/topics 过滤/不可解析游标/有洞不补发);#524 stream chunk 不入 replay buffer | | `__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 幂等 | diff --git a/apps/api/src/modules/triggers/CONTEXT.md b/apps/api/src/modules/triggers/CONTEXT.md index 29272d739..f2fc64bf8 100644 --- a/apps/api/src/modules/triggers/CONTEXT.md +++ b/apps/api/src/modules/triggers/CONTEXT.md @@ -11,7 +11,7 @@ Trigger 子系统(AS-026,3.28c-4):SCHEDULE(cron)+ EVENT(EventBus - `trigger-store.ts` — YAML 持久化 - `trigger-scheduler.ts` — SCHEDULE tick + EVENT EventBus 订阅 - `trigger-registry.ts` — 单例 TriggerScheduler(注入 eventBus) -- `trigger-action.ts` — CREATE 动作执行(从 trigger payload 创建 WorkUnit;#162 T8-E1:建单显式 `status='pending'` 人闸,按来源不按类型,人工确认 pending→unassigned 后才可认领) +- `trigger-action.ts` — CREATE 动作执行(从 trigger payload 创建 WorkUnit;#162 T8-E1:建单显式 `status='pending'` 人闸,按来源不按类型,人工确认 pending→unassigned 后才可认领)+ UPDATE 动作执行(#538 ADR 决策 6:禁改 status——TriggerStore 注册校验 + executeUpdateAction 执行守卫双保险;其余字段走 patchSnapshot 白名单经 service.update,metadata 走 updateMetadata 增量合并,白名单外字段丢弃并 warn) - `trigger-assignee-check.ts` — 指名执行者可运行性自检 `checkTriggerAssignees`(2026-09-10,doc-semantic-review 滞留 143h 事故根治):CREATE 类 trigger 的 `assigneeRole` 指名 = 独占认领(仅该角色 loop 可见),角色不存在/loop 非 running 即结构性死单;index.ts 启动挂载 loop 后调用,问题逐条 logger.error - `trigger.routes.ts` — Trigger 管理 REST API diff --git a/apps/api/src/modules/workunit/CONTEXT.md b/apps/api/src/modules/workunit/CONTEXT.md index 13b5bb34e..5c4912bfa 100644 --- a/apps/api/src/modules/workunit/CONTEXT.md +++ b/apps/api/src/modules/workunit/CONTEXT.md @@ -43,7 +43,7 @@ WorkUnit 核心域: 任务单元 CRUD、认领与状态机; F5 双向沟通的 N - **开图/物化/收尾哨兵**: analysis/plan(#471) WU done 且 l3.summary 含 `FOG:`/`DESTINATION:` -> 初始化 PMO map 台账(#471 起不再逐条建 decision 单, fog[].wuId 恒 null; metadata 落 mapOpenedAt 哨兵); spec WU done 且 l3.summary 含 `TASK:|AC:|BLOCKEDBY:|LEG:` -> 批量建 task 单(specTasksSpawnedAt 哨兵; #463 起无 TASK 行不落哨兵 = 人审有意不物化, 补确认 l3 补写再发 status_changed 可再触发物化; #471 起只服务存量 spec 单); done 触发角色记忆提取(memoryExtractedAt 哨兵)。各哨兵写入走「写入前重读合并」防同事件互覆。 - **#463 确认表单结构化**: review-passed 端点收可选 `confirm` body(decision 结论 / spec 物化 tasks / analysis·plan destination+fog+tasks——#471 起 plan 与 analysis 同形同契约)——校验与序列化正本在 confirm-payload.ts(resolveReviewConfirm), 后端把表单数据序列化进 l3.summary(存储契约不变, 下游 parseSpecTasks/parseMapOpening/decision-resolution 原样消费), confirm 与裸 summary 并存时 confirm 优先; analysis/plan 的 tasks 经 reviewPassed options.analysisTasks 覆写 metadata.analysisTasks(人审剔除/行内编辑生效)。预填数据源 = agent COMPLETE 落档的 specTasks(spec 输出 TASK 物化行)/decisionSuggestion(decision 输出 `## 结论摘要` 段)——prompt 契约见 agents/loop/prompt-composer CONTRACT_TEMPLATES。 - **#467 裁决轮提交**: `POST /:id/ruling`(human-only)——plan 裁决轮挂起(blocked + metadata.planRulings 非空 + waitingReason='plan-ruling')时收 `items:[{question, action:accept|reopen, conclusion?}]`, 校验(validateRulingItems, 400)后交 pmo/plan-ruling.applyPlanRuling: 批量落探路台账 + 组合裁决结果文本复活同会话。409 = 非 blocked / 无待裁 rulings。 -- **写单口与并发收口**: WU 全部写路径唯一出口 = WorkUnitService 语义方法(create/transitionStatus/update/delete/unclaim/claim + wu-closure 统一关闭 + updateMetadata 增量), 业务模块直调 FileStore 写原语即违规——docs/adr/2026-09-15-workunit-write-single-mouth.md(2026-09-15 定稿, #523 建单收口同源; 收口对象 = discord 按钮回调/monitor TTL/ops GC/trigger UPDATE 四绕过点, 实现随票落地后在此补记删除事件与 reason 口径)。锁内口径: 全部快照写路径走 FileStore 锁内 commitSnapshot/commitRemoval(appendEvent + upsertSnapshot 同一把 workunits flock 成对, 删除落 closed+deleted 墓碑, 墓碑事件行由 service.delete 单点构造调用方不自拼); metadata 增量写走 updateMetadata(id, mutator)(mutator 基于锁内最新 metadata, stepCount/consecutiveStuck 锁内重计, progressLog/pendingReplies 锁内尾部追加); createGuarded(input, guard) 支撑 review 建子 WU 锁内 check-then-create; 启动对账 reconcileIndex 接 apps/api index.ts。 +- **写单口与并发收口**: WU 全部写路径唯一出口 = WorkUnitService 语义方法(create/transitionStatus/update/delete/unclaim/claim + wu-closure 统一关闭 + updateMetadata 增量), 业务模块直调 FileStore 写原语即违规——docs/adr/2026-09-15-workunit-write-single-mouth.md(2026-09-15 定稿, #523 建单收口同源)。#538 已落地: discord 按钮回调(retry=unclaim+updateMetadata 合并 / abandon=wu-closure human-command)/monitor TTL/ops GC/trigger UPDATE 四绕过点全部收口(legacy events:goal-execution 停发)。删除口径: delete(id, { reason }) 墓碑事件行(closed + deleted:true + 可选 reason)由 service 单点构造, 删后发 workunit:removed(负载 id + channelId, 经 events/workunit-events-bridge 转发白名单进 SSE, 前端 workunitStore/channelWorkStore 删行; 只走事件流不进频道出声)。锁内口径: 全部快照写路径走 FileStore 锁内 commitSnapshot/commitRemoval(appendEvent + upsertSnapshot 同一把 workunits flock 成对, 删除落 closed+deleted 墓碑); metadata 增量写走 updateMetadata(id, mutator)(mutator 基于锁内最新 metadata, stepCount/consecutiveStuck 锁内重计, progressLog/pendingReplies 锁内尾部追加); createGuarded(input, guard) 支撑 review 建子 WU 锁内 check-then-create; 启动对账 reconcileIndex 接 apps/api index.ts。trigger UPDATE 禁改 status(TriggerStore 注册校验 + executeUpdateAction 执行守卫双保险), 其余字段走 patchSnapshot 白名单、metadata 走 updateMetadata 合并(ADR 决策 6)。 - **创建期父戳继承(#402)**: create/createGuarded 对带 parentId 的建单, 显式 metadata 未带归因戳(pmoId ‖ legacy ownershipProjectId)时从父 WU 继承, legacy 归一为 canonical pmoId(解析走 requirements/wu-pmo-attribution.ts 零依赖叶子, 无环)。创建期一次性落档语义不变, 显式传入优先, 父缺失/无戳不动(无归属 WU 合法, 不伪造)。覆盖默认管线展开、MCP/DELEGATE 子单等一切带父建单路径, 新增派生入口无需各自传戳。 - **metadata 字段承载**: workspaceRoot/ownershipSource/waitingReason(工程归属); worktreePath/worktreeBranch/worktreeBaseBranch/worktreeBaseRepo + verifyCommands/verifyReport/verifyFailCount/verifyFailHint(代码类 WU worktree 与自动验证); mergedAt/mergeCommit/mergeConflict/conflictFiles/mergeResolution(自动合并 + LLM 解冲突审计); pmoId(canonical PMO 归因戳, 唯一直读 key; legacy `ownershipProjectId` 同位名仅读取侧兼容); pmoBranch(PMO 分支合并目标); blockedBy/ac(接单依赖与验收标准, 机制只存不解释); progressLog(前序进展, 环形保留最近 5 条); sessionSummary(溢出滚动摘要); defaultTaskAssigneeId(analysis 确认默认执行角色); manualRelease/manualReleaseReason(转人工留痕); analysisTasks/analysisTasksSpawned/analysisTasksSpawnedAt/analysisFog/analysisDestination/autoConfirmedBy/autoConfirmedAt(analysis 派生哨兵集; #471 起 plan 同字段同口径); tokenBudget(#162 WU 级 token 额度; #471 起 publish 对 plan 默认落 1M, env STUDIO_PLAN_TOKEN_BUDGET 覆盖)/planStepAllowance(#471 plan 步数续期授权额度); specTasks/decisionSuggestion(#463 确认弹窗结构化预填, agent COMPLETE 落档)。 - **鉴权**: 15 条写端点(CRUD/claim/unclaim/review/status/讨论区发消息/编辑消息/opportunities adopt·ignore/resume/close) = requireAuth()+requireNotGuest(); GET 只读保持大门层。review-passed/review-rejected/POST /:id/status 拒绝 authorType=agent(403, A2A §4.4: 验收权只在人; 服务层 transitionStatus 不限 agent 内部合法迁移)。authorType/agentName 是自声明身份(不作凭证, 已知局限)。 diff --git a/apps/web/src/CONTEXT.md b/apps/web/src/CONTEXT.md index 30f6c6633..d36e9cbcc 100644 --- a/apps/web/src/CONTEXT.md +++ b/apps/web/src/CONTEXT.md @@ -26,7 +26,7 @@ Web 前端主源码。路由、全局状态、API 客户端、UI 组件、样式 | `fetchDiscipline` | `stores/fetchDiscipline.ts` | 取数纪律底座(#403,ADR 2026-08-31 决策 6):`createFetchGate`(seq 守卫)+ `disciplinedFetch`(TTL 锚点 / single-flight 并入 / force 不并入在途 / bypassTtl 只跳 TTL / inflight 只清自己)。只抽数取纪律不抽数据存法——rosterStore(全局单份)与 channelDataStore(per-key map)各自组合 | | `useDataPlaneSync` | `hooks/useDataPlaneSync.ts` | 数据面接线底座(#403):引用计数单例 SSE 路由(handleEvent 须引用稳定)+ onReconnect 强刷(maxAgeMs 0,single-flight 收敛)+ useGatedPoll 兜底;useRosterStoreSync 迁移其上(只剩 roster 事件路由),新数据面 store 接线一律复用,禁止逐 store 复印机制 | | `channelDataStore` | `stores/channelDataStore.ts` | 频道数据面 store(#403,ADR 2026-08-31):per-channelId 三切片——文件词表(FIFO 驻留上限 10)/ current-pmo / 成员 ID 列表(源自频道记录:页面水合写穿 + 面板修改成功本地写穿);纪律走 fetchDiscipline,(slice, channelId) 粒度各自 TTL 30s / 单飞 / seq。新鲜度 = TTL + 白捡触发器(requirement.created/updated → invalidateCurrentPmo),无 SSE 失效事件;agent 档案不进本 store(rosterStore.listAllAgents 是全量正本,「members 为空 → 全部 active」回退在消费方) | -| `channelWorkStore` / `useChannelWorkStoreSync` | `stores/channelWorkStore.ts` / `hooks/useChannelWorkStoreSync.ts` | 频道工作面数据面 store(#528,ADR 2026-08-31 模式;与 channelDataStore 并列——TTL 型慢变数据与事件驱动数据不同质):per-channelId 三 slice(wus / reqs / suggestions+currentWuId 同响应同 slice,resolved 旗标 = #488 台账等价替代)+ per-wuId wuChangedFiles(失败记 [] 不重试);wus/reqs 30s TTL 走 fetchDiscipline,suggestions 无 TTL 纯事件驱动(500ms 防抖内化 store);SSE 只路由活跃频道(页面级单消费方,activeChannelId 模块级登记):status_changed 全量快照直替(编造时间戳已删)/ requirement.* envelope 解包 upsert / message_sent 标脏;闸门动作 applyGateResult write-through;ChannelDetailPage 退回纯订阅者(4 份 state/手写订阅/防抖/重连对齐全删)。模块单例——页面测试 beforeEach 须 `__resetForTests()`(TTL 锚点/已打底 slice 跨用例短路首拉,ChannelDetailPage.test.tsx 顶部有样板) | +| `channelWorkStore` / `useChannelWorkStoreSync` | `stores/channelWorkStore.ts` / `hooks/useChannelWorkStoreSync.ts` | 频道工作面数据面 store(#528,ADR 2026-08-31 模式;与 channelDataStore 并列——TTL 型慢变数据与事件驱动数据不同质):per-channelId 三 slice(wus / reqs / suggestions+currentWuId 同响应同 slice,resolved 旗标 = #488 台账等价替代)+ per-wuId wuChangedFiles(失败记 [] 不重试);wus/reqs 30s TTL 走 fetchDiscipline,suggestions 无 TTL 纯事件驱动(500ms 防抖内化 store);SSE 只路由活跃频道(页面级单消费方,activeChannelId 模块级登记):status_changed 全量快照直替(编造时间戳已删)/ requirement.* envelope 解包 upsert / message_sent 标脏;闸门动作 applyGateResult write-through;workunit:removed(#538 删除出声,负载 { id, channelId },按活跃频道路由)→ applyWorkunitRemoved 删行;ChannelDetailPage 退回纯订阅者(4 份 state/手写订阅/防抖/重连对齐全删)。模块单例——页面测试 beforeEach 须 `__resetForTests()`(TTL 锚点/已打底 slice 跨用例短路首拉,ChannelDetailPage.test.tsx 顶部有样板) | | `useRosterStoreSync` | `hooks/useRosterStoreSync.ts` | rosterStore 实时接线(#346;#403 起机制抽至 useDataPlaneSync,本文件只剩 roster 事件路由与参数) | | `pmoDataStore` | `stores/pmoDataStore.ts` | company/project 数据面 store(#456):companies 全局单份 + projects per-companyId map(口径统一 limit=100,原 PMOPage 20 与 Library/FileRefChip 100 漂移收口);纪律走 fetchDiscipline(TTL 30s / 单飞 / seq),无 SSE 失效事件(ADR 决策 3),新鲜度 = TTL + 重连强刷 + 兜底轮询;错误落 *Error 状态(PMOPage 错误条用)、ensure 永不 reject、失败不落锚点。PmoProject = components/pmo/types.Project + gitRepo/deliveries(FileRefChip 匹配字段) | | `usePmoDataStoreSync` | `hooks/usePmoDataStoreSync.ts` | pmoDataStore 接线(#456):useDataPlaneSync 复用,无本域 SSE 事件 → noop handleEvent;ensureFresh = companies + 已驻留 projects 键全量对齐。App 级单点挂载(App.tsx PmoDataSync,同 RequirementChainSync 模式) | @@ -90,7 +90,7 @@ Web 前端主源码。路由、全局状态、API 客户端、UI 组件、样式 - **SSE 单连接**:全应用仅根部 `WebSocketProvider` 建一个 EventSource(/events/stream),禁止开第二条。**2026-08-25**:/events/stream 已移出 PUBLIC_API,URL 经 `?token=` 携带 JWT(buildSseUrl 每次连接现取 authStore token,EventSource 无法设 Authorization 头)。 - **SSE 事件负载消费约定**(2026-08 负载深化批 2,决策 4-6):`workunit.execution.step` 负载带可选 `channelId`——`useChannelLiveExecutions` 带上时按频道过滤、缺省不过滤(向后兼容);`workunit.status_changed` 负载 = 全量 WorkUnitData(`parseLiveWuRef` 解析出 id/status/channelId/metadata/type/scope/parentId——parentId 自 #442 起解析留存;#447 起前端不再判活跃 review 子工单(该判定后端化),parentId 仅作负载透传字段),终态清理 step 条目不限频道(防他频道残留);ChannelDetailPage 的 waitingWus chip 与 channelReqs(#394 起喂右栏链路卡)= 进频道一次 REST 打底 + SSE 增量维护(`status_changed` / `requirement.created|updated`),不再依赖 messages.length 触发重拉。**requirement.\* 负载 = `{ requirement }` 信封全量 RequirementData**(#415 修正:信封自桥接入起即嵌套全量,旧 `parseRequirementRef` 读顶层平铺字段从不匹配 → 补拉是死代码且事件被静默丢弃;今 `parseRequirementPayload` 解信封就地 upsert 零补拉,channelId 缺省不落 key 防合并抹归属)。 - **SSE 负载消费约定·批 3**(决策 7-9):RequirementsDocCard 纯静态渲染(5s 轮询与 executing 进度分支已删——#278 后无 `meta.status='executing'` 写入方,死代码);WorkUnitDrawer = 开抽屉一次性 REST 打底(wu 详情/token 历史/overhead 各一次)+ `workunit.status_changed`(同 id 负载直替本地 wu)/ `workunit.tokens`(复用 `parseWorkunitTokenEvents` 防御解析,他 WU/缺字段跳过)SSE 增量,无 eventTick 防抖重拉,`getOverhead` 不随事件重拉;`useWebSocket`/`WebSocketProvider` 暴露 `onReconnect` 注册口(首次 onopen 不触发,重连 onopen 触发一次),ChannelDetailPage 重连落点 = messages `refresh`(chips 缺口靠后续 SSE 增量自愈,强制对齐留收尾批)。 -- **SSE 负载消费约定·批 4**(#318,#313 follow-up):`useWorkUnitEvents` 门铃 hook 已删——三处剩余消费面全部改负载直更。ExecutionSteps:`workunit.execution.step` 负载经 `parseExecutionStepEvents` 就地 append(executionId-step 去重后者覆盖、步号升序、首拉在途事件暂存并入),重连一次性 refetch;WorkUnitListPage:store `applyWorkunitEvent`——`status_changed` 直替/移除行(未知行不插入防跨页重复)、`created` 插头部,过滤不符就地移除/不插入,旧形状负载缺 `claimable` 保留原值(ADR D2 回退);**取舍 a**:分页 total 本地 ±1 近似维护,页边界不追齐——本页无轮询兜底,自愈靠 SSE 重连 refetch、过滤切换/创建/审查等操作触发的 loadWorkUnits 与路由重进首拉;**取舍 c**:`status_changed` 未知行(不在当前页)即使新进过滤集也不插入(服务端过滤 + 分页下无法判定页内归属,插入会跨页重复),同靠上述路径自愈;AgentDetailPage:instance/当前卡状态面已上移 rosterStore(#346),本页保留 `workunit.status_changed` 历史行就地更新;**取舍 b**:历史任务「最近 20 条 + total」窗口无事件语义——命中本实例时 800ms 防抖只重拉历史区 1 接口对齐,不再整页 5 接口。后端 additive:`workunit.created/status_changed` 负载附 `claimable`(口径同 GET / 列表路由:unassigned 且无未了结依赖才 true,其余恒 false 不读 index)。 +- **SSE 负载消费约定·批 4**(#318,#313 follow-up):`useWorkUnitEvents` 门铃 hook 已删——三处剩余消费面全部改负载直更。ExecutionSteps:`workunit.execution.step` 负载经 `parseExecutionStepEvents` 就地 append(executionId-step 去重后者覆盖、步号升序、首拉在途事件暂存并入),重连一次性 refetch;WorkUnitListPage:store `applyWorkunitEvent`——`status_changed` 直替/移除行(未知行不插入防跨页重复)、`created` 插头部,过滤不符就地移除/不插入,旧形状负载缺 `claimable` 保留原值(ADR D2 回退);**取舍 a**:分页 total 本地 ±1 近似维护,页边界不追齐——本页无轮询兜底,自愈靠 SSE 重连 refetch、过滤切换/创建/审查等操作触发的 loadWorkUnits 与路由重进首拉;**取舍 c**:`status_changed` 未知行(不在当前页)即使新进过滤集也不插入(服务端过滤 + 分页下无法判定页内归属,插入会跨页重复),同靠上述路径自愈;AgentDetailPage:instance/当前卡状态面已上移 rosterStore(#346),本页保留 `workunit.status_changed` 历史行就地更新;**取舍 b**:历史任务「最近 20 条 + total」窗口无事件语义——命中本实例时 800ms 防抖只重拉历史区 1 接口对齐,不再整页 5 接口。后端 additive:`workunit.created/status_changed` 负载附 `claimable`(口径同 GET / 列表路由:unassigned 且无未了结依赖才 true,其余恒 false 不读 index)。**workunit:removed(#538,ADR 2026-09-15 决策 5)**:GC/TTL 删除出声——负载 { id, channelId },WorkUnitListPage → store `removeWorkunit` 删行(total/allTotal 各 -1 近似维护,同取舍 a),不再悬挂到重连 refetch;只走事件流不进频道出声。 - **未归属过滤(#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 颜色语义不动(**2026-09-11 起 AgentDashboard StatFilter 对齐同档**:数字 --fs-stat→--fs-title 归 `.agd-stat-num`,激活态补同款 accent 底线,两页页头 chip 视觉一致);② 「待人工」行级提权——`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 提醒。 From 045e4b97a9570743a852ef97d0c5d407ce602a0a Mon Sep 17 00:00:00 2001 From: dommaker Date: Tue, 15 Sep 2026 23:09:00 +0800 Subject: [PATCH 015/116] =?UTF-8?q?fix(agents):=20monitor=20TTL=20?= =?UTF-8?q?=E5=8A=A0=E7=BB=88=E6=80=81=E5=AE=88=E5=8D=AB=E2=80=94=E2=80=94?= =?UTF-8?q?=E4=BB=85=E5=88=A0=20done/closed=EF=BC=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=BC=BA=E5=88=A0=E8=BF=9B=E8=A1=8C=E4=B8=AD=E9=95=BF=E5=91=A8?= =?UTF-8?q?=E6=9C=9F=E5=8D=95=20(#540)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 90 天无状态过滤是 G31 GoalExecution TTL(执行记录无生命周期状态)迁到 WorkUnit 时机械沿用的产物,非有意设计;runGC 的 done/closed 口径才是 针对 WorkUnit 的有意筛选。进行中/阻塞单满 90 天改走探测告警/人审, 缺 status 的历史行按非终态保留不误删。 --- apps/api/src/modules/agents/CONTEXT.md | 2 +- .../__tests__/monitor-lifecycle.test.ts | 32 +++++++++++++++++-- .../agents/monitor/monitor-lifecycle.ts | 12 +++++-- .../2026-09-15-workunit-write-single-mouth.md | 2 +- 4 files changed, 40 insertions(+), 8 deletions(-) diff --git a/apps/api/src/modules/agents/CONTEXT.md b/apps/api/src/modules/agents/CONTEXT.md index 8e40ca175..f8fec2385 100644 --- a/apps/api/src/modules/agents/CONTEXT.md +++ b/apps/api/src/modules/agents/CONTEXT.md @@ -39,7 +39,7 @@ Agent 配置(profile)、运行实例(instance)、决策循环(loop) - **Idle 心跳 45s**,超时扫描 5min;**isOnline** = loop 存活+心跳新鲜(≤5min);#345 起 isOnline/每角色最新 error 聚合单源 `summarizeRoleStates`(agent-instance.service),agent-profile list 与 instance-timeout-scan 的 5min 窗口同源(INSTANCE_ALIVE_TIMEOUT_MS) - **系统探测 /proc 单出口(#344)**:新探测先落 `ops/proc-probes.ts`(零子进程)。磁盘 = statfs (blocks−bavail)/blocks(含 root 保留块,比 df Use% 偏高 1–5pp);内存 = MemTotal−MemAvailable(比 free -m used 列偏小,现代可用口径);僵尸 = /proc//stat state=Z。monitor systemHealthCheck 阈值不变(disk>90 warning / mem>95 critical / zombie>0 warning),details 的 dfOutput/freeOutput 已随 shell 解析删除,保留 usagePercent/zombieCount;ops 残留 ss/systemctl 走 async exec。#418 起命令行类探测同出口:readProcessCmdline 按 NUL→空格还原 ps aux 渲染口径(僵尸/内核线程空 cmdline → null 不计),countProcessesByCmdline 子串计数、listPidsByCmdline 按 JS RegExp 列 pid(ops-rules processes_to_clean 契约)、listZombieProcesses 列 pid+comm;ops.service isCloudflaredRunning/cleanStaleProcesses(kill 改 process.kill SIGKILL,失败不计口径不变)/countProcesses、triage 僵尸列举+claude 计数、env-snapper cloudflared 探测均委托此单出口 - **monitor-round 零同步子进程(#374)**:全部同步 exec 收口(review 补收票面漏列的第四处)——gcStaleWorktrees `git worktree prune`(cwd/5s timeout 语义不变)与 checkKnowledgeHealth 用户模型更新(30s timeout、`|| echo "{}"` shell 兜底不变)改 `monitor/exec-async.ts` 异步 exec;该用户模型命令已于 studio#501(2026-09-14)随 user-model 链路整体退役删除;dailyReflection git log/diff 改 execFileAsync(数组参数不经 shell 语义不变);ops preflight 磁盘 df 解析第三副本删除,委托 proc-probes.readDiskUsage(message 保真 Use% + available,statfs 不可用 → skip 不 fail)。preflight 其余同步 exec(vite build/lsof/cloudflared nohup spawn)为启动一次性路径,#374 方案未列,保留;进程清理与 cloudflared 探测的 ps aux 探测部分 #418 已收口;#454 补收 triage.service 这条遗漏同胞——diagnose 的 curl /health 与 tmux ls、act() 的修复命令与 service_down 验证 curl 全部改 exec-async 异步出口(timeout/shell 兜底语义不变),df/free 探测删除改 readDiskUsage/readMemoryUsage 同源(findings 前缀 `Disk:`/`Memory:`/`Tmux:` 不变,文案钉在 triage-agent.test.ts),ops/system-health.ts collectDisk 死代码里的同步 df 同清(statfs 口径,仅测试引用的模块) -- **WU 删除走 service.delete 单口(#538,ADR 2026-09-15 决策 3)**:monitor-lifecycle 每日 TTL(90 天无状态过滤照旧)与 ops/system-health runGC(done/closed 且 completedAt >30 天,无生产接线仅测试可达)的筛选逻辑留调用方,删除循环改调 `service.delete(id, { reason })`——墓碑事件行不再两处手抄(service 单点构造),删除经 workunit:removed 事件出声(SSE 转发前端删行,不进频道);TTL「满 90 天删任何状态」语义是否合理、runGC 接线与否各为独立 needs-triage 票 +- **WU 删除走 service.delete 单口(#538,ADR 2026-09-15 决策 3)**:monitor-lifecycle 每日 TTL 与 ops/system-health runGC(done/closed 且 completedAt >30 天,无生产接线仅测试可达)的筛选逻辑留调用方,删除循环改调 `service.delete(id, { reason })`——墓碑事件行不再两处手抄(service 单点构造),删除经 workunit:removed 事件出声(SSE 转发前端删行,不进频道);monitor TTL 筛选 #540 起加终态守卫——仅删 done/closed(原「90 天无状态过滤」系 G31 GoalExecution TTL 机械沿用,非有意设计;缺 status 按非终态保留);runGC 接线与否仍为独立 needs-triage 票 - **多实例单活**:`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 唤醒 -> runLoop observe(15s 轮询纯兜底,#523 起非主通道)-> 过滤 -> 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) diff --git a/apps/api/src/modules/agents/__tests__/monitor-lifecycle.test.ts b/apps/api/src/modules/agents/__tests__/monitor-lifecycle.test.ts index abe8ada81..52a3c3d1f 100644 --- a/apps/api/src/modules/agents/__tests__/monitor-lifecycle.test.ts +++ b/apps/api/src/modules/agents/__tests__/monitor-lifecycle.test.ts @@ -115,11 +115,12 @@ describe('dataLifecycle (每日 23:55 TTL)', () => { expect(fileStore.getIndex).not.toHaveBeenCalled(); }); - it('in window: runs precipitation gate + deletes WorkUnits older than 90 days, once per day', async () => { + it('in window: runs precipitation gate + deletes terminal WorkUnits older than 90 days, once per day', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date(2026, 6, 19, 23, 55)); // 本地 23:55 - const oldWu = { id: 'wu-old', createdAt: new Date(Date.now() - 100 * 24 * 3600_000).toISOString() }; - const newWu = { id: 'wu-new', createdAt: new Date().toISOString() }; + // #540:仅终态(done/closed)才进 TTL 删除;进行中/阻塞单满 90 天保留 + const oldWu = { id: 'wu-old', status: 'done', createdAt: new Date(Date.now() - 100 * 24 * 3600_000).toISOString() }; + const newWu = { id: 'wu-new', status: 'active', createdAt: new Date().toISOString() }; const fileStore = makeFileStore({ getIndex: vi.fn(async () => [oldWu, newWu]) }); const state = { lastPrecipitateRun: '', lastDataLifecycleRun: '' }; @@ -144,6 +145,31 @@ describe('dataLifecycle (每日 23:55 TTL)', () => { expect(fileStore.commitRemoval).toHaveBeenCalledTimes(1); }); + it('满 90 天的非终态单(active/blocked/in_review/pending/unassigned/无 status)不删,仅终态(done/closed)删(#540)', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 6, 19, 23, 55)); + const old = () => new Date(Date.now() - 100 * 24 * 3600_000).toISOString(); + const fileStore = makeFileStore({ + getIndex: vi.fn(async () => [ + { id: 'wu-done', status: 'done', createdAt: old() }, + { id: 'wu-closed', status: 'closed', createdAt: old() }, + { id: 'wu-active', status: 'active', createdAt: old() }, + { id: 'wu-blocked', status: 'blocked', createdAt: old() }, + { id: 'wu-review', status: 'in_review', createdAt: old() }, + { id: 'wu-pending', status: 'pending', createdAt: old() }, + { id: 'wu-unassigned', status: 'unassigned', createdAt: old() }, + { id: 'wu-nostatus', createdAt: old() }, // 缺 status 按非终态保留(不误删) + ]), + }); + const state = { lastPrecipitateRun: '', lastDataLifecycleRun: '' }; + + await dataLifecycle(fileStore, state); + + expect(fileStore.commitRemoval).toHaveBeenCalledTimes(2); + const deletedIds = fileStore.commitRemoval.mock.calls.map((c: [unknown, string]) => c[1]); + expect(deletedIds.sort()).toEqual(['wu-closed', 'wu-done']); + }); + it('truncates 统一事件文件 keeping last 7 days(createdAt 口径,坏行保留)', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date(2026, 6, 19, 23, 55)); diff --git a/apps/api/src/modules/agents/monitor/monitor-lifecycle.ts b/apps/api/src/modules/agents/monitor/monitor-lifecycle.ts index 7eb405989..20fcd848e 100644 --- a/apps/api/src/modules/agents/monitor/monitor-lifecycle.ts +++ b/apps/api/src/modules/agents/monitor/monitor-lifecycle.ts @@ -178,14 +178,20 @@ export async function dataLifecycle(fileStore: FileStore, state: LifecycleState) try { const execCutoffMs = Date.now() - 90 * 24 * 3600_000; const allWu = await fileStore.getIndex(); - const toDelete = allWu.filter(s => new Date(s.createdAt).getTime() < execCutoffMs); - // #538(ADR 2026-09-15 决策 3):筛选逻辑留本调用方(90 天无状态过滤照旧), + // #540:加终态守卫——仅删 done/closed。原「无状态过滤」是 G31 GoalExecution TTL + // (执行记录,无生命周期状态)迁到 WorkUnit 时机械沿用的产物,非有意设计; + // 进行中/阻塞的长周期单不再被强删(僵尸单走探测告警/人审,不走静默删除)。 + // 缺 status 的历史行按非终态保留,不误删。 + const TERMINAL_STATUSES = new Set(['done', 'closed']); + const toDelete = allWu.filter(s => + TERMINAL_STATUSES.has(s.status) && new Date(s.createdAt).getTime() < execCutoffMs); + // #538(ADR 2026-09-15 决策 3):筛选逻辑留本调用方, // 删除循环走 service.delete 单口——墓碑单点构造 + workunit:removed 出声。 // 动态引入避环(agents → workunit 静态链会经 channels 绕回 agents) const { WorkUnitService } = await import('../../workunit/workunit.service.js'); const workUnitService = new WorkUnitService(fileStore); for (const wu of toDelete) { - await workUnitService.delete(wu.id, { reason: 'monitor TTL: WorkUnit older than 90 days' }); + await workUnitService.delete(wu.id, { reason: 'monitor TTL: terminal WorkUnit older than 90 days' }); } logger.info('[MonitorService] TTL: WorkUnit cleaned', { deleted: toDelete.length, cutoff: new Date(execCutoffMs).toISOString() }); } catch (e) { diff --git a/docs/adr/2026-09-15-workunit-write-single-mouth.md b/docs/adr/2026-09-15-workunit-write-single-mouth.md index c835b8949..625f82fce 100644 --- a/docs/adr/2026-09-15-workunit-write-single-mouth.md +++ b/docs/adr/2026-09-15-workunit-write-single-mouth.md @@ -29,7 +29,7 @@ ## 不修(另议,勿并进收口票) - **关闭路径不广播**:wu-closure 不发 status_changed → 前端列表不实时刷新(discord abandon 收口后同现状,不回退也不提前修)= 架构评审候选 C2(双关闭路径合并)的开放问题。 -- **monitor TTL「满 90 天删任何状态(含 active/blocked)」是否合理**:needs-triage。 +- **monitor TTL「满 90 天删任何状态(含 active/blocked)」是否合理**:~~needs-triage~~ 已裁决(#540,2026-09-15):非有意设计——90 天无状态过滤是 G31 GoalExecution TTL(执行记录无生命周期)迁到 WorkUnit 时机械沿用的产物;monitor TTL 加终态守卫,仅删 done/closed,缺 status 按非终态保留。 - **system-health runGC 无生产接线**:接线 or 删除,needs-triage(收口票只迁它的写路径,不动接线状态)。 ## 实施切票 From bd18c8d0bb6e78649d9197eca063b8aa3717fadc Mon Sep 17 00:00:00 2001 From: dommaker Date: Tue, 15 Sep 2026 23:25:01 +0800 Subject: [PATCH 016/116] =?UTF-8?q?refactor(agents):=20=E6=8B=86=E9=99=A4?= =?UTF-8?q?=20agent-loop=20re-export=20=E9=97=A8=E9=9D=A2=EF=BC=8C?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=20import=20=E8=BF=81=E7=9C=9F=E5=B1=9E?= =?UTF-8?q?=E4=B8=BB=20(#544)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - agent-loop.ts 导出面收敛为 AgentLoop + StepResult(type re-export 保留, completion-gates 生产消费方经门面取类型);删除 parsers/events/guards 三处 re-export 块,主文件不再为门面承担子模块导出语义 - 14 个测试文件 import 迁真属主:agent-loop-parsers(resolveTarget/ parseAgentOutput/dynamicInterval 等)、agent-loop-events(tokens/tool:call 落盘)、agent-loop-guards(测试 WU 守卫);含 loop/__tests__/claim-fitness 与 agent-loop-v2 的动态 import - agents/CONTEXT.md loop/ 清单校正:补齐 agent-loop-registry/claim-fitness/ daily-token-budget/delegate-branch/prompt-composer/review-dispatcher/ session-resume/wu-lease,根目录段移除已删除文件(agent-output-parser/ agent-loop-workspace/agent-loop-utils/agent-loop-record-result/ agent-loop-step-guards/workunit-token-events 等) 机械票:纯 import 迁移 + 删 re-export + 文档清单校正,零逻辑改动。 验收:vitest run --changed origin/master 351 文件 3744 通过;tsc-gate 无新错。 --- apps/api/src/modules/agents/CONTEXT.md | 4 ++-- .../agents/__tests__/agent-loop-a2a.test.ts | 3 ++- .../__tests__/agent-loop-assignee.test.ts | 3 ++- .../agent-loop-decision-conclusion.test.ts | 2 +- .../__tests__/agent-loop-need-input.test.ts | 3 ++- .../__tests__/agent-loop-plan-ruling.test.ts | 3 ++- .../agent-loop-stale-claim-guard.test.ts | 3 ++- .../agent-loop-task-breakdown.test.ts | 2 +- .../__tests__/agent-loop-tokens.test.ts | 2 +- .../agents/__tests__/agent-loop-v2.test.ts | 2 +- .../agents/__tests__/agent-loop.test.ts | 4 +++- .../__tests__/daily-token-budget.test.ts | 3 ++- .../modules/agents/__tests__/e2e-verify.ts | 3 ++- .../__tests__/token-burn-guards.test.ts | 11 +++------ .../loop/__tests__/claim-fitness.test.ts | 3 ++- .../api/src/modules/agents/loop/agent-loop.ts | 23 ++++--------------- 16 files changed, 32 insertions(+), 42 deletions(-) diff --git a/apps/api/src/modules/agents/CONTEXT.md b/apps/api/src/modules/agents/CONTEXT.md index f8fec2385..6e9f12d0b 100644 --- a/apps/api/src/modules/agents/CONTEXT.md +++ b/apps/api/src/modules/agents/CONTEXT.md @@ -20,8 +20,8 @@ Agent 配置(profile)、运行实例(instance)、决策循环(loop) - `ops/` - ops.service(preflight 启动检查/health 轮询自愈/代理守护/默认数据 ensure;#409 起 worktree GC 已归一到 monitor-system-probes.gcStaleWorktrees 单实现,Ops 版 cleanupWorktrees 及其 hourly 挂载删除)、createHealthRoutes(/healthz 健康端点)、proc-probes(#344:/proc 系统探测单出口——statfs 磁盘/meminfo 内存//proc/*/stat 僵尸//proc/loadavg,零子进程;#374 起 ops.getStatus、monitor systemHealthCheck、ops preflight 磁盘检查三处同源委托;#418 起 +readProcessCmdline/countProcessesByCmdline/listPidsByCmdline/listZombieProcesses 四导出,triage.service 与 knowledge/env-snapper 探测同源) - `auditor/` - auditor.service(门面,24h 日审跨任务审计)、auditor-rules(错误归类/技能建议/知识健康;#523 起 OKR 低达成建 okr_proposal 单收口 WorkUnitService.create——原 commitSnapshot 直写不发 eventBus 事件,对唤醒体系隐形)、auditor-execution(低风险自动应用/确认卡片经 review-proposal 正本发卡/Resolution/Triage 升级;#439 起铃铛通知 link 带消息粒度——发卡后按 proposalId 反查卡消息拼 `?highlight=`,link 构造唯一出口 `buildAuditorNotificationLink`,反查不到降级频道粒度不阻断)、auditor-reports(行为趋势/七日趋势/tier 反馈)、review-adapter(#356:auditor_suggestion 卡接线 review-proposal 正本,kind=auditor,onApprove 建未指派 task 工单——自旧 channels/card-decision.service 搬入;审批走通用端点 /review-proposals/auditor/:id/*;`findAuditorCardMessageId` = 按提案 id 反查卡消息唯一出口,onApprove 原卡链接与 #439 通知 link 共用) - `knowledge/` - knowledge-curator.service(门面,冷启动+每日维护)、knowledge-extraction(提取 prompt 单一来源)、knowledge-cold-start(四源导入 docs/code/git/manual)、knowledge-maintenance(语义去重/质量评估/过期验证/矛盾审查) -- `loop/` - agent-loop(循环编排,拆分后仅保留类逻辑+re-export;#363:原 start() 同角色 terminated 启动清理已拆除,回收归 instance-timeout-scan)、agent-loop.types(类型契约,纯类型零运行时)、agent-loop-parsers(输出解析+prompt 模板纯函数)、agent-loop-events(tokens/tool:call 落盘,provider 分流 usage;#320 起 workunit:tokens 落盘后顺带 `noteTokenLedgerWritten` 更新 token 账本 `utils/token-ledger`,失败隔离)、agent-loop-guards(测试 WU 守卫+excludeAssignee)、lease-heartbeat(WU 租约心跳 30s,fencing 校验)、context-overflow(溢出识别+滚动摘要)、executor(Executor 接口,LocalExecutor 委托 agentRunner)、execution-step-events(步级事件落盘+步内流式 SSE)、wu-verification(自动验证可复用实现)、completion-gates(收口守卫链:提交/子任务/空diff/契约产物/验证/软观测)、review-contract(verdict 语义单一来源 pass/reject/needs-info) -- 根目录 - default-triggers(10 个系统 trigger:inspection-scan/dispatch-reconciliation/doc-semantic-review/#523 workunit-gate-escalation 等)、agent-loop.ts(门面,决策循环编排+re-export)、agent-output-parser(ACTION 协议解析/审查结论/任务拆分/动态间隔)、prompt-composer(prompt 组装,分段软定额截断)、agent-loop-workspace(worktree 解析/归属链/PMO 分支)、session-resume(续用判定+上限 MAX_SESSIONS_PER_WU=5)、agent-targeting(Observations->Target 解析)、default-provider(provider 默认选取)、instance-timeout-scan(心跳过期 5min 扫描+pid 复核;#363:统一回收 terminated 实例——跨角色 deleteState 连带判空删目录,闭环实例目录生命周期)、dispatch-reconciliation(派工/评审断链 5min 对账;#523 起自愈补建/重派成功本频道出声「断链已自愈」,告警/失败仍只走 #62 告警管线)、workunit-token-events(tokens/tool:call 事件写入)、agent-loop-utils(进程存活/git 根/worktrees 工具)、wu-test-guards(测试特征 WU 判定)、agent-loop-instance-state(实例状态:启动失败/idle 心跳/忙闲 SSE)、agent-loop-record-result(recordResult:提交/子任务/验证+状态迁移+里程碑;步骤安全阀 stepSafetyLimit——review=30、其余=15、#471 起 plan=null 不走强制收口)、agent-loop-step-guards(前置守卫:测试 WU 关闭/token 预算熔断;#471 起 plan 步数额度守卫在 agentStep 内 PLAN_STEP_LIMIT=60,到线 need_input 挂 blocked 转人,waiting-input 回复续期) +- `loop/` - agent-loop(循环编排,导出面 = AgentLoop + StepResult;#544 拆除 re-export 门面,子模块符号由真属主直出;#363:原 start() 同角色 terminated 启动清理已拆除,回收归 instance-timeout-scan)、agent-loop.types(类型契约,纯类型零运行时)、agent-loop-registry(profileId→running AgentLoop 注册表,profile 生命周期事件驱动挂载/卸载)、agent-loop-parsers(输出解析+prompt 模板纯函数)、agent-loop-events(tokens/tool:call 落盘,provider 分流 usage;#320 起 workunit:tokens 落盘后顺带 `noteTokenLedgerWritten` 更新 token 账本 `utils/token-ledger`,失败隔离)、agent-loop-guards(测试 WU 守卫+excludeAssignee)、claim-fitness(认领前适任判断,决策 14)、daily-token-budget(每日 token 预算熔断,C3)、delegate-branch(A2A DELEGATE 分支委派政策判定)、prompt-composer(prompt/上下文组装,分段软定额截断)、session-resume(会话续用判定,#94)、wu-lease(WU 租约/fencing 追踪器,#209)、lease-heartbeat(WU 租约心跳 30s,fencing 校验)、context-overflow(溢出识别+滚动摘要)、executor(Executor 接口,LocalExecutor 委托 agentRunner)、execution-step-events(步级事件落盘+步内流式 SSE)、wu-verification(自动验证可复用实现)、completion-gates(收口守卫链:提交/子任务/空diff/契约产物/验证/软观测)、review-contract(verdict 语义单一来源 pass/reject/needs-info)、review-dispatcher(状态机驱动 review 系统代派,AC-4.1~4.5) +- 根目录 - default-triggers(10 个系统 trigger:inspection-scan/dispatch-reconciliation/doc-semantic-review/#523 workunit-gate-escalation 等)、agent-targeting(Observations->Target 解析)、default-provider(provider 默认选取)、instance-timeout-scan(心跳过期 5min 扫描+pid 复核;#363:统一回收 terminated 实例——跨角色 deleteState 连带判空删目录,闭环实例目录生命周期)、dispatch-reconciliation(派工/评审断链 5min 对账;#523 起自愈补建/重派成功本频道出声「断链已自愈」,告警/失败仍只走 #62 告警管线)、wu-test-guards(测试特征 WU 判定) ### 依赖关系 diff --git a/apps/api/src/modules/agents/__tests__/agent-loop-a2a.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop-a2a.test.ts index 9f9e463a5..d6fc96f9a 100644 --- a/apps/api/src/modules/agents/__tests__/agent-loop-a2a.test.ts +++ b/apps/api/src/modules/agents/__tests__/agent-loop-a2a.test.ts @@ -35,7 +35,8 @@ vi.mock('../../knowledge/knowledge-service', () => ({ }, })); -import { AgentLoop, parseAgentOutput } from '../loop/agent-loop'; +import { AgentLoop } from '../loop/agent-loop'; +import { parseAgentOutput } from '../loop/agent-loop-parsers'; function makeProfile(id: string, name: string): AgentProfileData { return { diff --git a/apps/api/src/modules/agents/__tests__/agent-loop-assignee.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop-assignee.test.ts index c953f2577..ca00747cd 100644 --- a/apps/api/src/modules/agents/__tests__/agent-loop-assignee.test.ts +++ b/apps/api/src/modules/agents/__tests__/agent-loop-assignee.test.ts @@ -28,7 +28,8 @@ vi.mock('../../knowledge/knowledge-service', () => ({ }, })); -import { AgentLoop, resolveTarget } from '../loop/agent-loop'; +import { AgentLoop } from '../loop/agent-loop'; +import { resolveTarget } from '../loop/agent-loop-parsers'; const SELF_ROLE_ID = 'role-self'; const OTHER_ROLE_ID = 'role-other'; diff --git a/apps/api/src/modules/agents/__tests__/agent-loop-decision-conclusion.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop-decision-conclusion.test.ts index fae6cd94c..8ab397215 100644 --- a/apps/api/src/modules/agents/__tests__/agent-loop-decision-conclusion.test.ts +++ b/apps/api/src/modules/agents/__tests__/agent-loop-decision-conclusion.test.ts @@ -3,7 +3,7 @@ * metadata.decisionSuggestion,确认弹窗预填 + 收口闸 2 契约产物锚点的数据源)。 */ import { describe, it, expect } from 'vitest'; -import { parseDecisionConclusion } from '../loop/agent-loop.js'; +import { parseDecisionConclusion } from '../loop/agent-loop-parsers.js'; describe('parseDecisionConclusion', () => { it('提取 `## 结论摘要` 段正文(到下一个 ## 段为止)', () => { diff --git a/apps/api/src/modules/agents/__tests__/agent-loop-need-input.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop-need-input.test.ts index a1d8e04c3..e325ef269 100644 --- a/apps/api/src/modules/agents/__tests__/agent-loop-need-input.test.ts +++ b/apps/api/src/modules/agents/__tests__/agent-loop-need-input.test.ts @@ -26,7 +26,8 @@ vi.mock('../../knowledge/knowledge-service', () => ({ }, })); -import { AgentLoop, resolveTarget } from '../loop/agent-loop'; +import { AgentLoop } from '../loop/agent-loop'; +import { resolveTarget } from '../loop/agent-loop-parsers'; import { resumeWaitingWorkUnit } from '../../workunit/waiting-input.js'; const mockRole = { diff --git a/apps/api/src/modules/agents/__tests__/agent-loop-plan-ruling.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop-plan-ruling.test.ts index abf44719a..cf37a1f86 100644 --- a/apps/api/src/modules/agents/__tests__/agent-loop-plan-ruling.test.ts +++ b/apps/api/src/modules/agents/__tests__/agent-loop-plan-ruling.test.ts @@ -22,7 +22,8 @@ vi.mock('../../knowledge/knowledge-service', () => ({ }, })); -import { AgentLoop, parseAgentOutput } from '../loop/agent-loop'; +import { AgentLoop } from '../loop/agent-loop'; +import { parseAgentOutput } from '../loop/agent-loop-parsers'; const mockRole = { id: 'role-ruling', diff --git a/apps/api/src/modules/agents/__tests__/agent-loop-stale-claim-guard.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop-stale-claim-guard.test.ts index 8b4b6302f..245378933 100644 --- a/apps/api/src/modules/agents/__tests__/agent-loop-stale-claim-guard.test.ts +++ b/apps/api/src/modules/agents/__tests__/agent-loop-stale-claim-guard.test.ts @@ -28,7 +28,8 @@ vi.mock('../../knowledge/knowledge-service', () => ({ }, })); -import { AgentLoop, resolveTarget } from '../loop/agent-loop'; +import { AgentLoop } from '../loop/agent-loop'; +import { resolveTarget } from '../loop/agent-loop-parsers'; const SELF_ROLE_ID = 'role-self'; const SELF_INSTANCE_ID = 'instance-self'; diff --git a/apps/api/src/modules/agents/__tests__/agent-loop-task-breakdown.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop-task-breakdown.test.ts index e11b2cded..6712c88b9 100644 --- a/apps/api/src/modules/agents/__tests__/agent-loop-task-breakdown.test.ts +++ b/apps/api/src/modules/agents/__tests__/agent-loop-task-breakdown.test.ts @@ -2,7 +2,7 @@ * parseTaskBreakdown 单测(PMO 分析接力:analysis WU 输出的 TASK: 行解析) */ import { describe, it, expect } from 'vitest'; -import { parseTaskBreakdown } from '../loop/agent-loop.js'; +import { parseTaskBreakdown } from '../loop/agent-loop-parsers.js'; import { ANALYSIS_TASKS_MAX } from '../../workunit/workunit.service.js'; describe('parseTaskBreakdown', () => { diff --git a/apps/api/src/modules/agents/__tests__/agent-loop-tokens.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop-tokens.test.ts index 753a15fd6..237cd1691 100644 --- a/apps/api/src/modules/agents/__tests__/agent-loop-tokens.test.ts +++ b/apps/api/src/modules/agents/__tests__/agent-loop-tokens.test.ts @@ -11,7 +11,7 @@ import * as path from 'node:path'; import * as os from 'node:os'; import { TokenEstimator } from '@dommaker/harness'; -import { writeWorkunitTokenEvent, WORKUNIT_TOKENS_SSE_TYPE } from '../loop/agent-loop.js'; +import { writeWorkunitTokenEvent, WORKUNIT_TOKENS_SSE_TYPE } from '../loop/agent-loop-events.js'; import { eventBus } from '@dommaker/studio-shared'; import { resolveTokenLedgerFile } from '../../../utils/token-ledger.js'; diff --git a/apps/api/src/modules/agents/__tests__/agent-loop-v2.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop-v2.test.ts index 457703c5b..e7357389a 100644 --- a/apps/api/src/modules/agents/__tests__/agent-loop-v2.test.ts +++ b/apps/api/src/modules/agents/__tests__/agent-loop-v2.test.ts @@ -31,7 +31,7 @@ let resolveTarget: (obs: { // Dynamic import to handle RED phase (module doesn't exist yet) beforeEach(async () => { try { - const mod = await import('../loop/agent-loop.js'); + const mod = await import('../loop/agent-loop-parsers.js'); parseAgentOutput = mod.parseAgentOutput; dynamicInterval = mod.dynamicInterval; resolveTarget = mod.resolveTarget; diff --git a/apps/api/src/modules/agents/__tests__/agent-loop.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop.test.ts index e91afdd9a..894f7c9ac 100644 --- a/apps/api/src/modules/agents/__tests__/agent-loop.test.ts +++ b/apps/api/src/modules/agents/__tests__/agent-loop.test.ts @@ -95,7 +95,9 @@ vi.mock('../../knowledge/knowledge-service', () => ({ }, })); -import { AgentLoop, isProcessAlive, writeToolCallEvents, resolveToolTraceFile } from '../loop/agent-loop'; +import { AgentLoop } from '../loop/agent-loop'; +import { isProcessAlive } from '../loop/agent-loop-parsers'; +import { writeToolCallEvents, resolveToolTraceFile } from '../loop/agent-loop-events'; describe('AgentLoop', () => { let agentLoop: AgentLoop; diff --git a/apps/api/src/modules/agents/__tests__/daily-token-budget.test.ts b/apps/api/src/modules/agents/__tests__/daily-token-budget.test.ts index 6d3c38ba8..b0a0eec54 100644 --- a/apps/api/src/modules/agents/__tests__/daily-token-budget.test.ts +++ b/apps/api/src/modules/agents/__tests__/daily-token-budget.test.ts @@ -44,7 +44,8 @@ vi.mock('../../knowledge/knowledge-service', () => ({ }, })); -import { AgentLoop, writeWorkunitTokenEvent, type StepResult } from '../loop/agent-loop'; +import { AgentLoop, type StepResult } from '../loop/agent-loop'; +import { writeWorkunitTokenEvent } from '../loop/agent-loop-events'; import { BUDGET_TRIPPED_EVENT, DEFAULT_DAILY_TOKEN_BUDGET, diff --git a/apps/api/src/modules/agents/__tests__/e2e-verify.ts b/apps/api/src/modules/agents/__tests__/e2e-verify.ts index 2ff444424..0ccaed63d 100644 --- a/apps/api/src/modules/agents/__tests__/e2e-verify.ts +++ b/apps/api/src/modules/agents/__tests__/e2e-verify.ts @@ -11,7 +11,8 @@ * Requires: running API server, valid ANTHROPIC_API_KEY, Claude CLI installed */ const prisma = undefined as never; // @dommaker/studio-prisma removed (Spec 4 Phase 4) -import { AgentLoop, parseAgentOutput, resolveTarget, dynamicInterval } from '../loop/agent-loop.js'; +import { AgentLoop } from '../loop/agent-loop.js'; +import { parseAgentOutput, resolveTarget, dynamicInterval } from '../loop/agent-loop-parsers.js'; import { agentRunner } from '@dommaker/studio-agent'; import type { AgentTask, ExecutionResult } from '@dommaker/studio-agent'; import type { WorkUnit, AgentProfile, ChannelMessage } from '@prisma/client'; diff --git a/apps/api/src/modules/agents/__tests__/token-burn-guards.test.ts b/apps/api/src/modules/agents/__tests__/token-burn-guards.test.ts index 64ee015fe..6c15f73d4 100644 --- a/apps/api/src/modules/agents/__tests__/token-burn-guards.test.ts +++ b/apps/api/src/modules/agents/__tests__/token-burn-guards.test.ts @@ -44,14 +44,9 @@ vi.mock('../../knowledge/knowledge-service', () => ({ }, })); -import { - AgentLoop, - isTestLikeWorkUnit, - testWuGuardEnabled, - resolveRealUsage, - writeWorkunitTokenEvent, - type StepResult, -} from '../loop/agent-loop'; +import { AgentLoop, type StepResult } from '../loop/agent-loop'; +import { isTestLikeWorkUnit, testWuGuardEnabled } from '../loop/agent-loop-guards'; +import { resolveRealUsage, writeWorkunitTokenEvent } from '../loop/agent-loop-events'; import { WorkUnitService, type WorkUnitMetadata, type WorkUnitData } from '../../workunit/workunit.service'; import type { ExecutionResult } from '@dommaker/studio-agent'; diff --git a/apps/api/src/modules/agents/loop/__tests__/claim-fitness.test.ts b/apps/api/src/modules/agents/loop/__tests__/claim-fitness.test.ts index d7d0e775e..36867e922 100644 --- a/apps/api/src/modules/agents/loop/__tests__/claim-fitness.test.ts +++ b/apps/api/src/modules/agents/loop/__tests__/claim-fitness.test.ts @@ -40,7 +40,8 @@ vi.mock('../../../knowledge/knowledge-service', () => ({ }, })); -import { AgentLoop, resolveTarget } from '../agent-loop'; +import { AgentLoop } from '../agent-loop'; +import { resolveTarget } from '../agent-loop-parsers'; import { buildClaimFitnessPrompt, parseFitnessVerdict, parseUnfitRoles, isRoleUnfit, judgeClaimFitness, FITNESS_SCOPE_MAX_CHARS, diff --git a/apps/api/src/modules/agents/loop/agent-loop.ts b/apps/api/src/modules/agents/loop/agent-loop.ts index 16e0d2297..77d5fe6d7 100644 --- a/apps/api/src/modules/agents/loop/agent-loop.ts +++ b/apps/api/src/modules/agents/loop/agent-loop.ts @@ -5,7 +5,8 @@ // agent-loop-guards.js。知识搜索分析块(knowledge-search-analysis)零生产调用方,工单 43 已删。 // 工单 05(2026-08):prompt/上下文组装(含 buildSkill/Persona/RosterSection)→ prompt-composer.js; // DELEGATE 分支(建子单 + collab 元数据 + 降级文案)→ delegate-branch.js。 -// 本文件保留 AgentLoop 类编排逻辑 + re-export(对外导出语义不变)。 +// 本文件保留 AgentLoop 类编排逻辑;导出面 = AgentLoop + StepResult(#544 拆除 re-export 门面, +// 测试 import 已迁真属主 agent-loop-parsers / agent-loop-events / agent-loop-guards)。 import { execSync } from 'child_process'; import { eventBus, logger, FileStore, parseChannels, withAttestation, isStaleClaimSleep, parseStreamEvents, type RuntimeStateData } from '@dommaker/studio-shared'; import { TokenEstimator } from '@dommaker/harness'; @@ -59,23 +60,6 @@ import { isContextOverflowError, buildRollingSummary, OVERFLOW_SUMMARY_HEADER } import { WuLeaseTracker } from './wu-lease.js'; import { appendTranscriptStep, transcriptPath } from '../../transcripts/transcript-archive.js'; -// 输出解析/prompt 构建纯函数已抽到 ./agent-loop-parsers.js(工单 28,行为不变); -// re-export 保持对外导出语义不变 -export { - isProcessAlive, isGitRepoRoot, resolveWorktreesDir, - resolveTarget, parseAgentOutput, dynamicInterval, parseReviewReport, parseTaskBreakdown, - parseDecisionConclusion, -} from './agent-loop-parsers.js'; - -// workunit:tokens / tool:call 事件落盘已抽到 ./agent-loop-events.js(工单 28,行为不变); -// re-export 保持对外导出语义不变 -export { resolveRealUsage, writeWorkunitTokenEvent, resolveToolTraceFile, writeToolCallEvents, WORKUNIT_TOKENS_SSE_TYPE } from './agent-loop-events.js'; -export type { WorkunitTokenEventArgs, RealUsage } from './agent-loop-events.js'; - -// B2 测试特征 WU 守卫 + F4 excludeAssignee 解析已抽到 ./agent-loop-guards.js(工单 28,行为不变); -// re-export 保持对外导出语义不变 -export { testWuGuardEnabled, isTestLikeWorkUnit } from './agent-loop-guards.js'; - /** M2: workunit:tokens 事件写入目标(与 knowledge consumption/outcome 事件同一事件流)。 * STUDIO_EVENTS_JSONL 环境变量可覆盖(测试隔离用);缺省走 resolveStudioLogFile —— * 测试环境(VITEST/NODE_ENV=test)自动改写 tmpdir,防止测试污染生产事件流 @@ -131,7 +115,8 @@ const HEARTBEAT_FAIL_LIMIT = 3; // §10 P0 注入总预算(2K 红线)随 prompt 组装段一并迁到 ./prompt-composer.js(2026-08 工单 05) -// 类型契约已抽到 ./agent-loop.types.js(工单 28,行为不变);re-export 保持对外导出语义不变 +// 类型契约已抽到 ./agent-loop.types.js(工单 28,行为不变); +// StepResult re-export 保留——completion-gates 等生产消费方经本门面取类型(#544) export type { StepResult } from './agent-loop.types.js'; export class AgentLoop { From fe5a3258f9ef8c8367e229792423a4161d85a10c Mon Sep 17 00:00:00 2001 From: dommaker Date: Tue, 15 Sep 2026 23:37:43 +0800 Subject: [PATCH 017/116] =?UTF-8?q?refactor(agents):=20agentStep=20?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E5=AE=88=E5=8D=AB=E9=93=BE=E6=8A=BD=E5=87=BA?= =?UTF-8?q?=20step-guards=EF=BC=88=E5=AF=B9=E7=A7=B0=20completion-gates?= =?UTF-8?q?=EF=BC=89(#541)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B2 测试 WU / C3 日 token 预算 / #162 WU 级 tokenBudget / #471 plan 步数额度 四段前置判定从 agentStep 头部内联抽出到 loop/step-guards.ts,复制 completion-gates 的 Ctx/Deps/Outcome 注入模式:B2 副作用(写盘/关闭/频道 通知)与事件流路径经 deps 注入,开关与数据源保留生产默认实现。行为一字 不改(短路顺序、文案、留痕字段、容错路径全保留);agentStep 净缩 89 行。 新增 loop/__tests__/step-guards.test.ts 19 例伪 deps 直测,不再整类构造 AgentLoop。 --- .../agents/loop/__tests__/step-guards.test.ts | 287 ++++++++++++++++++ .../api/src/modules/agents/loop/agent-loop.ts | 103 +------ .../src/modules/agents/loop/step-guards.ts | 177 +++++++++++ 3 files changed, 478 insertions(+), 89 deletions(-) create mode 100644 apps/api/src/modules/agents/loop/__tests__/step-guards.test.ts create mode 100644 apps/api/src/modules/agents/loop/step-guards.ts diff --git a/apps/api/src/modules/agents/loop/__tests__/step-guards.test.ts b/apps/api/src/modules/agents/loop/__tests__/step-guards.test.ts new file mode 100644 index 000000000..a4e121780 --- /dev/null +++ b/apps/api/src/modules/agents/loop/__tests__/step-guards.test.ts @@ -0,0 +1,287 @@ +// 入口守卫链单测(step-guards,#541):纯 ctx 对象 + 注入伪依赖,无 vi.mock 模块工厂、 +// 不整类构造 AgentLoop(对称 completion-gates.test.ts 的可测试性契约)。 +// 覆盖:四段守卫各自命中/放行、短路顺序(B2 → C3 日预算 → #162 WU 预算 → #471 plan 额度)、 +// B2 副作用(留痕/关闭/频道通知)与 dep 失败容错、C3 当日只告警一次、 +// WU 预算/plan 额度的 need_input 形状(waitingReason)。 +import { describe, it, expect, vi } from 'vitest'; +import { + runStepGuards, + type StepGuardDeps, +} from '../step-guards'; +import { PLAN_STEP_LIMIT } from '../../../workunit/workunit.types'; +import type { WorkUnitData, WorkUnitMetadata } from '../../../workunit/workunit.service.js'; + +function makeWu(overrides: Partial = {}): WorkUnitData { + return { + id: 'wu-1', parentId: null, type: 'task', scope: '实现功能', assigneeId: 'instance-1', + status: 'active', failureType: null, retryCount: 0, timeoutAt: null, + channelId: null, projectPath: null, workspaceId: null, metadata: null, + createdAt: new Date(), updatedAt: new Date(), claimedAt: null, completedAt: null, + ...overrides, + }; +} + +/** 默认伪依赖:全部守卫放行(B2 关 / 日预算充足 / 无 WU 预算 / 非 plan) */ +function makeDeps(overrides: Partial = {}): StepGuardDeps & { + updateWuMetadata: ReturnType; + closeWu: ReturnType; + postNotice: ReturnType; + eventsFilePath: ReturnType; + testWuGuardEnabled: ReturnType; + isTestLikeWorkUnit: ReturnType; + tokenBudgetGuardEnabled: ReturnType; + resolveDailyTokenBudget: ReturnType; + getDailyTokenUsage: ReturnType; + notifyBudgetTripped: ReturnType; +} { + return { + updateWuMetadata: vi.fn().mockResolvedValue(undefined), + closeWu: vi.fn().mockResolvedValue(undefined), + postNotice: vi.fn().mockResolvedValue(undefined), + eventsFilePath: vi.fn().mockReturnValue('/tmp/studio-events.jsonl'), + testWuGuardEnabled: vi.fn().mockReturnValue(false), + isTestLikeWorkUnit: vi.fn().mockReturnValue(false), + tokenBudgetGuardEnabled: vi.fn().mockReturnValue(false), + resolveDailyTokenBudget: vi.fn().mockReturnValue(2_000_000), + getDailyTokenUsage: vi.fn().mockResolvedValue({ dateKey: '2026-09-15', usedTokens: 0, notified: false }), + notifyBudgetTripped: vi.fn().mockResolvedValue(undefined), + ...overrides, + } as ReturnType; +} + +describe('step-guards: B2 测试特征 WU 守卫', () => { + it('命中:skipped + 留痕(testWorkUnitGuard/blockReason)+ 关闭 + 频道通知', async () => { + const deps = makeDeps({ + testWuGuardEnabled: vi.fn().mockReturnValue(true), + isTestLikeWorkUnit: vi.fn().mockReturnValue(true), + }); + const metadata: WorkUnitMetadata = { triggerId: 't-1' }; + const out = await runStepGuards({ wu: makeWu({ scope: 'tree-tokens test' }), metadata }, deps); + + expect(out.result).toEqual({ action: 'skipped', summary: '' }); + expect(deps.updateWuMetadata).toHaveBeenCalledTimes(1); + const [wuId, written] = deps.updateWuMetadata.mock.calls[0] as [string, WorkUnitMetadata]; + expect(wuId).toBe('wu-1'); + expect(written.triggerId).toBe('t-1'); // 原 metadata 字段保留 + expect(written.testWorkUnitGuard).toBe(true); + expect(written.blockReason).toContain('test-wu-guard'); + expect(deps.closeWu).toHaveBeenCalledWith('wu-1'); + expect(deps.postNotice).toHaveBeenCalledTimes(1); + expect((deps.postNotice.mock.calls[0] as [string, string])[1]).toContain('测试特征任务'); + }); + + it('守卫关闭或非测试特征:放行(result=null),零副作用', async () => { + for (const deps of [ + makeDeps({ testWuGuardEnabled: vi.fn().mockReturnValue(false), isTestLikeWorkUnit: vi.fn().mockReturnValue(true) }), + makeDeps({ testWuGuardEnabled: vi.fn().mockReturnValue(true), isTestLikeWorkUnit: vi.fn().mockReturnValue(false) }), + ]) { + const out = await runStepGuards({ wu: makeWu(), metadata: {} }, deps); + expect(out.result).toBeNull(); + expect(deps.updateWuMetadata).not.toHaveBeenCalled(); + expect(deps.closeWu).not.toHaveBeenCalled(); + expect(deps.postNotice).not.toHaveBeenCalled(); + } + }); + + it('WU 已 closed:不再重复关闭,仍留痕 + 通知', async () => { + const deps = makeDeps({ + testWuGuardEnabled: vi.fn().mockReturnValue(true), + isTestLikeWorkUnit: vi.fn().mockReturnValue(true), + }); + const out = await runStepGuards({ wu: makeWu({ status: 'closed' }), metadata: {} }, deps); + + expect(out.result?.action).toBe('skipped'); + expect(deps.closeWu).not.toHaveBeenCalled(); + expect(deps.updateWuMetadata).toHaveBeenCalledTimes(1); + expect(deps.postNotice).toHaveBeenCalledTimes(1); + }); + + it('副作用 dep 失败(写盘/关闭 reject):容错继续,仍返回 skipped', async () => { + const deps = makeDeps({ + testWuGuardEnabled: vi.fn().mockReturnValue(true), + isTestLikeWorkUnit: vi.fn().mockReturnValue(true), + updateWuMetadata: vi.fn().mockRejectedValue(new Error('disk full')), + closeWu: vi.fn().mockRejectedValue(new Error('transition failed')), + }); + const out = await runStepGuards({ wu: makeWu(), metadata: {} }, deps); + + expect(out.result?.action).toBe('skipped'); + expect(deps.closeWu).toHaveBeenCalled(); // 写盘失败不阻断关闭尝试 + expect(deps.postNotice).toHaveBeenCalledTimes(1); + }); +}); + +describe('step-guards: C3 日 token 预算熔断', () => { + const guardOn = (usedTokens: number, notified = false) => makeDeps({ + tokenBudgetGuardEnabled: vi.fn().mockReturnValue(true), + resolveDailyTokenBudget: vi.fn().mockReturnValue(2_000_000), + getDailyTokenUsage: vi.fn().mockResolvedValue({ dateKey: '2026-09-15', usedTokens, notified }), + }); + + it('当日已耗 ≥ 预算:need_input 挂起 + 首次告警(notifyBudgetTripped)', async () => { + const deps = guardOn(2_000_000); + const out = await runStepGuards({ wu: makeWu(), metadata: {} }, deps); + + expect(out.result?.action).toBe('need_input'); + expect(out.result?.summary).toContain('预算已熔断'); + expect(deps.getDailyTokenUsage).toHaveBeenCalledWith({ eventsFile: '/tmp/studio-events.jsonl' }); + expect(deps.notifyBudgetTripped).toHaveBeenCalledTimes(1); + expect(deps.notifyBudgetTripped).toHaveBeenCalledWith({ + eventsFile: '/tmp/studio-events.jsonl', usedTokens: 2_000_000, budget: 2_000_000, + }); + }); + + it('当日已告警过(notified=true):仍挂起但不再告警', async () => { + const deps = guardOn(3_000_000, true); + const out = await runStepGuards({ wu: makeWu(), metadata: {} }, deps); + + expect(out.result?.action).toBe('need_input'); + expect(deps.notifyBudgetTripped).not.toHaveBeenCalled(); + }); + + it('当日已耗 < 预算:放行', async () => { + const deps = guardOn(1_999_999); + const out = await runStepGuards({ wu: makeWu(), metadata: {} }, deps); + + expect(out.result).toBeNull(); + expect(deps.notifyBudgetTripped).not.toHaveBeenCalled(); + }); + + it('预算 <=0:关闭熔断,已耗再高也放行', async () => { + const deps = makeDeps({ + tokenBudgetGuardEnabled: vi.fn().mockReturnValue(true), + resolveDailyTokenBudget: vi.fn().mockReturnValue(0), + getDailyTokenUsage: vi.fn().mockResolvedValue({ dateKey: '2026-09-15', usedTokens: 999_999_999, notified: false }), + }); + const out = await runStepGuards({ wu: makeWu(), metadata: {} }, deps); + + expect(out.result).toBeNull(); + expect(deps.getDailyTokenUsage).not.toHaveBeenCalled(); // 预算 <=0 不查用量 + }); + + it('守卫开关关闭:不查用量直接放行', async () => { + const deps = makeDeps({ tokenBudgetGuardEnabled: vi.fn().mockReturnValue(false) }); + const out = await runStepGuards({ wu: makeWu(), metadata: {} }, deps); + + expect(out.result).toBeNull(); + expect(deps.getDailyTokenUsage).not.toHaveBeenCalled(); + }); +}); + +describe('step-guards: #162 WU 级 tokenBudget 熔断', () => { + it('_cumulativeTokens ≥ tokenBudget:need_input + waitingReason=wu-token-budget + 人话三选文案', async () => { + const deps = makeDeps(); + const out = await runStepGuards({ + wu: makeWu(), + metadata: { tokenBudget: 1000, _cumulativeTokens: 1500 }, + }, deps); + + expect(out.result?.action).toBe('need_input'); + expect(out.result?.metadataUpdates?.waitingReason).toBe('wu-token-budget'); + expect(out.result?.summary).toContain('追加预算'); + expect(out.result?.summary).toContain('收尾'); + expect(out.result?.summary).toContain('放弃'); + }); + + it('_cumulativeTokens < tokenBudget:放行', async () => { + const out = await runStepGuards({ + wu: makeWu(), + metadata: { tokenBudget: 1000, _cumulativeTokens: 999 }, + }, makeDeps()); + expect(out.result).toBeNull(); + }); + + it('无 tokenBudget / <=0 / 非法值:不受限放行', async () => { + for (const metadata of [ + { _cumulativeTokens: 999_999 }, + { tokenBudget: 0, _cumulativeTokens: 999_999 }, + { tokenBudget: Number.NaN, _cumulativeTokens: 999_999 }, + { tokenBudget: -5, _cumulativeTokens: 999_999 }, + ]) { + const out = await runStepGuards({ wu: makeWu(), metadata }, makeDeps()); + expect(out.result).toBeNull(); + } + }); +}); + +describe('step-guards: #471 plan 步数额度熔断', () => { + it('plan + stepCount ≥ 默认额度(PLAN_STEP_LIMIT):need_input + waitingReason=plan-step-limit', async () => { + const out = await runStepGuards({ + wu: makeWu({ type: 'plan' }), + metadata: { stepCount: PLAN_STEP_LIMIT }, + }, makeDeps()); + + expect(out.result?.action).toBe('need_input'); + expect(out.result?.metadataUpdates?.waitingReason).toBe('plan-step-limit'); + expect(out.result?.summary).toContain('步数额度'); + }); + + it('plan + 自定义 planStepAllowance:未达额度放行;续期后额度生效', async () => { + const below = await runStepGuards({ + wu: makeWu({ type: 'plan' }), + metadata: { stepCount: 61, planStepAllowance: 120 }, + }, makeDeps()); + expect(below.result).toBeNull(); + + const at = await runStepGuards({ + wu: makeWu({ type: 'plan' }), + metadata: { stepCount: 120, planStepAllowance: 120 }, + }, makeDeps()); + expect(at.result?.action).toBe('need_input'); + }); + + it('非 plan 类型:stepCount 再高也不进本守卫', async () => { + const out = await runStepGuards({ + wu: makeWu({ type: 'task' }), + metadata: { stepCount: PLAN_STEP_LIMIT + 100 }, + }, makeDeps()); + expect(out.result).toBeNull(); + }); +}); + +describe('step-guards: 守卫链顺序与短路', () => { + it('B2 命中时后续守卫不跑(日预算不查询、WU 预算不判)', async () => { + const deps = makeDeps({ + testWuGuardEnabled: vi.fn().mockReturnValue(true), + isTestLikeWorkUnit: vi.fn().mockReturnValue(true), + tokenBudgetGuardEnabled: vi.fn().mockReturnValue(true), + }); + const out = await runStepGuards({ + wu: makeWu(), + metadata: { tokenBudget: 1000, _cumulativeTokens: 1500 }, + }, deps); + + expect(out.result?.action).toBe('skipped'); + expect(deps.getDailyTokenUsage).not.toHaveBeenCalled(); + }); + + it('日预算熔断时 WU 预算/plan 守卫不跑(日预算优先)', async () => { + const deps = makeDeps({ + tokenBudgetGuardEnabled: vi.fn().mockReturnValue(true), + resolveDailyTokenBudget: vi.fn().mockReturnValue(100), + getDailyTokenUsage: vi.fn().mockResolvedValue({ dateKey: '2026-09-15', usedTokens: 500, notified: true }), + }); + const out = await runStepGuards({ + wu: makeWu({ type: 'plan' }), + metadata: { tokenBudget: 1000, _cumulativeTokens: 1500, stepCount: PLAN_STEP_LIMIT }, + }, deps); + + expect(out.result?.action).toBe('need_input'); + expect(out.result?.summary).toContain('预算已熔断'); // 日预算文案,非 WU 预算三选 + expect(out.result?.metadataUpdates?.waitingReason).toBeUndefined(); + }); + + it('WU 预算熔断时 plan 守卫不跑(WU 预算优先)', async () => { + const out = await runStepGuards({ + wu: makeWu({ type: 'plan' }), + metadata: { tokenBudget: 1000, _cumulativeTokens: 1500, stepCount: PLAN_STEP_LIMIT }, + }, makeDeps()); + + expect(out.result?.metadataUpdates?.waitingReason).toBe('wu-token-budget'); + }); + + it('全部放行:result=null', async () => { + const out = await runStepGuards({ wu: makeWu(), metadata: {} }, makeDeps()); + expect(out.result).toBeNull(); + }); +}); diff --git a/apps/api/src/modules/agents/loop/agent-loop.ts b/apps/api/src/modules/agents/loop/agent-loop.ts index 77d5fe6d7..20a025dc7 100644 --- a/apps/api/src/modules/agents/loop/agent-loop.ts +++ b/apps/api/src/modules/agents/loop/agent-loop.ts @@ -5,6 +5,8 @@ // agent-loop-guards.js。知识搜索分析块(knowledge-search-analysis)零生产调用方,工单 43 已删。 // 工单 05(2026-08):prompt/上下文组装(含 buildSkill/Persona/RosterSection)→ prompt-composer.js; // DELEGATE 分支(建子单 + collab 元数据 + 降级文案)→ delegate-branch.js。 +// #541(2026-09):agentStep 入口四段前置守卫(B2 测试 WU / C3 日预算 / #162 WU 预算 / +// #471 plan 额度)→ step-guards.js(对称出口侧 completion-gates 的 Ctx/Deps/Outcome 模式)。 // 本文件保留 AgentLoop 类编排逻辑;导出面 = AgentLoop + StepResult(#544 拆除 re-export 门面, // 测试 import 已迁真属主 agent-loop-parsers / agent-loop-events / agent-loop-guards)。 import { execSync } from 'child_process'; @@ -24,20 +26,16 @@ import { postWuSystemMessage } from '../../workunit/wu-messenger.js'; import type { MessageMeta } from '../../channels/channel-message.service.js'; import { withBlockedCta } from '../../workunit/blocked-cta.js'; import { parseWuMetadata, mergedWuView } from '../../workunit/wu-metadata.js'; -import { PLAN_STEP_LIMIT } from '../../workunit/workunit.types.js'; import { hasUnfinishedDeps, buildStatusById } from '../../workunit/wu-dependencies.js'; import { resolvePmoBranchForWU } from '../../requirements/pmo-branch-resolver.js'; import { resolveStudioLogFile } from '../../../utils/studio-log-path.js'; import { writeStudioEvent } from '../../../utils/studio-events.js'; import { getErrorMessage } from '../../../utils/errors.js'; -import { - tokenBudgetGuardEnabled, resolveDailyTokenBudget, getDailyTokenUsage, - notifyBudgetTripped, -} from './daily-token-budget.js'; import { emitExecutionStepEvent, emitExecutionStreamLine, emitExecutionStreamStepStart, emitWorkUnitFailedEvent } from './execution-step-events.js'; import { loadCurrentWuContexts, type CurrentWuContext } from '../../monitoring/current-wu-context.js'; import { CODE_WORKTREE_TYPES, runWuVerification } from './wu-verification.js'; import { runCompletionGuards } from './completion-gates.js'; +import { runStepGuards } from './step-guards.js'; import { parseMapOpening } from '../../pmo/map-opening.js'; import { parseSpecTasks } from '../../pmo/spec-materialization.js'; import type { StepResult, Observations, Target, RuntimeInstanceRow } from './agent-loop.types.js'; @@ -51,7 +49,7 @@ import { resolveRealUsage, writeWorkunitTokenEvent, resolveToolTraceFile, writeToolCallEvents, type RealUsage, } from './agent-loop-events.js'; -import { testWuGuardEnabled, isTestLikeWorkUnit, parseExcludeAssignee } from './agent-loop-guards.js'; +import { parseExcludeAssignee } from './agent-loop-guards.js'; import { judgeClaimFitness, isRoleUnfit, parseUnfitRoles } from './claim-fitness.js'; import { composeStepPrompt } from './prompt-composer.js'; import { handleDelegateBranch } from './delegate-branch.js'; @@ -897,90 +895,17 @@ export class AgentLoop { const wu = target.workUnit; const metadata = parseWuMetadata(wu.metadata); - // B2 守卫(2026-08-03 token-burn issue P0-1c):测试特征 WU 不起会话、直接关闭。 - // 历史事故:路由测试经共享数据根把测试 WU 写进生产 FileStore,daemon 当真任务逐个 - // 起 Claude 会话执行(16 个会话 420 万 token)。关闭留痕 testWorkUnitGuard + blockReason。 - if (testWuGuardEnabled() && isTestLikeWorkUnit(wu, metadata)) { - logger.warn('[AgentLoop] Test-like WorkUnit guarded — closing without execution', { - workUnitId: wu.id, scope: wu.scope, - }); - await this.workUnitService.update(wu.id, { - metadata: { ...metadata, testWorkUnitGuard: true, blockReason: 'test-wu-guard: 测试特征任务,守卫关闭' }, - }).catch(err => logger.warn('[AgentLoop] test-wu guard metadata write failed', { workUnitId: wu.id, error: String(err) })); - if (wu.status !== 'closed') { - await this.workUnitService.transitionStatus(wu.id, 'closed') - .catch(err => logger.warn('[AgentLoop] test-wu guard close failed', { workUnitId: wu.id, error: String(err) })); - } - await this.postToDiscussionSpace(wu.id, '检测到测试特征任务,已跳过执行并关闭(防止测试数据空烧 token)') - .catch(() => {}); - return { action: 'skipped', summary: '' }; - } + // 入口守卫链(#541,对称出口侧 completion-gates):B2 测试特征 WU / C3 日 token 预算 / + // #162 WU 级 tokenBudget / #471 plan 步数额度四段「该不该跑这一步」前置判定抽到 + // ./step-guards.js(顺序即优先级,首个命中短路)。命中即返回(skipped/need_input),放行继续。 + const guardOutcome = await runStepGuards({ wu, metadata }, { + updateWuMetadata: (wuId, m) => this.workUnitService.update(wuId, { metadata: m }), + closeWu: wuId => this.workUnitService.transitionStatus(wuId, 'closed'), + postNotice: (wuId, text) => this.postToDiscussionSpace(wuId, text), + eventsFilePath: studioEventsJsonlPath, + }); + if (guardOutcome.result) return guardOutcome.result; - // C3 守卫(2026-08-03 token-burn issue P2-2,决策记录 #4):每日 token 预算熔断。 - // 当日 billed 口径消耗 ≥ 预算(默认 2M/日,STUDIO_DAILY_TOKEN_BUDGET 覆盖,<=0 关闭)→ - // 不起会话,WU 经 need_input 挂起(recordResult 落 waitingForInput + blockReason), - // 等次日本地零点预算复位或人工处置;全局当日只告警一次(studio:budget-tripped 事件留痕)。 - // 用量走进程内计数器(daily-token-budget),仅首次/跨天全量扫一次事件文件,不拖慢热路径。 - if (tokenBudgetGuardEnabled()) { - const dailyBudget = resolveDailyTokenBudget(); - if (dailyBudget > 0) { - const eventsFile = studioEventsJsonlPath(); - const daily = await getDailyTokenUsage({ eventsFile }); - if (daily.usedTokens >= dailyBudget) { - logger.warn('[AgentLoop] Daily token budget tripped — pausing automatic execution', { - workUnitId: wu.id, usedTokens: daily.usedTokens, budget: dailyBudget, - }); - if (!daily.notified) { - await notifyBudgetTripped({ eventsFile, usedTokens: daily.usedTokens, budget: dailyBudget }); - } - return { - action: 'need_input' as const, - summary: `每日 token 预算已熔断(当日已用 ${daily.usedTokens.toLocaleString()} / 上限 ${dailyBudget.toLocaleString()},billed 口径含 cache_read):已暂停自动执行、不再起会话。次日(本地零点)预算复位后回复任意内容继续,或直接关闭任务`, - }; - } - } - } - // #162(T8-E1,#130 决策 3):WU 级 token 预算熔断。metadata.tokenBudget 显式数值 - // (任何类型 WU 可带,与日预算无关、不吃 STUDIO_TOKEN_BUDGET_GUARD 开关——字段在场即生效), - // 对照 metadata._cumulativeTokens(billed 口径簿记,与日预算同口径)。超线复用日预算同款 - // need_input 挂起路径(recordResult 落 waitingForInput + blockReason),不新造状态; - // waitingReason='wu-token-budget' 供 waiting-input 人三选分流(追加预算/收尾/放弃)。 - // 人读面说人话:提示文案不出现 WU/metadata/闸/熔断等机制黑话。 - if (typeof metadata.tokenBudget === 'number' && Number.isFinite(metadata.tokenBudget) && metadata.tokenBudget > 0) { - const wuBudget = Math.floor(metadata.tokenBudget); - const wuUsed = metadata._cumulativeTokens ?? 0; - if (wuUsed >= wuBudget) { - logger.warn('[AgentLoop] WU token budget reached — suspending for human decision', { - workUnitId: wu.id, usedTokens: wuUsed, budget: wuBudget, - }); - return { - action: 'need_input' as const, - summary: `这项任务已消耗 ${wuUsed.toLocaleString()} token,达到为它设定的上限 ${wuBudget.toLocaleString()},已暂停等你决定。回复:「追加预算」在上限之上再加 ${wuBudget.toLocaleString()} 继续执行;「追加预算 <数值>」把上限改为指定数值;「收尾」用现有产出提交审查;「放弃」结束任务`, - metadataUpdates: { waitingReason: 'wu-token-budget' }, - }; - } - } - // #471(Triage 定稿 1):plan 步数额度熔断。一脉会话承载全规划链,额度高于 - // implement(PLAN_STEP_LIMIT=60,常量与语义见 workunit.types.ts)。到线不走 - // recordResult 的强制收口 in_review(plan 已从中豁免)——前置守卫在此转 - // need_input 挂 blocked 转人,不静默截断;人回复即续期(waiting-input 给 - // planStepAllowance 加一份 PLAN_STEP_LIMIT,复活回 active 续跑)。 - if (wu.type === 'plan') { - const allowance = typeof metadata.planStepAllowance === 'number' && Number.isFinite(metadata.planStepAllowance) && metadata.planStepAllowance > 0 - ? Math.floor(metadata.planStepAllowance) - : PLAN_STEP_LIMIT; - const planSteps = metadata.stepCount ?? 0; - if (planSteps >= allowance) { - logger.warn('[AgentLoop] Plan step limit reached — suspending for human decision', { - workUnitId: wu.id, stepCount: planSteps, allowance, - }); - return { - action: 'need_input' as const, - summary: `这次规划已推进 ${planSteps} 步,达到为它设定的步数额度 ${allowance},已暂停等你决定。回复任意内容(或「继续」)即续期 ${PLAN_STEP_LIMIT} 步接着跑;想收尾可在 Web 端点「通过」进入人工确认`, - metadataUpdates: { waitingReason: 'plan-step-limit' }, - }; - } - } // P0 修复 6: traceId 贯穿 — 频道消息 → WU metadata → 执行参数(extraEnv)与日志行 const traceId = typeof metadata.traceId === 'string' && metadata.traceId ? metadata.traceId : undefined; diff --git a/apps/api/src/modules/agents/loop/step-guards.ts b/apps/api/src/modules/agents/loop/step-guards.ts new file mode 100644 index 000000000..ea08b9aae --- /dev/null +++ b/apps/api/src/modules/agents/loop/step-guards.ts @@ -0,0 +1,177 @@ +/** + * 入口守卫链(#541,2026-09 从 agent-loop.agentStep 头部原样抽出,行为一字不改, + * 对称补齐出口侧 completion-gates):agentStep 的四段「该不该跑这一步」前置判定 —— + * B2 测试特征 WU 守卫 → C3 日 token 预算熔断 → #162 WU 级 token 预算熔断 → #471 plan 步数额度熔断。 + * 顺序即优先级:首个命中的守卫短路返回 StepResult,后续守卫不再执行(与原内联行为一致)。 + * + * 职责边界: + * - 本模块 = 守卫政策(guard policy):判定/短路结果/B2 留痕副作用的调用编排。 + * - agent-loop.agentStep = 编排:构建 ctx → 调 runStepGuards → 命中即返回, + * 放行则继续 traceId/频道版本/worktree/会话签发/prompt 组装。 + * + * 可测试性:B2 副作用(WU 写盘/关闭/频道通知)与事件流路径经 deps 注入(agent-loop 绑定 + * workUnitService/postToDiscussionSpace/studioEventsJsonlPath);守卫开关与外部数据源 + * (日预算用量/告警)有生产默认实现,单测整体注入伪实现,无需 vi.mock 模块工厂。 + */ + +import { logger } from '@dommaker/studio-shared'; +import { PLAN_STEP_LIMIT } from '../../workunit/workunit.types.js'; +import type { WorkUnitData, WorkUnitMetadata } from '../../workunit/workunit.service.js'; +import type { StepResult } from './agent-loop.types.js'; +import { testWuGuardEnabled, isTestLikeWorkUnit } from './agent-loop-guards.js'; +import { + tokenBudgetGuardEnabled, resolveDailyTokenBudget, getDailyTokenUsage, + notifyBudgetTripped, type DailyTokenUsage, +} from './daily-token-budget.js'; + +/** 守卫链输入:wu + 已解析的 metadata(agentStep 入口 parseWuMetadata 的同一对象) */ +export interface StepGuardCtx { + wu: WorkUnitData; + metadata: WorkUnitMetadata; +} + +/** 守卫链外部依赖。前四项为编排层绑定(agent-loop 注入),无默认实现; + * 其余为开关/数据源,默认 = 生产实现,单测整体注入伪实现。 */ +export interface StepGuardDeps { + /** B2: WU metadata 写盘留痕(agent-loop 注入 workUnitService.update(id, { metadata })) */ + updateWuMetadata: (wuId: string, metadata: WorkUnitMetadata) => Promise; + /** B2: 关闭 WU(agent-loop 注入 workUnitService.transitionStatus(id, 'closed')) */ + closeWu: (wuId: string) => Promise; + /** B2: 频道留痕(agent-loop 注入 postToDiscussionSpace) */ + postNotice: (wuId: string, text: string) => Promise; + /** C3: 事件流文件路径(agent-loop 注入 studioEventsJsonlPath,惰性解析 env 覆盖) */ + eventsFilePath: () => string; + /** B2 守卫开关(默认读 process.env;测试环境默认关闭,STUDIO_TEST_WU_GUARD 覆盖) */ + testWuGuardEnabled?: () => boolean; + /** B2 测试特征判定(metadata 显式标记或 scope 命中测试名单模式) */ + isTestLikeWorkUnit?: (wu: { scope: string }, metadata: WorkUnitMetadata) => boolean; + /** C3 守卫开关(默认读 process.env;STUDIO_TOKEN_BUDGET_GUARD 覆盖) */ + tokenBudgetGuardEnabled?: () => boolean; + /** C3: 每日预算(STUDIO_DAILY_TOKEN_BUDGET 覆盖;<=0 = 不熔断) */ + resolveDailyTokenBudget?: () => number; + /** C3: 当日已耗查询(进程内计数器 + 首查/跨天全量扫) */ + getDailyTokenUsage?: (opts: { eventsFile: string }) => Promise; + /** C3: 当日首次熔断告警(budget-tripped 事件留痕 + 通知出口) */ + notifyBudgetTripped?: (opts: { eventsFile: string; usedTokens: number; budget: number }) => Promise; +} + +export interface StepGuardOutcome { + /** 非 null = 守卫短路:agentStep 直接返回该 StepResult;null = 放行继续执行 */ + result: StepResult | null; +} + +/** + * 依次跑入口守卫(顺序即优先级,首个命中即短路): + * 1. B2 测试特征 WU 守卫:不起会话、直接关闭(留痕 testWorkUnitGuard + blockReason + 频道通知)。 + * 2. C3 日 token 预算熔断:当日 billed 消耗 ≥ 预算 → need_input 挂起(次日零点复位或人工处置); + * 全局当日只告警一次(budget-tripped 事件留痕)。 + * 3. #162 WU 级 tokenBudget 熔断:metadata.tokenBudget 显式数值在场即生效(不吃 C3 开关), + * 超线 → need_input 挂起 + waitingReason='wu-token-budget'(人三选:追加预算/收尾/放弃)。 + * 4. #471 plan 步数额度熔断:stepCount ≥ planStepAllowance(默认 PLAN_STEP_LIMIT)→ + * need_input 挂 blocked 转人 + waitingReason='plan-step-limit'(人回复即续期)。 + */ +export async function runStepGuards( + ctx: StepGuardCtx, + deps: StepGuardDeps, +): Promise { + const { wu, metadata } = ctx; + const testGuardOn = deps.testWuGuardEnabled ?? (() => testWuGuardEnabled()); + const isTestLike = deps.isTestLikeWorkUnit ?? isTestLikeWorkUnit; + const budgetGuardOn = deps.tokenBudgetGuardEnabled ?? (() => tokenBudgetGuardEnabled()); + const dailyBudgetOf = deps.resolveDailyTokenBudget ?? (() => resolveDailyTokenBudget()); + const dailyUsage = deps.getDailyTokenUsage ?? getDailyTokenUsage; + const notifyTripped = deps.notifyBudgetTripped ?? notifyBudgetTripped; + + // B2 守卫(2026-08-03 token-burn issue P0-1c):测试特征 WU 不起会话、直接关闭。 + // 历史事故:路由测试经共享数据根把测试 WU 写进生产 FileStore,daemon 当真任务逐个 + // 起 Claude 会话执行(16 个会话 420 万 token)。关闭留痕 testWorkUnitGuard + blockReason。 + if (testGuardOn() && isTestLike(wu, metadata)) { + logger.warn('[AgentLoop] Test-like WorkUnit guarded — closing without execution', { + workUnitId: wu.id, scope: wu.scope, + }); + await deps.updateWuMetadata(wu.id, { + ...metadata, testWorkUnitGuard: true, blockReason: 'test-wu-guard: 测试特征任务,守卫关闭', + }).catch(err => logger.warn('[AgentLoop] test-wu guard metadata write failed', { workUnitId: wu.id, error: String(err) })); + if (wu.status !== 'closed') { + await deps.closeWu(wu.id) + .catch(err => logger.warn('[AgentLoop] test-wu guard close failed', { workUnitId: wu.id, error: String(err) })); + } + await deps.postNotice(wu.id, '检测到测试特征任务,已跳过执行并关闭(防止测试数据空烧 token)') + .catch(() => {}); + return { result: { action: 'skipped', summary: '' } }; + } + + // C3 守卫(2026-08-03 token-burn issue P2-2,决策记录 #4):每日 token 预算熔断。 + // 当日 billed 口径消耗 ≥ 预算(默认 2M/日,STUDIO_DAILY_TOKEN_BUDGET 覆盖,<=0 关闭)→ + // 不起会话,WU 经 need_input 挂起(recordResult 落 waitingForInput + blockReason), + // 等次日本地零点预算复位或人工处置;全局当日只告警一次(studio:budget-tripped 事件留痕)。 + // 用量走进程内计数器(daily-token-budget),仅首次/跨天全量扫一次事件文件,不拖慢热路径。 + if (budgetGuardOn()) { + const dailyBudget = dailyBudgetOf(); + if (dailyBudget > 0) { + const eventsFile = deps.eventsFilePath(); + const daily = await dailyUsage({ eventsFile }); + if (daily.usedTokens >= dailyBudget) { + logger.warn('[AgentLoop] Daily token budget tripped — pausing automatic execution', { + workUnitId: wu.id, usedTokens: daily.usedTokens, budget: dailyBudget, + }); + if (!daily.notified) { + await notifyTripped({ eventsFile, usedTokens: daily.usedTokens, budget: dailyBudget }); + } + return { + result: { + action: 'need_input' as const, + summary: `每日 token 预算已熔断(当日已用 ${daily.usedTokens.toLocaleString()} / 上限 ${dailyBudget.toLocaleString()},billed 口径含 cache_read):已暂停自动执行、不再起会话。次日(本地零点)预算复位后回复任意内容继续,或直接关闭任务`, + }, + }; + } + } + } + // #162(T8-E1,#130 决策 3):WU 级 token 预算熔断。metadata.tokenBudget 显式数值 + // (任何类型 WU 可带,与日预算无关、不吃 STUDIO_TOKEN_BUDGET_GUARD 开关——字段在场即生效), + // 对照 metadata._cumulativeTokens(billed 口径簿记,与日预算同口径)。超线复用日预算同款 + // need_input 挂起路径(recordResult 落 waitingForInput + blockReason),不新造状态; + // waitingReason='wu-token-budget' 供 waiting-input 人三选分流(追加预算/收尾/放弃)。 + // 人读面说人话:提示文案不出现 WU/metadata/闸/熔断等机制黑话。 + if (typeof metadata.tokenBudget === 'number' && Number.isFinite(metadata.tokenBudget) && metadata.tokenBudget > 0) { + const wuBudget = Math.floor(metadata.tokenBudget); + const wuUsed = metadata._cumulativeTokens ?? 0; + if (wuUsed >= wuBudget) { + logger.warn('[AgentLoop] WU token budget reached — suspending for human decision', { + workUnitId: wu.id, usedTokens: wuUsed, budget: wuBudget, + }); + return { + result: { + action: 'need_input' as const, + summary: `这项任务已消耗 ${wuUsed.toLocaleString()} token,达到为它设定的上限 ${wuBudget.toLocaleString()},已暂停等你决定。回复:「追加预算」在上限之上再加 ${wuBudget.toLocaleString()} 继续执行;「追加预算 <数值>」把上限改为指定数值;「收尾」用现有产出提交审查;「放弃」结束任务`, + metadataUpdates: { waitingReason: 'wu-token-budget' }, + }, + }; + } + } + // #471(Triage 定稿 1):plan 步数额度熔断。一脉会话承载全规划链,额度高于 + // implement(PLAN_STEP_LIMIT=60,常量与语义见 workunit.types.ts)。到线不走 + // recordResult 的强制收口 in_review(plan 已从中豁免)——前置守卫在此转 + // need_input 挂 blocked 转人,不静默截断;人回复即续期(waiting-input 给 + // planStepAllowance 加一份 PLAN_STEP_LIMIT,复活回 active 续跑)。 + if (wu.type === 'plan') { + const allowance = typeof metadata.planStepAllowance === 'number' && Number.isFinite(metadata.planStepAllowance) && metadata.planStepAllowance > 0 + ? Math.floor(metadata.planStepAllowance) + : PLAN_STEP_LIMIT; + const planSteps = metadata.stepCount ?? 0; + if (planSteps >= allowance) { + logger.warn('[AgentLoop] Plan step limit reached — suspending for human decision', { + workUnitId: wu.id, stepCount: planSteps, allowance, + }); + return { + result: { + action: 'need_input' as const, + summary: `这次规划已推进 ${planSteps} 步,达到为它设定的步数额度 ${allowance},已暂停等你决定。回复任意内容(或「继续」)即续期 ${PLAN_STEP_LIMIT} 步接着跑;想收尾可在 Web 端点「通过」进入人工确认`, + metadataUpdates: { waitingReason: 'plan-step-limit' }, + }, + }; + } + } + + return { result: null }; +} From d6e866d81cced9b634c4aef3368ac214c50ca9df Mon Sep 17 00:00:00 2001 From: dommaker Date: Tue, 15 Sep 2026 23:38:15 +0800 Subject: [PATCH 018/116] =?UTF-8?q?docs(context):=20agents/CONTEXT.md=20?= =?UTF-8?q?=E8=A1=A5=20step-guards=20=E6=9D=A1=E7=9B=AE=20(#541)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/modules/agents/CONTEXT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/modules/agents/CONTEXT.md b/apps/api/src/modules/agents/CONTEXT.md index 6e9f12d0b..71dcb77ce 100644 --- a/apps/api/src/modules/agents/CONTEXT.md +++ b/apps/api/src/modules/agents/CONTEXT.md @@ -20,7 +20,7 @@ Agent 配置(profile)、运行实例(instance)、决策循环(loop) - `ops/` - ops.service(preflight 启动检查/health 轮询自愈/代理守护/默认数据 ensure;#409 起 worktree GC 已归一到 monitor-system-probes.gcStaleWorktrees 单实现,Ops 版 cleanupWorktrees 及其 hourly 挂载删除)、createHealthRoutes(/healthz 健康端点)、proc-probes(#344:/proc 系统探测单出口——statfs 磁盘/meminfo 内存//proc/*/stat 僵尸//proc/loadavg,零子进程;#374 起 ops.getStatus、monitor systemHealthCheck、ops preflight 磁盘检查三处同源委托;#418 起 +readProcessCmdline/countProcessesByCmdline/listPidsByCmdline/listZombieProcesses 四导出,triage.service 与 knowledge/env-snapper 探测同源) - `auditor/` - auditor.service(门面,24h 日审跨任务审计)、auditor-rules(错误归类/技能建议/知识健康;#523 起 OKR 低达成建 okr_proposal 单收口 WorkUnitService.create——原 commitSnapshot 直写不发 eventBus 事件,对唤醒体系隐形)、auditor-execution(低风险自动应用/确认卡片经 review-proposal 正本发卡/Resolution/Triage 升级;#439 起铃铛通知 link 带消息粒度——发卡后按 proposalId 反查卡消息拼 `?highlight=`,link 构造唯一出口 `buildAuditorNotificationLink`,反查不到降级频道粒度不阻断)、auditor-reports(行为趋势/七日趋势/tier 反馈)、review-adapter(#356:auditor_suggestion 卡接线 review-proposal 正本,kind=auditor,onApprove 建未指派 task 工单——自旧 channels/card-decision.service 搬入;审批走通用端点 /review-proposals/auditor/:id/*;`findAuditorCardMessageId` = 按提案 id 反查卡消息唯一出口,onApprove 原卡链接与 #439 通知 link 共用) - `knowledge/` - knowledge-curator.service(门面,冷启动+每日维护)、knowledge-extraction(提取 prompt 单一来源)、knowledge-cold-start(四源导入 docs/code/git/manual)、knowledge-maintenance(语义去重/质量评估/过期验证/矛盾审查) -- `loop/` - agent-loop(循环编排,导出面 = AgentLoop + StepResult;#544 拆除 re-export 门面,子模块符号由真属主直出;#363:原 start() 同角色 terminated 启动清理已拆除,回收归 instance-timeout-scan)、agent-loop.types(类型契约,纯类型零运行时)、agent-loop-registry(profileId→running AgentLoop 注册表,profile 生命周期事件驱动挂载/卸载)、agent-loop-parsers(输出解析+prompt 模板纯函数)、agent-loop-events(tokens/tool:call 落盘,provider 分流 usage;#320 起 workunit:tokens 落盘后顺带 `noteTokenLedgerWritten` 更新 token 账本 `utils/token-ledger`,失败隔离)、agent-loop-guards(测试 WU 守卫+excludeAssignee)、claim-fitness(认领前适任判断,决策 14)、daily-token-budget(每日 token 预算熔断,C3)、delegate-branch(A2A DELEGATE 分支委派政策判定)、prompt-composer(prompt/上下文组装,分段软定额截断)、session-resume(会话续用判定,#94)、wu-lease(WU 租约/fencing 追踪器,#209)、lease-heartbeat(WU 租约心跳 30s,fencing 校验)、context-overflow(溢出识别+滚动摘要)、executor(Executor 接口,LocalExecutor 委托 agentRunner)、execution-step-events(步级事件落盘+步内流式 SSE)、wu-verification(自动验证可复用实现)、completion-gates(收口守卫链:提交/子任务/空diff/契约产物/验证/软观测)、review-contract(verdict 语义单一来源 pass/reject/needs-info)、review-dispatcher(状态机驱动 review 系统代派,AC-4.1~4.5) +- `loop/` - agent-loop(循环编排,导出面 = AgentLoop + StepResult;#544 拆除 re-export 门面,子模块符号由真属主直出;#363:原 start() 同角色 terminated 启动清理已拆除,回收归 instance-timeout-scan)、agent-loop.types(类型契约,纯类型零运行时)、agent-loop-registry(profileId→running AgentLoop 注册表,profile 生命周期事件驱动挂载/卸载)、agent-loop-parsers(输出解析+prompt 模板纯函数)、agent-loop-events(tokens/tool:call 落盘,provider 分流 usage;#320 起 workunit:tokens 落盘后顺带 `noteTokenLedgerWritten` 更新 token 账本 `utils/token-ledger`,失败隔离)、agent-loop-guards(测试 WU 守卫+excludeAssignee)、step-guards(#541 入口守卫链:B2 测试 WU / C3 日预算 / #162 WU 预算 / #471 plan 额度,Ctx/Deps/Outcome 对称 completion-gates,agentStep 注入副作用 deps)、claim-fitness(认领前适任判断,决策 14)、daily-token-budget(每日 token 预算熔断,C3)、delegate-branch(A2A DELEGATE 分支委派政策判定)、prompt-composer(prompt/上下文组装,分段软定额截断)、session-resume(会话续用判定,#94)、wu-lease(WU 租约/fencing 追踪器,#209)、lease-heartbeat(WU 租约心跳 30s,fencing 校验)、context-overflow(溢出识别+滚动摘要)、executor(Executor 接口,LocalExecutor 委托 agentRunner)、execution-step-events(步级事件落盘+步内流式 SSE)、wu-verification(自动验证可复用实现)、completion-gates(收口守卫链:提交/子任务/空diff/契约产物/验证/软观测)、review-contract(verdict 语义单一来源 pass/reject/needs-info)、review-dispatcher(状态机驱动 review 系统代派,AC-4.1~4.5) - 根目录 - default-triggers(10 个系统 trigger:inspection-scan/dispatch-reconciliation/doc-semantic-review/#523 workunit-gate-escalation 等)、agent-targeting(Observations->Target 解析)、default-provider(provider 默认选取)、instance-timeout-scan(心跳过期 5min 扫描+pid 复核;#363:统一回收 terminated 实例——跨角色 deleteState 连带判空删目录,闭环实例目录生命周期)、dispatch-reconciliation(派工/评审断链 5min 对账;#523 起自愈补建/重派成功本频道出声「断链已自愈」,告警/失败仍只走 #62 告警管线)、wu-test-guards(测试特征 WU 判定) ### 依赖关系 From dd56cd89e67bdd6b0260250eabcd52dd5f00f13a Mon Sep 17 00:00:00 2001 From: dommaker Date: Tue, 15 Sep 2026 23:46:33 +0800 Subject: [PATCH 019/116] =?UTF-8?q?refactor(agent-loop):=20per-WU-type=20C?= =?UTF-8?q?OMPLETE=20=E6=94=B6=E5=89=B2=E6=94=B9=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E8=A1=A8=20(#542)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 completion-harvest.ts:type→收割器组注册表,新 type 收割=注册一行; 单收割器抛错仅 warn 跳过,不阻断收割链(开放问题 1/2 决议落码) - agentStep 成功路径 66 行 if 分支收敛为注册表调度调用,行为不变 - 表驱动直测 27 用例(__tests__/completion-harvest.test.ts),不依赖执行器 mock --- apps/api/src/modules/agents/CONTEXT.md | 2 +- .../loop/__tests__/completion-harvest.test.ts | 159 ++++++++++++++++++ .../api/src/modules/agents/loop/agent-loop.ts | 78 ++------- .../modules/agents/loop/completion-harvest.ts | 122 ++++++++++++++ 4 files changed, 292 insertions(+), 69 deletions(-) create mode 100644 apps/api/src/modules/agents/loop/__tests__/completion-harvest.test.ts create mode 100644 apps/api/src/modules/agents/loop/completion-harvest.ts diff --git a/apps/api/src/modules/agents/CONTEXT.md b/apps/api/src/modules/agents/CONTEXT.md index 71dcb77ce..7f389ab34 100644 --- a/apps/api/src/modules/agents/CONTEXT.md +++ b/apps/api/src/modules/agents/CONTEXT.md @@ -20,7 +20,7 @@ Agent 配置(profile)、运行实例(instance)、决策循环(loop) - `ops/` - ops.service(preflight 启动检查/health 轮询自愈/代理守护/默认数据 ensure;#409 起 worktree GC 已归一到 monitor-system-probes.gcStaleWorktrees 单实现,Ops 版 cleanupWorktrees 及其 hourly 挂载删除)、createHealthRoutes(/healthz 健康端点)、proc-probes(#344:/proc 系统探测单出口——statfs 磁盘/meminfo 内存//proc/*/stat 僵尸//proc/loadavg,零子进程;#374 起 ops.getStatus、monitor systemHealthCheck、ops preflight 磁盘检查三处同源委托;#418 起 +readProcessCmdline/countProcessesByCmdline/listPidsByCmdline/listZombieProcesses 四导出,triage.service 与 knowledge/env-snapper 探测同源) - `auditor/` - auditor.service(门面,24h 日审跨任务审计)、auditor-rules(错误归类/技能建议/知识健康;#523 起 OKR 低达成建 okr_proposal 单收口 WorkUnitService.create——原 commitSnapshot 直写不发 eventBus 事件,对唤醒体系隐形)、auditor-execution(低风险自动应用/确认卡片经 review-proposal 正本发卡/Resolution/Triage 升级;#439 起铃铛通知 link 带消息粒度——发卡后按 proposalId 反查卡消息拼 `?highlight=`,link 构造唯一出口 `buildAuditorNotificationLink`,反查不到降级频道粒度不阻断)、auditor-reports(行为趋势/七日趋势/tier 反馈)、review-adapter(#356:auditor_suggestion 卡接线 review-proposal 正本,kind=auditor,onApprove 建未指派 task 工单——自旧 channels/card-decision.service 搬入;审批走通用端点 /review-proposals/auditor/:id/*;`findAuditorCardMessageId` = 按提案 id 反查卡消息唯一出口,onApprove 原卡链接与 #439 通知 link 共用) - `knowledge/` - knowledge-curator.service(门面,冷启动+每日维护)、knowledge-extraction(提取 prompt 单一来源)、knowledge-cold-start(四源导入 docs/code/git/manual)、knowledge-maintenance(语义去重/质量评估/过期验证/矛盾审查) -- `loop/` - agent-loop(循环编排,导出面 = AgentLoop + StepResult;#544 拆除 re-export 门面,子模块符号由真属主直出;#363:原 start() 同角色 terminated 启动清理已拆除,回收归 instance-timeout-scan)、agent-loop.types(类型契约,纯类型零运行时)、agent-loop-registry(profileId→running AgentLoop 注册表,profile 生命周期事件驱动挂载/卸载)、agent-loop-parsers(输出解析+prompt 模板纯函数)、agent-loop-events(tokens/tool:call 落盘,provider 分流 usage;#320 起 workunit:tokens 落盘后顺带 `noteTokenLedgerWritten` 更新 token 账本 `utils/token-ledger`,失败隔离)、agent-loop-guards(测试 WU 守卫+excludeAssignee)、step-guards(#541 入口守卫链:B2 测试 WU / C3 日预算 / #162 WU 预算 / #471 plan 额度,Ctx/Deps/Outcome 对称 completion-gates,agentStep 注入副作用 deps)、claim-fitness(认领前适任判断,决策 14)、daily-token-budget(每日 token 预算熔断,C3)、delegate-branch(A2A DELEGATE 分支委派政策判定)、prompt-composer(prompt/上下文组装,分段软定额截断)、session-resume(会话续用判定,#94)、wu-lease(WU 租约/fencing 追踪器,#209)、lease-heartbeat(WU 租约心跳 30s,fencing 校验)、context-overflow(溢出识别+滚动摘要)、executor(Executor 接口,LocalExecutor 委托 agentRunner)、execution-step-events(步级事件落盘+步内流式 SSE)、wu-verification(自动验证可复用实现)、completion-gates(收口守卫链:提交/子任务/空diff/契约产物/验证/软观测)、review-contract(verdict 语义单一来源 pass/reject/needs-info)、review-dispatcher(状态机驱动 review 系统代派,AC-4.1~4.5) +- `loop/` - agent-loop(循环编排,导出面 = AgentLoop + StepResult;#544 拆除 re-export 门面,子模块符号由真属主直出;#363:原 start() 同角色 terminated 启动清理已拆除,回收归 instance-timeout-scan)、agent-loop.types(类型契约,纯类型零运行时)、agent-loop-registry(profileId→running AgentLoop 注册表,profile 生命周期事件驱动挂载/卸载)、agent-loop-parsers(输出解析+prompt 模板纯函数)、agent-loop-events(tokens/tool:call 落盘,provider 分流 usage;#320 起 workunit:tokens 落盘后顺带 `noteTokenLedgerWritten` 更新 token 账本 `utils/token-ledger`,失败隔离)、agent-loop-guards(测试 WU 守卫+excludeAssignee)、step-guards(#541 入口守卫链:B2 测试 WU / C3 日预算 / #162 WU 预算 / #471 plan 额度,Ctx/Deps/Outcome 对称 completion-gates,agentStep 注入副作用 deps)、claim-fitness(认领前适任判断,决策 14)、daily-token-budget(每日 token 预算熔断,C3)、delegate-branch(A2A DELEGATE 分支委派政策判定)、prompt-composer(prompt/上下文组装,分段软定额截断)、session-resume(会话续用判定,#94)、wu-lease(WU 租约/fencing 追踪器,#209)、lease-heartbeat(WU 租约心跳 30s,fencing 校验)、context-overflow(溢出识别+滚动摘要)、executor(Executor 接口,LocalExecutor 委托 agentRunner)、execution-step-events(步级事件落盘+步内流式 SSE)、wu-verification(自动验证可复用实现)、completion-gates(收口守卫链:提交/子任务/空diff/契约产物/验证/软观测)、completion-harvest(#542 per-WU-type COMPLETE 收割注册表:type→收割器组,review/analysis|plan/decision/spec 五键,新 type=注册一行,单收割器抛错仅跳过)、review-contract(verdict 语义单一来源 pass/reject/needs-info)、review-dispatcher(状态机驱动 review 系统代派,AC-4.1~4.5) - 根目录 - default-triggers(10 个系统 trigger:inspection-scan/dispatch-reconciliation/doc-semantic-review/#523 workunit-gate-escalation 等)、agent-targeting(Observations->Target 解析)、default-provider(provider 默认选取)、instance-timeout-scan(心跳过期 5min 扫描+pid 复核;#363:统一回收 terminated 实例——跨角色 deleteState 连带判空删目录,闭环实例目录生命周期)、dispatch-reconciliation(派工/评审断链 5min 对账;#523 起自愈补建/重派成功本频道出声「断链已自愈」,告警/失败仍只走 #62 告警管线)、wu-test-guards(测试特征 WU 判定) ### 依赖关系 diff --git a/apps/api/src/modules/agents/loop/__tests__/completion-harvest.test.ts b/apps/api/src/modules/agents/loop/__tests__/completion-harvest.test.ts new file mode 100644 index 000000000..a20d73626 --- /dev/null +++ b/apps/api/src/modules/agents/loop/__tests__/completion-harvest.test.ts @@ -0,0 +1,159 @@ +// per-WU-type COMPLETE 收割注册表单测(#542):表驱动直测 harvestCompletionMetadata, +// 不构造 AgentLoop、不 mock 执行器(对称 step-guards/completion-gates 的可测试性契约)。 +// 覆盖:六种收割分支逐 type 命中/落空、plan 复用 analysis 契约、inspection 变体标记、 +// 未注册 type 空获、单个收割器抛错不阻断其余收割器(失败语义决议)。 +import { describe, it, expect, afterEach } from 'vitest'; +import { + harvestCompletionMetadata, + COMPLETION_HARVEST_REGISTRY, + type CompletionHarvestContext, +} from '../completion-harvest'; +import type { WorkUnitMetadata } from '../../../workunit/workunit.service.js'; + +function ctx(overrides: Partial = {}): CompletionHarvestContext { + return { wuId: 'wu-1', wuType: 'task', metadata: {} as WorkUnitMetadata, ...overrides }; +} + +describe('harvestCompletionMetadata', () => { + it.each([ + ['task'], ['feature'], ['bug'], ['unknown-type'], + ])('未注册 type(%s)→ 空 updates', (wuType) => { + expect(harvestCompletionMetadata('REVIEW_RESULT: {"verdict":"pass"}', ctx({ wuType }))).toEqual({}); + }); + + describe('review', () => { + it.each([ + { + name: 'REVIEW_RESULT pass 行 → reviewReport.approved=true', + text: '评审完毕\nREVIEW_RESULT: {"verdict":"pass","summary":"LGTM"}', + expected: { reviewReport: { approved: true, reason: 'LGTM', issues: undefined } }, + }, + { + name: 'REVIEW_RESULT reject 行 → reviewReport.approved=false', + text: 'REVIEW_RESULT: {"verdict":"reject","summary":"缺测试"}', + expected: { reviewReport: { approved: false, reason: '缺测试', issues: undefined } }, + }, + { + name: '无 REVIEW_RESULT 且无 verdict 关键词 → 空(转人工,不误拒)', + text: '随便一段没有结论的输出', + expected: {}, + }, + ])('$name', ({ text, expected }) => { + expect(harvestCompletionMetadata(text, ctx({ wuType: 'review' }))).toEqual(expected); + }); + }); + + describe.each(['analysis', 'plan'])('%s(plan 复用 analysis 契约)', (wuType) => { + it.each([ + { + name: 'TASK: 拆分行 → analysisTasks', + text: '分析结论\nTASK: 拆出登录接口\nTASK: 补齐鉴权测试', + metadata: {}, + expected: { analysisTasks: ['拆出登录接口', '补齐鉴权测试'] }, + }, + { + name: 'FOG:+DESTINATION: → analysisFog + analysisDestination', + text: 'DESTINATION: 上线支付\nFOG: 用哪家通道?\nFOG: 额度怎么定?', + metadata: {}, + expected: { analysisFog: ['用哪家通道?', '额度怎么定?'], analysisDestination: '上线支付' }, + }, + { + name: '有 FOG 无 DESTINATION → 只落 analysisFog(destination 不单独落档)', + text: 'FOG: 用哪家通道?', + metadata: {}, + expected: { analysisFog: ['用哪家通道?'] }, + }, + { + name: 'inspection=true + OPPORTUNITY: 行 → opportunities(opp-N/pending)', + text: 'OPPORTUNITY: {"problem":"构建慢","suggestion":"开缓存","estimate":"1d"}', + metadata: { inspection: true }, + expected: { + opportunities: [{ id: 'opp-1', problem: '构建慢', suggestion: '开缓存', estimate: '1d', status: 'pending' }], + }, + }, + { + name: '无 inspection 标记 → OPPORTUNITY 行不收割', + text: 'OPPORTUNITY: {"problem":"构建慢","suggestion":"开缓存"}', + metadata: {}, + expected: {}, + }, + { + name: '无任何协议行 → 空(不阻断完成)', + text: '就是一段普通结论文本', + metadata: {}, + expected: {}, + }, + { + name: '三类协议行同现 → 全部落档', + text: 'TASK: 拆接口\nFOG: 通道选型?\nOPPORTUNITY: {"problem":"构建慢","suggestion":"开缓存"}', + metadata: { inspection: true }, + expected: { + analysisTasks: ['拆接口'], + analysisFog: ['通道选型?'], + opportunities: [{ id: 'opp-1', problem: '构建慢', suggestion: '开缓存', status: 'pending' }], + }, + }, + ])('$name', ({ text, metadata, expected }) => { + expect(harvestCompletionMetadata(text, ctx({ wuType, metadata: metadata as WorkUnitMetadata }))).toEqual(expected); + }); + }); + + describe('decision', () => { + it.each([ + { + name: '## 结论摘要 段 → decisionSuggestion', + text: '分析过程……\n## 结论摘要\n建议走方案 A。\n## 其他\n无关内容', + expected: { decisionSuggestion: '建议走方案 A。' }, + }, + { + name: '无结论摘要段 → 空(确认弹窗空手填)', + text: '没有任何标题的正文', + expected: {}, + }, + ])('$name', ({ text, expected }) => { + expect(harvestCompletionMetadata(text, ctx({ wuType: 'decision' }))).toEqual(expected); + }); + }); + + describe('spec', () => { + it.each([ + { + name: 'TASK: 物化行(含 AC/BLOCKEDBY/LEG 段)→ specTasks', + text: 'TASK: 登录接口 | AC: 返回 token | BLOCKEDBY: wu-0, wu-1 | LEG: api', + expected: { specTasks: [{ title: '登录接口', ac: ['返回 token'], blockedBy: ['wu-0', 'wu-1'], leg: 'api' }] }, + }, + { + name: '无 TASK 行 → 空(不阻断完成)', + text: '普通 spec 结论文本', + expected: {}, + }, + ])('$name', ({ text, expected }) => { + expect(harvestCompletionMetadata(text, ctx({ wuType: 'spec' }))).toEqual(expected); + }); + }); + + describe('失败语义:单个收割器抛错不阻断收割链', () => { + afterEach(() => { + delete COMPLETION_HARVEST_REGISTRY['throwing-type']; + }); + + it('抛错的收割器被跳过,同 type 其余收割器照常收割', () => { + COMPLETION_HARVEST_REGISTRY['throwing-type'] = [ + () => { throw new Error('boom'); }, + () => ({ analysisTasks: ['幸存'] }), + ]; + expect(harvestCompletionMetadata('whatever', ctx({ wuType: 'throwing-type' }))) + .toEqual({ analysisTasks: ['幸存'] }); + }); + }); + + it('新 type 收割 = 注册一行(注册表驱动,无 type 分支)', () => { + COMPLETION_HARVEST_REGISTRY['ad-hoc-type'] = [(text) => text.includes('X') ? { customField: 1 } : {}]; + try { + expect(harvestCompletionMetadata('has X', ctx({ wuType: 'ad-hoc-type' }))).toEqual({ customField: 1 }); + expect(harvestCompletionMetadata('no marker', ctx({ wuType: 'ad-hoc-type' }))).toEqual({}); + } finally { + delete COMPLETION_HARVEST_REGISTRY['ad-hoc-type']; + } + }); +}); diff --git a/apps/api/src/modules/agents/loop/agent-loop.ts b/apps/api/src/modules/agents/loop/agent-loop.ts index 20a025dc7..ba8e7ebc6 100644 --- a/apps/api/src/modules/agents/loop/agent-loop.ts +++ b/apps/api/src/modules/agents/loop/agent-loop.ts @@ -36,13 +36,11 @@ import { loadCurrentWuContexts, type CurrentWuContext } from '../../monitoring/c import { CODE_WORKTREE_TYPES, runWuVerification } from './wu-verification.js'; import { runCompletionGuards } from './completion-gates.js'; import { runStepGuards } from './step-guards.js'; -import { parseMapOpening } from '../../pmo/map-opening.js'; -import { parseSpecTasks } from '../../pmo/spec-materialization.js'; +import { harvestCompletionMetadata } from './completion-harvest.js'; import type { StepResult, Observations, Target, RuntimeInstanceRow } from './agent-loop.types.js'; import { isProcessAlive, isGitRepoRoot, resolveWorktreesDir, - resolveTarget, parseAgentOutput, dynamicInterval, parseReviewReport, parseTaskBreakdown, - parseOpportunities, parseDecisionConclusion, + resolveTarget, parseAgentOutput, dynamicInterval, sleep, } from './agent-loop-parsers.js'; import { @@ -1401,70 +1399,14 @@ export class AgentLoop { } catch { /* non-blocking */ } } - // P0 修复(reviewReport 回传断链):review 子 WU 报告 COMPLETE 时,把 reviewer - // 最终输出解析为结构化结论写入 metadata.reviewReport —— 这是 ReviewDispatcher - // 路径 B 判定父 WU 过/拒的唯一数据源。解析失败不写(dispatcher 转人工,不误拒)。 - if (wu.type === 'review' && stepResult.action === 'complete') { - const report = parseReviewReport(result.outputText ?? ''); - if (report) { - metadataUpdates.reviewReport = report; - } else { - logger.warn(`[AgentLoop] Review WU ${wu.id} completed without parseable REVIEW_RESULT — 由 ReviewDispatcher 转人工`); - } - } - - // PMO 规划接力(analysis-handoff):analysis/plan WU COMPLETE 时解析 TASK: 拆分行 - // 写入 metadata.analysisTasks —— 人工确认(reviewPassed → done)后由 - // analysis-handoff 据此建未指派 task 子 WU(频道成员涌现认领 = 派工)。 - // #471:plan(一脉会话)沿用 analysis 字段名——解析契约/确认弹窗预填/派工消费全不变。 - // 解析失败/无 TASK 行不写(确认后仅提示可手动拆,不阻断完成)。 - if ((wu.type === 'analysis' || wu.type === 'plan') && stepResult.action === 'complete') { - const tasks = parseTaskBreakdown(result.outputText ?? ''); - if (tasks.length > 0) { - metadataUpdates.analysisTasks = tasks; - } - // #106 M7 对齐:同份输出里的 FOG:/DESTINATION: 行(map-opening 同一解析器, - // 契约单一来源)落 metadata——人工确认弹窗据此预填待决问题清单(人审改后随 - // l3.summary 回传开图)。无 FOG 行 = 非探路型,两字段都不落(destination - // 单独落档会预填出一行无人消费的 DESTINATION,误导确认人)。 - const opening = parseMapOpening(result.outputText ?? ''); - if (opening.fog.length > 0) { - metadataUpdates.analysisFog = opening.fog; - if (opening.destination) { - metadataUpdates.analysisDestination = opening.destination; - } - } - // #163(T8-E2,#130 决策 2):巡检单 COMPLETE 时解析 OPPORTUNITY: 协议行 - // 落 metadata.opportunities(初始全 pending;id=opp-N 单内唯一)——冷却闸判定 - // 与 web 确认 UI(采纳/忽略)消费。无合法 OPPORTUNITY 行不写,不阻断完成。 - if (metadata.inspection === true) { - const opps = parseOpportunities(result.outputText ?? ''); - if (opps.length > 0) { - metadataUpdates.opportunities = opps.map((o, i) => ({ - id: `opp-${i + 1}`, - ...o, - status: 'pending' as const, - })); - } - } - } - - // #463:decision/spec 确认表单的结构化预填数据源(照 analysis TASK/FOG 落档先例, - // 结构化数据在确认之前落档,确认弹窗只做评审不做录入): - // decision COMPLETE → `## 结论摘要` 段(prompt 契约已有)落 decisionSuggestion; - // spec COMPLETE → TASK: 物化行(spec-materialization 同一解析器,契约单一来源) - // 落 specTasks。解析无获不写,不阻断完成。 - if (wu.type === 'decision' && stepResult.action === 'complete') { - const suggestion = parseDecisionConclusion(result.outputText ?? ''); - if (suggestion) { - metadataUpdates.decisionSuggestion = suggestion; - } - } - if (wu.type === 'spec' && stepResult.action === 'complete') { - const specTasks = parseSpecTasks(result.outputText ?? ''); - if (specTasks.length > 0) { - metadataUpdates.specTasks = specTasks; - } + // #542:per-WU-type COMPLETE 收割改注册表(completion-harvest.ts)—— + // review→reviewReport / analysis|plan→analysisTasks+analysisFog+opportunities / + // decision→decisionSuggestion / spec→specTasks。新 type 收割 = 注册表加一行; + // 解析无获不落档、单收割器抛错仅跳过,均不阻断完成(语义同原各分支)。 + if (stepResult.action === 'complete') { + Object.assign(metadataUpdates, harvestCompletionMetadata(result.outputText ?? '', { + wuId: wu.id, wuType: wu.type, metadata, + })); } return { ...stepResult, metadataUpdates, channelVersion }; diff --git a/apps/api/src/modules/agents/loop/completion-harvest.ts b/apps/api/src/modules/agents/loop/completion-harvest.ts new file mode 100644 index 000000000..5de72327b --- /dev/null +++ b/apps/api/src/modules/agents/loop/completion-harvest.ts @@ -0,0 +1,122 @@ +/** + * per-WU-type COMPLETE 收割注册表(#542,2026-09-15 架构评审候选 A2) + * + * agentStep 成功路径的 WU 输出解析 → metadataUpdates 落档,从 agent-loop.ts 的 + * 长串 if 分支改为「type → 收割器组」注册表调度: + * - 新 WU type 收割 = 在 COMPLETION_HARVEST_REGISTRY 注册一行; + * - 注册表键 = wu.type 单键(开放问题 1 决议);变体走收割器内部读 ctx.metadata + * (inspection 标记),plan 与 analysis 共享同一收割器组(#471 一脉会话,契约不变); + * - 失败语义(开放问题 2 决议):单个收割器抛错仅 warn 跳过该收割器,其余收割器 + * 与整条收割链不受影响;解析器本身返回 null/[] 不抛(解析无获不落档、不阻断完成, + * 语义与各分支原实现逐一对应)。 + * + * 解析器全部复用现成品(agent-loop-parsers / pmo/map-opening / pmo/spec-materialization), + * 本模块只做调度与落档形状组装,不新增解析契约。 + */ + +import { logger } from '@dommaker/studio-shared'; +import type { WorkUnitMetadata } from '../../workunit/workunit.service.js'; +import { + parseReviewReport, parseTaskBreakdown, parseOpportunities, parseDecisionConclusion, +} from './agent-loop-parsers.js'; +import { parseMapOpening } from '../../pmo/map-opening.js'; +import { parseSpecTasks } from '../../pmo/spec-materialization.js'; + +export interface CompletionHarvestContext { + wuId: string; + wuType: string; + /** 当前 metadata(变体判定用,如 inspection 标记) */ + metadata: WorkUnitMetadata; +} + +/** 收割器:从 WU 最终输出解析出 metadataUpdates 贡献(无获返回 {}) */ +export type CompletionHarvester = (outputText: string, ctx: CompletionHarvestContext) => Partial; + +/** review:REVIEW_RESULT → reviewReport(ReviewDispatcher 路径 B 判定的唯一数据源) */ +const reviewReportHarvester: CompletionHarvester = (outputText, ctx) => { + const report = parseReviewReport(outputText); + if (report) return { reviewReport: report }; + logger.warn(`[AgentLoop] Review WU ${ctx.wuId} completed without parseable REVIEW_RESULT — 由 ReviewDispatcher 转人工`); + return {}; +}; + +/** analysis/plan:TASK: 拆分行 → analysisTasks(analysis-handoff 派工数据源) */ +const analysisTasksHarvester: CompletionHarvester = (outputText) => { + const tasks = parseTaskBreakdown(outputText); + return tasks.length > 0 ? { analysisTasks: tasks } : {}; +}; + +/** analysis/plan:FOG:/DESTINATION: 行 → analysisFog/analysisDestination(确认弹窗预填) */ +const mapOpeningHarvester: CompletionHarvester = (outputText) => { + const opening = parseMapOpening(outputText); + if (opening.fog.length === 0) return {}; + return { + analysisFog: opening.fog, + ...(opening.destination ? { analysisDestination: opening.destination } : {}), + }; +}; + +/** analysis/plan 巡检变体(metadata.inspection===true):OPPORTUNITY: 行 → opportunities */ +const inspectionOpportunitiesHarvester: CompletionHarvester = (outputText, ctx) => { + if (ctx.metadata.inspection !== true) return {}; + const opps = parseOpportunities(outputText); + if (opps.length === 0) return {}; + return { + opportunities: opps.map((o, i) => ({ + id: `opp-${i + 1}`, + ...o, + status: 'pending' as const, + })), + }; +}; + +/** decision:`## 结论摘要` 段 → decisionSuggestion(确认弹窗预填 agent 建议结论) */ +const decisionSuggestionHarvester: CompletionHarvester = (outputText) => { + const suggestion = parseDecisionConclusion(outputText); + return suggestion ? { decisionSuggestion: suggestion } : {}; +}; + +/** spec:TASK: 物化行 → specTasks(确认弹窗卡片墙预填,spec-materialization 同一解析器) */ +const specTasksHarvester: CompletionHarvester = (outputText) => { + const specTasks = parseSpecTasks(outputText); + return specTasks.length > 0 ? { specTasks } : {}; +}; + +/** analysis 与 plan 共享的收割器组(#471:plan 沿用 analysis 字段名与解析契约) */ +const ANALYSIS_HARVESTERS: CompletionHarvester[] = [ + analysisTasksHarvester, + mapOpeningHarvester, + inspectionOpportunitiesHarvester, +]; + +/** + * 收割注册表:wu.type → 收割器组。新 WU type 收割 = 注册一行。 + * 同 type 多收割器按序执行,结果合并(后写覆盖先写——同 type 内各收割器落不同字段, + * 正常无覆盖)。 + */ +export const COMPLETION_HARVEST_REGISTRY: Record = { + review: [reviewReportHarvester], + analysis: ANALYSIS_HARVESTERS, + plan: ANALYSIS_HARVESTERS, + decision: [decisionSuggestionHarvester], + spec: [specTasksHarvester], +}; + +/** + * 调度入口:按 wu.type 查注册表,逐收割器执行并合并 metadataUpdates。 + * 单个收割器抛错仅 warn 跳过(收割是落档增强,绝不让解析异常打断 agentStep 成功路径); + * 未注册 type → {}。 + */ +export function harvestCompletionMetadata(outputText: string, ctx: CompletionHarvestContext): Partial { + const harvesters = COMPLETION_HARVEST_REGISTRY[ctx.wuType]; + if (!harvesters) return {}; + const updates: Partial = {}; + for (const harvester of harvesters) { + try { + Object.assign(updates, harvester(outputText, ctx)); + } catch (err) { + logger.warn(`[AgentLoop] completion harvest failed for WU ${ctx.wuId} (type=${ctx.wuType}): ${err instanceof Error ? err.message : String(err)}`); + } + } + return updates; +} From 760628051a4c103abfcd0ac760858684fc3a35ba Mon Sep 17 00:00:00 2001 From: dommaker Date: Wed, 16 Sep 2026 00:04:30 +0800 Subject: [PATCH 020/116] =?UTF-8?q?refactor(agent-loop):=20=E4=B8=A4?= =?UTF-8?q?=E6=AE=B5=E5=AD=AA=E7=94=9F=E9=87=8D=E8=AF=95=E9=AA=A8=E6=9E=B6?= =?UTF-8?q?=E6=94=B6=E7=BC=96=E4=B8=BA=E6=AD=A5=E6=89=A7=E8=A1=8C=E9=87=8D?= =?UTF-8?q?=E8=AF=95=E7=AD=96=E7=95=A5=20(#543)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #94 续用丢失降级与 #96 上下文溢出重试(配额判定→改参→重算 prompt→再执行 →成败分叉→簿记回写)原各写一遍卡在 agentStep 中段,收敛为 loop/step-retry-policy.ts 的 runStepRetry 一份(Ctx/Deps/Outcome 同 step-guards);MAX_SESSIONS_PER_WU 与 resetUnestablishedSessionBookkeeping 正本随迁。重试序成显式断言点:占额/簿记回写经 fake executor 直测 (14 例),两个集成测试文件的重试细节用例降为接线冒烟。 --- apps/api/src/modules/agents/CONTEXT.md | 2 +- .../agent-loop-context-overflow.test.ts | 31 +- .../agent-loop-session-resume.test.ts | 14 +- .../loop/__tests__/step-retry-policy.test.ts | 290 ++++++++++++++++++ .../api/src/modules/agents/loop/agent-loop.ts | 145 ++------- .../modules/agents/loop/step-retry-policy.ts | 212 +++++++++++++ 6 files changed, 554 insertions(+), 140 deletions(-) create mode 100644 apps/api/src/modules/agents/loop/__tests__/step-retry-policy.test.ts create mode 100644 apps/api/src/modules/agents/loop/step-retry-policy.ts diff --git a/apps/api/src/modules/agents/CONTEXT.md b/apps/api/src/modules/agents/CONTEXT.md index 7f389ab34..670dec2e8 100644 --- a/apps/api/src/modules/agents/CONTEXT.md +++ b/apps/api/src/modules/agents/CONTEXT.md @@ -20,7 +20,7 @@ Agent 配置(profile)、运行实例(instance)、决策循环(loop) - `ops/` - ops.service(preflight 启动检查/health 轮询自愈/代理守护/默认数据 ensure;#409 起 worktree GC 已归一到 monitor-system-probes.gcStaleWorktrees 单实现,Ops 版 cleanupWorktrees 及其 hourly 挂载删除)、createHealthRoutes(/healthz 健康端点)、proc-probes(#344:/proc 系统探测单出口——statfs 磁盘/meminfo 内存//proc/*/stat 僵尸//proc/loadavg,零子进程;#374 起 ops.getStatus、monitor systemHealthCheck、ops preflight 磁盘检查三处同源委托;#418 起 +readProcessCmdline/countProcessesByCmdline/listPidsByCmdline/listZombieProcesses 四导出,triage.service 与 knowledge/env-snapper 探测同源) - `auditor/` - auditor.service(门面,24h 日审跨任务审计)、auditor-rules(错误归类/技能建议/知识健康;#523 起 OKR 低达成建 okr_proposal 单收口 WorkUnitService.create——原 commitSnapshot 直写不发 eventBus 事件,对唤醒体系隐形)、auditor-execution(低风险自动应用/确认卡片经 review-proposal 正本发卡/Resolution/Triage 升级;#439 起铃铛通知 link 带消息粒度——发卡后按 proposalId 反查卡消息拼 `?highlight=`,link 构造唯一出口 `buildAuditorNotificationLink`,反查不到降级频道粒度不阻断)、auditor-reports(行为趋势/七日趋势/tier 反馈)、review-adapter(#356:auditor_suggestion 卡接线 review-proposal 正本,kind=auditor,onApprove 建未指派 task 工单——自旧 channels/card-decision.service 搬入;审批走通用端点 /review-proposals/auditor/:id/*;`findAuditorCardMessageId` = 按提案 id 反查卡消息唯一出口,onApprove 原卡链接与 #439 通知 link 共用) - `knowledge/` - knowledge-curator.service(门面,冷启动+每日维护)、knowledge-extraction(提取 prompt 单一来源)、knowledge-cold-start(四源导入 docs/code/git/manual)、knowledge-maintenance(语义去重/质量评估/过期验证/矛盾审查) -- `loop/` - agent-loop(循环编排,导出面 = AgentLoop + StepResult;#544 拆除 re-export 门面,子模块符号由真属主直出;#363:原 start() 同角色 terminated 启动清理已拆除,回收归 instance-timeout-scan)、agent-loop.types(类型契约,纯类型零运行时)、agent-loop-registry(profileId→running AgentLoop 注册表,profile 生命周期事件驱动挂载/卸载)、agent-loop-parsers(输出解析+prompt 模板纯函数)、agent-loop-events(tokens/tool:call 落盘,provider 分流 usage;#320 起 workunit:tokens 落盘后顺带 `noteTokenLedgerWritten` 更新 token 账本 `utils/token-ledger`,失败隔离)、agent-loop-guards(测试 WU 守卫+excludeAssignee)、step-guards(#541 入口守卫链:B2 测试 WU / C3 日预算 / #162 WU 预算 / #471 plan 额度,Ctx/Deps/Outcome 对称 completion-gates,agentStep 注入副作用 deps)、claim-fitness(认领前适任判断,决策 14)、daily-token-budget(每日 token 预算熔断,C3)、delegate-branch(A2A DELEGATE 分支委派政策判定)、prompt-composer(prompt/上下文组装,分段软定额截断)、session-resume(会话续用判定,#94)、wu-lease(WU 租约/fencing 追踪器,#209)、lease-heartbeat(WU 租约心跳 30s,fencing 校验)、context-overflow(溢出识别+滚动摘要)、executor(Executor 接口,LocalExecutor 委托 agentRunner)、execution-step-events(步级事件落盘+步内流式 SSE)、wu-verification(自动验证可复用实现)、completion-gates(收口守卫链:提交/子任务/空diff/契约产物/验证/软观测)、completion-harvest(#542 per-WU-type COMPLETE 收割注册表:type→收割器组,review/analysis|plan/decision/spec 五键,新 type=注册一行,单收割器抛错仅跳过)、review-contract(verdict 语义单一来源 pass/reject/needs-info)、review-dispatcher(状态机驱动 review 系统代派,AC-4.1~4.5) +- `loop/` - agent-loop(循环编排,导出面 = AgentLoop + StepResult;#544 拆除 re-export 门面,子模块符号由真属主直出;#363:原 start() 同角色 terminated 启动清理已拆除,回收归 instance-timeout-scan)、agent-loop.types(类型契约,纯类型零运行时)、agent-loop-registry(profileId→running AgentLoop 注册表,profile 生命周期事件驱动挂载/卸载)、agent-loop-parsers(输出解析+prompt 模板纯函数)、agent-loop-events(tokens/tool:call 落盘,provider 分流 usage;#320 起 workunit:tokens 落盘后顺带 `noteTokenLedgerWritten` 更新 token 账本 `utils/token-ledger`,失败隔离)、agent-loop-guards(测试 WU 守卫+excludeAssignee)、step-guards(#541 入口守卫链:B2 测试 WU / C3 日预算 / #162 WU 预算 / #471 plan 额度,Ctx/Deps/Outcome 对称 completion-gates,agentStep 注入副作用 deps)、step-retry-policy(#543 步执行重试策略:#94 续用丢失降级 + #96 上下文溢出两段孪生骨架收敛为 runStepRetry 一份,Ctx/Deps/Outcome 同 step-guards,MAX_SESSIONS_PER_WU 与 resetUnestablishedSessionBookkeeping 正本在此,fake executor 直测占额/簿记)、claim-fitness(认领前适任判断,决策 14)、daily-token-budget(每日 token 预算熔断,C3)、delegate-branch(A2A DELEGATE 分支委派政策判定)、prompt-composer(prompt/上下文组装,分段软定额截断)、session-resume(会话续用判定,#94)、wu-lease(WU 租约/fencing 追踪器,#209)、lease-heartbeat(WU 租约心跳 30s,fencing 校验)、context-overflow(溢出识别+滚动摘要)、executor(Executor 接口,LocalExecutor 委托 agentRunner)、execution-step-events(步级事件落盘+步内流式 SSE)、wu-verification(自动验证可复用实现)、completion-gates(收口守卫链:提交/子任务/空diff/契约产物/验证/软观测)、completion-harvest(#542 per-WU-type COMPLETE 收割注册表:type→收割器组,review/analysis|plan/decision/spec 五键,新 type=注册一行,单收割器抛错仅跳过)、review-contract(verdict 语义单一来源 pass/reject/needs-info)、review-dispatcher(状态机驱动 review 系统代派,AC-4.1~4.5) - 根目录 - default-triggers(10 个系统 trigger:inspection-scan/dispatch-reconciliation/doc-semantic-review/#523 workunit-gate-escalation 等)、agent-targeting(Observations->Target 解析)、default-provider(provider 默认选取)、instance-timeout-scan(心跳过期 5min 扫描+pid 复核;#363:统一回收 terminated 实例——跨角色 deleteState 连带判空删目录,闭环实例目录生命周期)、dispatch-reconciliation(派工/评审断链 5min 对账;#523 起自愈补建/重派成功本频道出声「断链已自愈」,告警/失败仍只走 #62 告警管线)、wu-test-guards(测试特征 WU 判定) ### 依赖关系 diff --git a/apps/api/src/modules/agents/__tests__/agent-loop-context-overflow.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop-context-overflow.test.ts index c68cc172c..71690b013 100644 --- a/apps/api/src/modules/agents/__tests__/agent-loop-context-overflow.test.ts +++ b/apps/api/src/modules/agents/__tests__/agent-loop-context-overflow.test.ts @@ -1,6 +1,9 @@ // #96: CLI 上下文溢出纯反应式策略 —— 溢出错误 → 会话滚动摘要落盘 → 新会话带摘要 // 注入重试一次 → 再败 NEED_INPUT;并收口 #95 降级路径超限(续用降级也遵守 MAX)。 // 真实 FileStore(tmpdir)+ 真实 WorkUnitService;CLI 执行与 knowledge-service mock。 +// #543:溢出/降级重试序收编 loop/step-retry-policy 后,重试细节断言(占额/簿记/摘要注入) +// 移 loop/__tests__/step-retry-policy.test.ts 直测(fake executor),本文件只留整类构造的 +// 触发接线、配额分叉与失败 outcome 落盘接线。 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; @@ -102,39 +105,27 @@ describe('#96: CLI 上下文溢出纯反应式策略', () => { return mockExecuteLightweight.mock.calls.at(index)![0] as AgentTask; } - it('续用会话溢出 → 新会话带摘要重试成功:sessionCount+1、sessionSummary 落盘、重试 prompt 注入摘要', async () => { + it('续用会话溢出 → 新会话带摘要重试成功(接线冒烟;占额/簿记/摘要注入细节见 loop/__tests__/step-retry-policy 直测)', async () => { const wu = await setupWorkUnit({ sessionId: 'sess-overflow', sessionCount: 1, stepCount: 2, progressLog: [{ step: 1, action: 'progress', summary: '完成数据层', at: '2026-08-12T10:00:00Z' }], }); // 首次(续用形态)报溢出;重试走默认成功 - let firstParams: AgentTask['parameters'] | undefined; - mockExecuteLightweight.mockImplementationOnce(async (task: AgentTask) => { - firstParams = { ...task.parameters }; - return { ...OVERFLOW_FAILURE }; - }); + mockExecuteLightweight.mockImplementationOnce(async () => ({ ...OVERFLOW_FAILURE })); const step = await (agentLoop as unknown as AgentStepCapable).agentStep({ workUnit: wu }); + // 接线:首败触发策略重试一次(新建形态);重试 prompt 带真实滚动摘要段;成功走正常路径 expect(mockExecuteLightweight).toHaveBeenCalledTimes(2); - // 首次:续用形态;重试:新建形态(claude 传新 UUID、不带 sessionResume) - expect(firstParams?.sessionId).toBe('sess-overflow'); - expect(firstParams?.sessionResume).toBe(true); const retryParams = taskAt(1).parameters!; expect(retryParams.sessionId).toMatch(UUID_RE); expect(retryParams.sessionId).not.toBe('sess-overflow'); expect(retryParams.sessionResume).toBeUndefined(); - // 摘要落盘 + 会话簿记:新号、sessionCount+1、lastSessionResumed=false + expect(taskAt(1).prompt).toContain('## 会话摘要(上下文溢出)'); + expect(taskAt(1).prompt).toContain('完成数据层'); expect(step.action).toBe('progress'); - expect(step.metadataUpdates?.sessionId).toBe(retryParams.sessionId); expect(step.metadataUpdates?.sessionCount).toBe(2); - expect(step.metadataUpdates?.lastSessionResumed).toBe(false); - expect(typeof step.metadataUpdates?.sessionSummary).toBe('string'); expect(step.metadataUpdates?.sessionSummary).toContain('实现登录功能'); - expect(step.metadataUpdates?.sessionSummary).toContain('完成数据层'); - // 重试 prompt 注入摘要段 - expect(taskAt(1).prompt).toContain('## 会话摘要(上下文溢出)'); - expect(taskAt(1).prompt).toContain('完成数据层'); }); it('新建会话(首步后)溢出 → 同样落盘摘要并重试一次', async () => { @@ -153,7 +144,7 @@ describe('#96: CLI 上下文溢出纯反应式策略', () => { expect(taskAt(1).prompt).toContain('## 会话摘要(上下文溢出)'); }); - it('溢出重试仍失败 → NEED_INPUT(合流既有路径):sessionSummary 保留落盘、sessionId 重置、sessionCount 计入', async () => { + it('溢出重试仍失败 → NEED_INPUT(接线冒烟;簿记重置/摘要保留/记账细节见 loop/__tests__/step-retry-policy 直测)', async () => { const wu = await setupWorkUnit({ sessionId: 'sess-overflow', sessionCount: 1 }); mockExecuteLightweight.mockResolvedValue({ ...OVERFLOW_FAILURE }); @@ -163,15 +154,13 @@ describe('#96: CLI 上下文溢出纯反应式策略', () => { expect(step.action).toBe('need_input'); expect(step.summary).toContain('上下文溢出'); // #90: 溢出重试再败转 need_input 也落 failure outcome + // (策略 recordFailureOutcome → 本类 recordOutcomeEvent → knowledgeService 的接线断言) expect(knowledgeService.recordOutcome).toHaveBeenCalledWith(expect.objectContaining({ success: false, errorType: 'execution_failed', })); - // 摘要保留(落盘供人工参考),会话簿记:sessionId 重置、sessionCount 计入 - expect(typeof step.metadataUpdates?.sessionSummary).toBe('string'); expect(step.metadataUpdates).not.toHaveProperty('sessionId'); expect(step.metadataUpdates!.sessionCount).toBe(2); - expect(step.metadataUpdates).not.toHaveProperty('lastSessionResumed'); }); it('溢出且会话配额已满(sessionCount>=MAX)→ 摘要落盘 + 直接 NEED_INPUT,不再起新会话', async () => { diff --git a/apps/api/src/modules/agents/__tests__/agent-loop-session-resume.test.ts b/apps/api/src/modules/agents/__tests__/agent-loop-session-resume.test.ts index 5c3b0d063..544c93a99 100644 --- a/apps/api/src/modules/agents/__tests__/agent-loop-session-resume.test.ts +++ b/apps/api/src/modules/agents/__tests__/agent-loop-session-resume.test.ts @@ -4,6 +4,9 @@ // 续用步报「会话不存在」→ 换发新 sessionId 降级重试一次。 // HOME 经 vi.stubEnv 指向 tmpdir 造会话文件;真实 FileStore(tmpdir)+ 真实 WorkUnitService; // CLI 执行 mock;#481:执行根经 metadata.workspaceRoot 注入(wu.workspaceId 不再参与 cwd 决策)。 +// #543:降级重试序收编 loop/step-retry-policy 后,重试细节断言(占额/簿记/prompt 重算) +// 移 loop/__tests__/step-retry-policy.test.ts 直测(fake executor),本文件只留整类构造的 +// 续用判定/形态接线与降级路径冒烟。 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; @@ -265,7 +268,7 @@ describe('#94: 会话号 per-WU 化与续用降级', () => { expect(lastTask().parameters?.sessionResume).toBeUndefined(); }); - it('续用步报「会话不存在」→ 降级:换发新 UUID 重试一次(无 sessionResume),成功后落新号 + sessionCount+1 + lastSessionResumed=false', async () => { + it('续用步报「会话不存在」→ 触发降级重试(接线冒烟;占额/簿记/prompt 重算细节见 loop/__tests__/step-retry-policy 直测)', async () => { createClaudeSessionFile('sess-lost'); const wu = await setupWorkUnit({ sessionId: 'sess-lost', sessionCount: 1 }); // 降级重试复用同一 task 对象改 parameters(#94 设计)——首次调用形态需在调用时快照 @@ -280,20 +283,18 @@ describe('#94: 会话号 per-WU 化与续用降级', () => { const step = await (agentLoop as unknown as AgentStepCapable).agentStep({ workUnit: wu }); + // 接线:首次续用形态失败 → 策略改参重试一次(新建形态)→ 成功走正常路径 expect(mockExecuteLightweight).toHaveBeenCalledTimes(2); - // 第一次:续用形态;第二次:新建形态(claude 传新 UUID、不带 sessionResume) expect(firstCallParams?.sessionId).toBe('sess-lost'); expect(firstCallParams?.sessionResume).toBe(true); const retryParams = taskAt(1).parameters!; expect(retryParams.sessionId).toMatch(UUID_RE); expect(retryParams.sessionId).not.toBe('sess-lost'); expect(retryParams.sessionResume).toBeUndefined(); - // 降级成功:metadata 落新号、sessionCount+1、lastSessionResumed=false expect(step.action).toBe('progress'); expect(step.metadataUpdates?.sessionId).toBe(retryParams.sessionId); expect(step.metadataUpdates?.sessionCount).toBe(2); - expect(step.metadataUpdates?.lastSessionResumed).toBe(false); - // 续用实际失败 → sessionResumes 不计(#94 起只计实际续用成功的步) + // 续用实际失败 → sessionResumes 不计(#94 起只计实际续用成功的步;agent-loop 据 retried outcome 翻 sessionResumed=false 的接线断言) expect(step.metadataUpdates).not.toHaveProperty('sessionResumes'); }); @@ -311,7 +312,7 @@ describe('#94: 会话号 per-WU 化与续用降级', () => { expect(step.action).toBe('failed'); }); - it('降级重试仍失败 → action=failed,sessionId/lastSessionResumed 回滚、sessionCount 计入(#95)', async () => { + it('降级重试仍失败 → action=failed(接线冒烟;簿记回滚/记账/errorType 细节见 loop/__tests__/step-retry-policy 直测)', async () => { createClaudeSessionFile('sess-lost'); const wu = await setupWorkUnit({ sessionId: 'sess-lost', sessionCount: 1 }); mockExecuteLightweight.mockResolvedValue({ @@ -325,7 +326,6 @@ describe('#94: 会话号 per-WU 化与续用降级', () => { expect(step.action).toBe('failed'); expect(step.metadataUpdates).not.toHaveProperty('sessionId'); expect(step.metadataUpdates!.sessionCount).toBe(2); - expect(step.metadataUpdates).not.toHaveProperty('lastSessionResumed'); }); it('#95: 续用降级(check 判命中、执行才发现会话丢失)→ 重试 prompt 注入前序进展段', async () => { diff --git a/apps/api/src/modules/agents/loop/__tests__/step-retry-policy.test.ts b/apps/api/src/modules/agents/loop/__tests__/step-retry-policy.test.ts new file mode 100644 index 000000000..265727122 --- /dev/null +++ b/apps/api/src/modules/agents/loop/__tests__/step-retry-policy.test.ts @@ -0,0 +1,290 @@ +// 步执行重试策略单测(step-retry-policy,#543):#94 续用丢失降级与 #96 上下文溢出 +// 两段孪生骨架收敛为一份后的显式断言点 —— 纯 ctx 对象 + 注入伪依赖(fake executor), +// 无 vi.mock 模块工厂、不整类构造 AgentLoop(对称 step-guards/completion-gates 的可测试性契约)。 +// 覆盖:触发判定(matchStepRetry)、会话配额占额(sessionCount 计入/不计入)、 +// 会话簿记回写(sessionId/lastSessionResumed/sessionSummary)、重试 task 改写形态 +// (claude 传新号 / kimi 不传、sessionResume 摘除)、prompt 重算参数、成败分叉的 +// terminal 形状(failed vs need_input)与副作用调用(recordTokenEvent/recordFailureOutcome/emitFailedStep)。 +import { describe, it, expect, vi } from 'vitest'; +import type { AgentTask, ExecutionResult } from '@dommaker/studio-agent'; +import { + matchStepRetry, + runStepRetry, + resetUnestablishedSessionBookkeeping, + MAX_SESSIONS_PER_WU, + type StepRetryCtx, + type StepRetryDeps, +} from '../step-retry-policy'; +import type { WorkUnitData, WorkUnitMetadata } from '../../../workunit/workunit.service.js'; + +const RESUME_LOST_DETAIL = 'No conversation found with session ID sess-lost'; +const OVERFLOW_DETAIL = 'Prompt is too long'; +const NEW_SESSION_ID = '11111111-2222-3333-4444-555555555555'; + +function makeWu(overrides: Partial = {}): WorkUnitData { + return { + id: 'wu-1', parentId: null, type: 'task', scope: '实现登录功能', assigneeId: 'instance-1', + status: 'active', failureType: null, retryCount: 0, timeoutAt: null, + channelId: null, projectPath: null, workspaceId: null, metadata: null, + createdAt: new Date(), updatedAt: new Date(), claimedAt: null, completedAt: null, + ...overrides, + }; +} + +function makeTask(provider: 'claude' | 'kimi' = 'claude'): AgentTask { + return { + id: 'wu-1', + executionId: 'wu-1-1', + provider, + prompt: 'ORIGINAL-PROMPT', + parameters: { sessionId: 'sess-lost', sessionResume: true, maxTurns: 50 }, + timeoutMs: 1000, + }; +} + +function failureResult(error: string): ExecutionResult { + return { + success: false, error, worktree: '/tmp/wt', outputFiles: [], logFile: '/tmp/log', sessionCount: 1, + }; +} + +const SUCCESS_RESULT: ExecutionResult = { + success: true, outputText: 'ACTION: PROGRESS:继续', + worktree: '/tmp/wt', outputFiles: [], logFile: '/tmp/log', sessionCount: 1, +}; + +function makeCtx(overrides: Partial = {}): StepRetryCtx { + return { + wu: makeWu(), + metadata: {}, + provider: 'claude', + task: makeTask(), + firstResult: failureResult(RESUME_LOST_DETAIL), + detail: RESUME_LOST_DETAIL, + sessionsUsed: 1, + resumeSessionId: 'sess-lost', + metadataUpdates: {}, + ...overrides, + }; +} + +function makeDeps(overrides: Partial = {}) { + return { + execute: vi.fn().mockResolvedValue(SUCCESS_RESULT), + recomposePrompt: vi.fn().mockResolvedValue('RECOMPOSED-PROMPT'), + recordTokenEvent: vi.fn(), + recordFailureOutcome: vi.fn(), + emitFailedStep: vi.fn(), + newSessionId: vi.fn().mockReturnValue(NEW_SESSION_ID), + ...overrides, + }; +} + +describe('matchStepRetry: 重试触发判定(顺序即优先级)', () => { + it('续用步报「会话不存在」→ session-resume-lost', () => { + expect(matchStepRetry({ resumeSessionId: 'sess-lost', detail: RESUME_LOST_DETAIL })).toBe('session-resume-lost'); + }); + + it('非续用步报「会话不存在」→ null(不触发降级,也不误判溢出)', () => { + expect(matchStepRetry({ resumeSessionId: null, detail: RESUME_LOST_DETAIL })).toBeNull(); + }); + + it('新建步报溢出错误 → context-overflow', () => { + expect(matchStepRetry({ resumeSessionId: null, detail: OVERFLOW_DETAIL })).toBe('context-overflow'); + }); + + it('续用步报溢出错误(非会话丢失类)→ context-overflow', () => { + expect(matchStepRetry({ resumeSessionId: 'sess-1', detail: OVERFLOW_DETAIL })).toBe('context-overflow'); + }); + + it('普通执行错误 → null', () => { + expect(matchStepRetry({ resumeSessionId: 'sess-1', detail: 'CLI boom' })).toBeNull(); + expect(matchStepRetry({ resumeSessionId: null, detail: 'CLI boom' })).toBeNull(); + }); +}); + +describe('runStepRetry: session-resume-lost(#94 续用丢失降级)', () => { + it('配额未满 → 换新号重试成功:task 改写新建形态、sessionCount+1、簿记落新号、prompt 按新建重算', async () => { + const ctx = makeCtx(); + const deps = makeDeps(); + + const out = await runStepRetry(ctx, deps); + + expect(deps.execute).toHaveBeenCalledTimes(1); + // task 原地改写:claude 传新 UUID、摘除 sessionResume + expect(ctx.task.parameters?.sessionId).toBe(NEW_SESSION_ID); + expect(ctx.task.parameters?.sessionResume).toBeUndefined(); + // prompt 重算:isNewSession=true(断链新会话,注入前序进展段由 composer 负责) + expect(deps.recomposePrompt).toHaveBeenCalledWith({ isNewSession: true }); + expect(ctx.task.prompt).toBe('RECOMPOSED-PROMPT'); + // 占额:降级 = 一次新建会话尝试,sessionCount = sessionsUsed + 1 + expect(ctx.metadataUpdates.sessionCount).toBe(2); + // 回写:换新号落盘 + 续用标记翻 false + expect(ctx.metadataUpdates.sessionId).toBe(NEW_SESSION_ID); + expect(ctx.metadataUpdates.lastSessionResumed).toBe(false); + // outcome:retried,交还重试结果与新号(agent-loop 走正常成功路径) + expect(out).toEqual({ kind: 'retried', result: SUCCESS_RESULT, sessionId: NEW_SESSION_ID }); + // 成功路径不重记账(重试结果由 agent-loop 成功路径统一 recordTokenEvent) + expect(deps.recordTokenEvent).not.toHaveBeenCalled(); + expect(deps.recordFailureOutcome).not.toHaveBeenCalled(); + }); + + it('配额已满 → 不起新会话:terminal need_input + failure outcome + failed 步事件,sessionCount 不增', async () => { + const ctx = makeCtx({ sessionsUsed: MAX_SESSIONS_PER_WU }); + const deps = makeDeps(); + + const out = await runStepRetry(ctx, deps); + + expect(deps.execute).not.toHaveBeenCalled(); + expect(out.kind).toBe('terminal'); + if (out.kind !== 'terminal') return; + expect(out.result.action).toBe('need_input'); + expect(out.result.summary).toContain('续用会话已丢失且会话重建已达上限'); + expect(deps.recordFailureOutcome).toHaveBeenCalledWith(RESUME_LOST_DETAIL); + expect(deps.emitFailedStep).toHaveBeenCalledWith('need_input', RESUME_LOST_DETAIL, ctx.firstResult); + expect(ctx.metadataUpdates).not.toHaveProperty('sessionCount'); + expect(ctx.metadataUpdates).not.toHaveProperty('sessionId'); + }); + + it('重试仍失败 → terminal failed(errorType/errorDetail/errorAt):重试记账、簿记重置、sessionCount 计入', async () => { + const retryFailure = failureResult('CLI boom again'); + const ctx = makeCtx(); + const deps = makeDeps({ execute: vi.fn().mockResolvedValue(retryFailure) }); + + const out = await runStepRetry(ctx, deps); + + expect(out.kind).toBe('terminal'); + if (out.kind !== 'terminal') return; + expect(out.result.action).toBe('failed'); + expect(out.result.summary).toBe('CLI 执行失败: CLI boom again'); + expect(out.result.metadataUpdates?.errorType).toBe('execution_failed'); + expect(out.result.metadataUpdates?.errorDetail).toBe('CLI boom again'); + expect(typeof out.result.metadataUpdates?.errorAt).toBe('string'); + // 失败的重试照样烧 token → 记账;失败 outcome + failed 步事件 + expect(deps.recordTokenEvent).toHaveBeenCalledWith(retryFailure); + expect(deps.recordFailureOutcome).toHaveBeenCalledWith('CLI boom again'); + expect(deps.emitFailedStep).toHaveBeenCalledWith('failed', 'CLI boom again', retryFailure); + // 新会话未建立:sessionId/lastSessionResumed 回滚,sessionCount 保留计入(#95) + expect(ctx.metadataUpdates).not.toHaveProperty('sessionId'); + expect(ctx.metadataUpdates).not.toHaveProperty('lastSessionResumed'); + expect(ctx.metadataUpdates.sessionCount).toBe(2); + }); + + it('非 claude provider(kimi cwd 维度续用)→ 重试不传 sessionId', async () => { + const ctx = makeCtx({ provider: 'kimi', task: makeTask('kimi') }); + const deps = makeDeps(); + + const out = await runStepRetry(ctx, deps); + + expect(ctx.task.parameters?.sessionId).toBeUndefined(); + expect(ctx.task.parameters?.sessionResume).toBeUndefined(); + expect(out.kind).toBe('retried'); + }); +}); + +describe('runStepRetry: context-overflow(#96 上下文溢出)', () => { + const overflowMetadata: WorkUnitMetadata = { + progressLog: [{ step: 1, action: 'progress', summary: '完成数据层', at: '2026-08-12T10:00:00Z' }], + }; + + it('重试成功:摘要落盘、prompt = 重算(isNewSession:false)+ 摘要段、sessionCount+1、簿记落新号', async () => { + const ctx = makeCtx({ + detail: OVERFLOW_DETAIL, + firstResult: failureResult(OVERFLOW_DETAIL), + resumeSessionId: null, + metadata: overflowMetadata, + }); + const deps = makeDeps(); + + const out = await runStepRetry(ctx, deps); + + expect(deps.execute).toHaveBeenCalledTimes(1); + // 摘要先于配额判定落盘(scope + progressLog) + expect(ctx.metadataUpdates.sessionSummary).toContain('实现登录功能'); + expect(ctx.metadataUpdates.sessionSummary).toContain('完成数据层'); + // prompt 重算:isNewSession=false(避免与 handoff 前序进展段重复),摘要段追加在后 + expect(deps.recomposePrompt).toHaveBeenCalledWith({ isNewSession: false }); + expect(ctx.task.prompt).toBe( + `RECOMPOSED-PROMPT\n\n## 会话摘要(上下文溢出)\n\n${ctx.metadataUpdates.sessionSummary}`, + ); + expect(ctx.metadataUpdates.sessionCount).toBe(2); + expect(ctx.metadataUpdates.sessionId).toBe(NEW_SESSION_ID); + expect(ctx.metadataUpdates.lastSessionResumed).toBe(false); + expect(out).toEqual({ kind: 'retried', result: SUCCESS_RESULT, sessionId: NEW_SESSION_ID }); + }); + + it('配额已满 → 摘要仍落盘 + terminal need_input,不再起新会话', async () => { + const ctx = makeCtx({ + detail: OVERFLOW_DETAIL, + firstResult: failureResult(OVERFLOW_DETAIL), + resumeSessionId: null, + metadata: overflowMetadata, + sessionsUsed: MAX_SESSIONS_PER_WU, + }); + const deps = makeDeps(); + + const out = await runStepRetry(ctx, deps); + + expect(deps.execute).not.toHaveBeenCalled(); + expect(typeof ctx.metadataUpdates.sessionSummary).toBe('string'); + expect(out.kind).toBe('terminal'); + if (out.kind !== 'terminal') return; + expect(out.result.action).toBe('need_input'); + expect(out.result.summary).toContain('上下文溢出且会话重建已达上限'); + expect(deps.recordFailureOutcome).toHaveBeenCalledWith(OVERFLOW_DETAIL); + expect(deps.emitFailedStep).toHaveBeenCalledWith('need_input', OVERFLOW_DETAIL, ctx.firstResult); + expect(ctx.metadataUpdates).not.toHaveProperty('sessionCount'); + }); + + it('重试仍失败 → terminal need_input(非 failed):摘要保留、簿记重置、sessionCount 计入', async () => { + const retryFailure = failureResult('Prompt is too long'); + const ctx = makeCtx({ + detail: OVERFLOW_DETAIL, + firstResult: failureResult(OVERFLOW_DETAIL), + resumeSessionId: null, + metadata: overflowMetadata, + }); + const deps = makeDeps({ execute: vi.fn().mockResolvedValue(retryFailure) }); + + const out = await runStepRetry(ctx, deps); + + expect(out.kind).toBe('terminal'); + if (out.kind !== 'terminal') return; + expect(out.result.action).toBe('need_input'); + expect(out.result.summary).toContain('上下文溢出'); + expect(out.result.summary).toContain('新会话带摘要重试一次仍失败'); + expect(deps.recordTokenEvent).toHaveBeenCalledWith(retryFailure); + expect(deps.recordFailureOutcome).toHaveBeenCalledWith('Prompt is too long'); + expect(deps.emitFailedStep).toHaveBeenCalledWith('need_input', 'Prompt is too long', retryFailure); + expect(typeof ctx.metadataUpdates.sessionSummary).toBe('string'); + expect(ctx.metadataUpdates).not.toHaveProperty('sessionId'); + expect(ctx.metadataUpdates).not.toHaveProperty('lastSessionResumed'); + expect(ctx.metadataUpdates.sessionCount).toBe(2); + }); +}); + +describe('runStepRetry: 不触发重试', () => { + it('普通执行错误 → no-retry:零副作用、零簿记改写', async () => { + const ctx = makeCtx({ detail: 'CLI boom', firstResult: failureResult('CLI boom') }); + const deps = makeDeps(); + + const out = await runStepRetry(ctx, deps); + + expect(out).toEqual({ kind: 'no-retry' }); + expect(deps.execute).not.toHaveBeenCalled(); + expect(deps.recomposePrompt).not.toHaveBeenCalled(); + expect(ctx.metadataUpdates).toEqual({}); + }); +}); + +describe('resetUnestablishedSessionBookkeeping', () => { + it('摘除 sessionId/lastSessionResumed,保留 sessionCount(失败尝试计入配额,#95)', () => { + const updates: Partial = { + sessionId: 'sess-x', lastSessionResumed: false, sessionCount: 3, + }; + resetUnestablishedSessionBookkeeping(updates); + expect(updates).not.toHaveProperty('sessionId'); + expect(updates).not.toHaveProperty('lastSessionResumed'); + expect(updates.sessionCount).toBe(3); + }); +}); diff --git a/apps/api/src/modules/agents/loop/agent-loop.ts b/apps/api/src/modules/agents/loop/agent-loop.ts index ba8e7ebc6..331b00e0e 100644 --- a/apps/api/src/modules/agents/loop/agent-loop.ts +++ b/apps/api/src/modules/agents/loop/agent-loop.ts @@ -7,6 +7,8 @@ // DELEGATE 分支(建子单 + collab 元数据 + 降级文案)→ delegate-branch.js。 // #541(2026-09):agentStep 入口四段前置守卫(B2 测试 WU / C3 日预算 / #162 WU 预算 / // #471 plan 额度)→ step-guards.js(对称出口侧 completion-gates 的 Ctx/Deps/Outcome 模式)。 +// #543(2026-09):agentStep 中段两段孪生重试骨架(#94 续用丢失降级 / #96 上下文溢出) +// → step-retry-policy.js(同 Ctx/Deps/Outcome 模式,fake executor 直测占额/簿记回写)。 // 本文件保留 AgentLoop 类编排逻辑;导出面 = AgentLoop + StepResult(#544 拆除 re-export 门面, // 测试 import 已迁真属主 agent-loop-parsers / agent-loop-events / agent-loop-guards)。 import { execSync } from 'child_process'; @@ -51,8 +53,8 @@ import { parseExcludeAssignee } from './agent-loop-guards.js'; import { judgeClaimFitness, isRoleUnfit, parseUnfitRoles } from './claim-fitness.js'; import { composeStepPrompt } from './prompt-composer.js'; import { handleDelegateBranch } from './delegate-branch.js'; -import { shouldResumeSession, RESUME_FAILURE_RE } from './session-resume.js'; -import { isContextOverflowError, buildRollingSummary, OVERFLOW_SUMMARY_HEADER } from './context-overflow.js'; +import { shouldResumeSession } from './session-resume.js'; +import { runStepRetry, resetUnestablishedSessionBookkeeping, MAX_SESSIONS_PER_WU } from './step-retry-policy.js'; import { WuLeaseTracker } from './wu-lease.js'; import { appendTranscriptStep, transcriptPath } from '../../transcripts/transcript-archive.js'; @@ -94,12 +96,8 @@ const STEP_WALL_CLOCK_MS = 1_800_000; const STEP_SILENCE_WARN_MS = 300_000; const STEP_SILENCE_KILL_MS = 600_000; -/** B5(2026-08-03 token-burn issue P1-1):每 WU 独立会话数上限(#95 由 2 放宽到 5)。 - * 会话反复重建(stuck 重开 / token 截断重开)意味着整段 transcript 全文重放重新烧一遍; - * 超限说明自动执行已失控,转 need_input 等人工评估(#94 起人工回复不再重置预算—— - * 复活后凭 metadata.sessionId 优先续用旧会话,见 waiting-input.ts)。 - * #95: 失败/超时的会话建立尝试计入预算(resetUnestablishedSession 不再清 sessionCount)。 */ -const MAX_SESSIONS_PER_WU = 5; +// B5 每 WU 会话数上限 MAX_SESSIONS_PER_WU:#543 迁到 ./step-retry-policy.js(重试配额判定 +// 真属主,语义注释随迁),本文件新建会话签发(下方 sessionsUsed 检查)经 import 复用同一常量。 /** F6-fix: 空闲分支心跳节流间隔 — agent-timeout-scan 阈值为 5min,45s 一次足够保活 */ const IDLE_HEARTBEAT_INTERVAL_MS = 45_000; @@ -1197,112 +1195,36 @@ export class AgentLoop { errorAt: new Date().toISOString(), }, }); - let detail = (result.error ?? '未知错误').slice(0, 500); + const detail = (result.error ?? '未知错误').slice(0, 500); logger.error(`[AgentLoop] agentStep execution failed for ${wu.id}: ${detail}`, { traceId }); // B6: 失败执行同样记账(CLI 已跑的轮次照样烧了 token,runner error 路径透出 usage) recordTokenEvent(result); - // #94 续用降级:仅续用步 + 「会话不存在」错误(档案 sessionId 对应会话已被清理)→ - // 换发新 sessionId 重试一次(claude 传 --session-id、不带 sessionResume)。 + // #543: #94 续用丢失降级与 #96 上下文溢出两段孪生重试骨架(配额判定 → 改参 → + // 重算 prompt → 再执行 → 成败分叉 → 簿记回写)收编到 ./step-retry-policy.js —— + // 本处只保留编排:注入 executor/prompt 重算/记账/失败事件 deps,按 outcome 分流。 // 非续用类错误(超时/业务失败)与 catch 分支(spawn 异常)不触发;每步至多烧一次重试。 - if (resumeSessionId && RESUME_FAILURE_RE.test(detail)) { - // #96: 收口 #95 降级超限 —— 续用降级重试也遵守 MAX_SESSIONS_PER_WU(删除 #94「绕过 MAX 一次」先例)。 - if (sessionsUsed >= MAX_SESSIONS_PER_WU) { - logger.warn('[AgentLoop] Resume session lost and session limit reached — need human evaluation', { - workUnitId: wu.id, sessionsUsed, max: MAX_SESSIONS_PER_WU, - }); - this.recordOutcomeEvent(wu, false, detail, 'execution_failed', injectedKnowledgeIds); - emitFailedStep('need_input', detail, result); - return { - action: 'need_input' as const, - summary: `续用会话已丢失且会话重建已达上限(${sessionsUsed}/${MAX_SESSIONS_PER_WU}):已暂停自动执行。请人工评估后回复任意内容继续,或直接关闭任务`, - metadataUpdates, - }; - } - const fallbackSessionId = randomUUID(); - logger.warn(`[AgentLoop] Resume target session lost for ${wu.id} — falling back to a new session`, { traceId }); - task.parameters!.sessionId = taskProvider === 'claude' ? fallbackSessionId : undefined; - delete task.parameters!.sessionResume; - // #95: 降级重试 = 一次新建会话尝试,成败均计入会话预算(失败/超时尝试计入) - metadataUpdates.sessionCount = sessionsUsed + 1; - // #95: 降级换新号 = 执行期续用不命中(断链新会话)——check 时 shouldResumeSession 判命中 - // 未注入前序进展,执行才发现会话丢失。重算 prompt 以注入「前序进展」段 + 回放 - // waitingQuestion(复用同一 ctx/deps;knowledge/skill 等段重复组装一次,副作用均 fire-and-forget)。 - const recomposed = await composeStepPrompt( - { wu, metadata, newReplies: target.newReplies?.map(r => r.content), isNewSession: true }, - composeDeps, - ); - task.prompt = recomposed.prompt; - const retryResult: ExecutionResult = await this.executor.execute(task); - if (retryResult.success === false) { - // 降级重试仍失败 → 既有 failed 返回;新会话未建立,重置 sessionId 但保留 sessionCount(计入) - detail = (retryResult.error ?? '未知错误').slice(0, 500); - logger.error(`[AgentLoop] agentStep fallback retry failed for ${wu.id}: ${detail}`, { traceId }); - recordTokenEvent(retryResult); - this.resetUnestablishedSession(metadataUpdates); - this.recordOutcomeEvent(wu, false, detail, 'execution_failed', injectedKnowledgeIds); - emitFailedStep('failed', detail, retryResult); - return failResult(detail); - } - // 降级成功:走正常成功路径;换新号落盘(sessionCount 已在查过 MAX 后计入) - result = retryResult; - effectiveSessionId = fallbackSessionId; - sessionResumed = false; - metadataUpdates.sessionId = fallbackSessionId; - metadataUpdates.lastSessionResumed = false; - } else if (isContextOverflowError(detail)) { - // #96: CLI 上下文溢出纯反应式策略 —— 溢出错误 → 会话滚动摘要落盘 → 新会话带摘要 - // 注入重试一次 → 再败 NEED_INPUT。溢出重试 = 一次新建会话尝试,占会话配额 - // (与 #95 失败/超时尝试计入语义一致,超限走 need_input,不静默绕过 MAX)。 - // 摘要来源 = wu.scope + progressLog(会话内逐步 summary),不递归摘要、不建语义搜索。 - const overflowSummary = buildRollingSummary(wu.scope, metadata); - metadataUpdates.sessionSummary = overflowSummary; - if (sessionsUsed >= MAX_SESSIONS_PER_WU) { - logger.warn('[AgentLoop] Context overflow and session limit reached — need human evaluation', { - workUnitId: wu.id, sessionsUsed, max: MAX_SESSIONS_PER_WU, - }); - this.recordOutcomeEvent(wu, false, detail, 'execution_failed', injectedKnowledgeIds); - emitFailedStep('need_input', detail, result); - return { - action: 'need_input' as const, - summary: `CLI 上下文溢出且会话重建已达上限(${sessionsUsed}/${MAX_SESSIONS_PER_WU}):已暂停自动执行。请人工评估后回复任意内容继续,或直接关闭任务`, - metadataUpdates, - }; - } - const overflowSessionId = randomUUID(); - logger.warn(`[AgentLoop] Context overflow for ${wu.id} — persisting rolling summary and retrying in a new session`, { traceId }); - task.parameters!.sessionId = taskProvider === 'claude' ? overflowSessionId : undefined; - delete task.parameters!.sessionResume; - metadataUpdates.sessionCount = sessionsUsed + 1; - metadataUpdates.sessionId = overflowSessionId; - metadataUpdates.lastSessionResumed = false; - // 重算 prompt 注入摘要(isNewSession:false 避免与 handoff 前序进展段重复;摘要单独注入) - const recomposed = await composeStepPrompt( - { wu, metadata, newReplies: target.newReplies?.map(r => r.content), isNewSession: false }, - composeDeps, - ); - task.prompt = `${recomposed.prompt}\n\n${OVERFLOW_SUMMARY_HEADER}\n\n${overflowSummary}`; - const retryResult: ExecutionResult = await this.executor.execute(task); - if (retryResult.success === false) { - // 再败 → NEED_INPUT(合流既有 need_input 路径);sessionSummary 保留落盘供人工参考, - // sessionId 重置、sessionCount 计入(不再三连败静默 blocked) - const retryDetail = (retryResult.error ?? '未知错误').slice(0, 500); - logger.error(`[AgentLoop] agentStep overflow retry failed for ${wu.id}: ${retryDetail}`, { traceId }); - recordTokenEvent(retryResult); - this.resetUnestablishedSession(metadataUpdates); - this.recordOutcomeEvent(wu, false, retryDetail, 'execution_failed', injectedKnowledgeIds); - emitFailedStep('need_input', retryDetail, retryResult); - return { - action: 'need_input' as const, - summary: `CLI 上下文溢出:新会话带摘要重试一次仍失败(${retryDetail.slice(0, 200)}),已暂停自动执行。请人工评估后回复任意内容继续,或直接关闭任务`, - metadataUpdates, - }; - } - // 溢出重试成功:走正常成功路径;换新号落盘 - result = retryResult; - effectiveSessionId = overflowSessionId; + const retryOutcome = await runStepRetry( + { wu, metadata, provider: taskProvider, task, firstResult: result, detail, sessionsUsed, resumeSessionId, metadataUpdates }, + { + execute: (t) => this.executor.execute(t), + recomposePrompt: async ({ isNewSession }) => (await composeStepPrompt( + { wu, metadata, newReplies: target.newReplies?.map(r => r.content), isNewSession }, + composeDeps, + )).prompt, + recordTokenEvent, + recordFailureOutcome: (d) => this.recordOutcomeEvent(wu, false, d, 'execution_failed', injectedKnowledgeIds), + emitFailedStep, + }, + ); + if (retryOutcome.kind === 'terminal') return retryOutcome.result; + if (retryOutcome.kind === 'retried') { + // 重试成功:走正常成功路径;换新号(簿记已由策略写入 metadataUpdates) + result = retryOutcome.result; + effectiveSessionId = retryOutcome.sessionId; sessionResumed = false; } else { - // 首 step 失败:会话未必已建立,重置避免下步 --resume 一个从未建立的会话 + // 非重试类失败(no-retry):首 step 失败会话未必已建立,重置避免下步 --resume + // 一个从未建立的会话 if (newSessionId) this.resetUnestablishedSession(metadataUpdates); this.recordOutcomeEvent(wu, false, detail, 'execution_failed', injectedKnowledgeIds); emitFailedStep('failed', detail, result); @@ -1528,15 +1450,16 @@ export class AgentLoop { } /** - * 首 step(新建会话)执行失败 / 续用降级重试仍失败时重置会话簿记:CLI 会话未必已建立 + * 首 step(新建会话)执行失败 / spawn 异常时重置会话簿记:CLI 会话未必已建立 * (可能根本没 spawn 到),不重置则下一步按续用发 `--resume <从未建立的 id>` * (claude 必报 "No conversation found")。续用 step 失败不调用 —— 会话已存在, * 保留下一步继续 resume。(#94:实例槽位清除已随 per-WU 化一并移除) * #95: sessionCount 不再清除 —— 失败/超时的会话建立尝试计入预算(超限转 need_input)。 + * #543: 实现唯一正本在 ./step-retry-policy.js(resetUnestablishedSessionBookkeeping), + * 重试再败路径由策略模块直接调用,本方法委托保持编排侧单一入口。 */ private resetUnestablishedSession(metadataUpdates: Partial): void { - delete metadataUpdates.sessionId; - delete metadataUpdates.lastSessionResumed; + resetUnestablishedSessionBookkeeping(metadataUpdates); } /** Record result: monitoring checkpoints + state transitions (zero token) */ diff --git a/apps/api/src/modules/agents/loop/step-retry-policy.ts b/apps/api/src/modules/agents/loop/step-retry-policy.ts new file mode 100644 index 000000000..949b244ac --- /dev/null +++ b/apps/api/src/modules/agents/loop/step-retry-policy.ts @@ -0,0 +1,212 @@ +/** + * 步执行重试策略(#543,2026-09 架构评审 A3):收编 agentStep 中段两段孪生重试骨架 —— + * #94「续用丢失降级」(续用步报「会话不存在」→ 换发新 sessionId 按新建重试一次)与 + * #96「上下文溢出重试」(溢出错误 → 滚动摘要落盘 → 新会话带摘要重试一次 → 再败 NEED_INPUT)。 + * 两段原各写一遍「配额判定 → 改参 → 重算 prompt → 再执行 → 成败分叉 → 簿记回写」, + * 本模块收敛为 runStepRetry 一份;差异(触发条件/prompt 重算参数/再败的 terminal 形状) + * 以 StepRetryKind 参数化。 + * + * 职责边界: + * - 本模块 = 重试政策(retry policy):触发判定(matchStepRetry)、会话配额占额、 + * 会话簿记回写、重试 task 改写与再执行的调用编排。 + * - agent-loop.agentStep = 编排:首次执行失败 → 调 runStepRetry → terminal 直接返回 / + * retried 改写 result 走正常成功路径 / no-retry 走既有 failed 路径。 + * + * 可测试性:executor/prompt 重算/记账/失败事件全部经 deps 注入(同 step-guards 的 + * Ctx/Deps/Outcome 模式),单测 fake executor 直测,无需整类构造 AgentLoop。 + * 重试复用 #94 设计:同一 task 对象原地改 parameters/prompt 再执行。 + */ + +import { randomUUID } from 'crypto'; +import { logger } from '@dommaker/studio-shared'; +import type { AgentTask, ExecutionResult } from '@dommaker/studio-agent'; +import type { WorkUnitData, WorkUnitMetadata } from '../../workunit/workunit.service.js'; +import type { StepResult } from './agent-loop.types.js'; +import { RESUME_FAILURE_RE } from './session-resume.js'; +import { isContextOverflowError, buildRollingSummary, OVERFLOW_SUMMARY_HEADER } from './context-overflow.js'; + +/** B5(2026-08-03 token-burn issue P1-1):每 WU 独立会话数上限(#95 由 2 放宽到 5)。 + * 会话反复重建(stuck 重开 / token 截断重开)意味着整段 transcript 全文重放重新烧一遍; + * 超限说明自动执行已失控,转 need_input 等人工评估(#94 起人工回复不再重置预算—— + * 复活后凭 metadata.sessionId 优先续用旧会话,见 waiting-input.ts)。 + * #95: 失败/超时的会话建立尝试计入预算(resetUnestablishedSession 不再清 sessionCount)。 + * #543: 常量从 agent-loop 迁入本模块(重试配额判定的真属主),agent-loop 新建签发复用。 */ +export const MAX_SESSIONS_PER_WU = 5; + +/** 重试种类:session-resume-lost = #94 续用丢失降级;context-overflow = #96 溢出重试 */ +export type StepRetryKind = 'session-resume-lost' | 'context-overflow'; + +/** + * 重试触发判定(纯函数)。顺序即优先级: + * 1. 续用步(resumeSessionId 在场)+「会话不存在」类错误 → session-resume-lost。 + * 非续用步报同款错误不触发降级(会话本就不是续用目标,无链可断)。 + * 2. 溢出类错误(续用/新建步皆可)→ context-overflow。 + * 其余错误(超时/业务失败)→ null,走既有 failed 路径;catch 分支(spawn 异常)不进本函数。 + */ +export function matchStepRetry(input: { resumeSessionId: string | null; detail: string }): StepRetryKind | null { + if (input.resumeSessionId && RESUME_FAILURE_RE.test(input.detail)) return 'session-resume-lost'; + if (isContextOverflowError(input.detail)) return 'context-overflow'; + return null; +} + +/** 重试策略输入。task 与 metadataUpdates 为原地改写对象(#94 设计 + 簿记断言点)。 */ +export interface StepRetryCtx { + wu: WorkUnitData; + metadata: WorkUnitMetadata; + /** profile provider(registry id)——仅 claude 给 CLI 传新 sessionId(kimi/codex/opencode 续用语义不传) */ + provider: string; + /** 首次执行的 task(重试时原地改 parameters.sessionId/sessionResume 与 prompt 后再执行) */ + task: AgentTask; + /** 首次失败结果(budget-exceeded terminal 的 emitFailedStep 携带) */ + firstResult: ExecutionResult; + /** 首次失败 detail(已截 500 字符) */ + detail: string; + /** 本步前已用会话数(metadata.sessionCount ?? 有 sessionId 按 1 计,agent-loop 口径) */ + sessionsUsed: number; + /** 本步续用目标(null = 新建步) */ + resumeSessionId: string | null; + /** 本步簿记增量(原地改写:占额 sessionCount / 回写 sessionId/lastSessionResumed/sessionSummary) */ + metadataUpdates: Partial; +} + +/** 重试策略外部依赖(agent-loop 绑定注入;单测整体注入伪实现)。 */ +export interface StepRetryDeps { + /** 再执行(agent-loop 注入 this.executor.execute;fake executor 注入形状 = 本签名) */ + execute: (task: AgentTask) => Promise; + /** prompt 重算(agent-loop 注入 composeStepPrompt 的同款 ctx/deps 调用,只回传 prompt 文本) */ + recomposePrompt: (opts: { isNewSession: boolean }) => Promise; + /** B6 token 记账(仅重试再败时调用;重试成功由 agent-loop 成功路径统一记账) */ + recordTokenEvent: (res: ExecutionResult) => unknown; + /** #90 failure outcome 落盘(agent-loop 绑死 success=false + errorType='execution_failed' + 注入知识 ids) */ + recordFailureOutcome: (detail: string) => void; + /** #172 失败步 execution_step 事件(agent-loop 闭包,读取 effectiveSessionId/sessionResumed 当前值) */ + emitFailedStep: (action: 'failed' | 'need_input', detail: string, res?: ExecutionResult) => void; + /** 新会话号签发(默认 randomUUID;单测注入定值) */ + newSessionId?: () => string; + /** 会话配额上限(默认 MAX_SESSIONS_PER_WU;单测可注入小值) */ + maxSessionsPerWu?: number; +} + +export type StepRetryOutcome = + /** 未触发重试:agent-loop 走既有 failed 路径 */ + | { kind: 'no-retry' } + /** 重试成功:agent-loop 以 result 替换首败结果、sessionId 改写有效会话号、sessionResumed=false,走正常成功路径 */ + | { kind: 'retried'; result: ExecutionResult; sessionId: string } + /** 重试序终结(配额满 / 再败):agentStep 直接返回该 StepResult */ + | { kind: 'terminal'; result: StepResult }; + +/** + * 首 step(新建会话)执行失败 / 降级重试仍失败时重置会话簿记:CLI 会话未必已建立 + * (可能根本没 spawn 到),不重置则下一步按续用发 `--resume <从未建立的 id>` + * (claude 必报 "No conversation found")。续用 step 失败不调用 —— 会话已存在, + * 保留下一步继续 resume。 + * #95: sessionCount 不清除 —— 失败/超时的会话建立尝试计入预算(超限转 need_input)。 + * #543: 从 agent-loop 私有方法提为模块级纯函数(重试再败路径的簿记断言点), + * agent-loop 的 resetUnestablishedSession 委托本函数(单一正本)。 + */ +export function resetUnestablishedSessionBookkeeping(metadataUpdates: Partial): void { + delete metadataUpdates.sessionId; + delete metadataUpdates.lastSessionResumed; +} + +/** + * 重试序统一骨架(两段孪生块的收敛体): + * 触发判定 → [溢出:摘要先落盘] → 配额判定(满 → terminal need_input,不起新会话)→ + * 签发新号 + task 改新建形态 + sessionCount 计入 → prompt 重算(降级 isNewSession:true; + * 溢出 isNewSession:false + 追加摘要段)→ 再执行 → + * 再败(重置簿记 + 记账 + failure outcome + failed 步事件 → terminal;降级=failed,溢出=need_input)/ + * 成功(簿记落新号 → retried)。 + */ +export async function runStepRetry(ctx: StepRetryCtx, deps: StepRetryDeps): Promise { + const kind = matchStepRetry({ resumeSessionId: ctx.resumeSessionId, detail: ctx.detail }); + if (!kind) return { kind: 'no-retry' }; + + const { wu, metadata, task, metadataUpdates } = ctx; + const maxSessions = deps.maxSessionsPerWu ?? MAX_SESSIONS_PER_WU; + + // #96: 溢出先落滚动摘要(配额满转人工时也保留供人工参考);来源 = wu.scope + progressLog, + // 不递归摘要、不建语义搜索。摘要字段与 buildRollingSummary 单一正本在 context-overflow.ts。 + let overflowSummary: string | null = null; + if (kind === 'context-overflow') { + overflowSummary = buildRollingSummary(wu.scope, metadata); + metadataUpdates.sessionSummary = overflowSummary; + } + + // 配额判定(#96 收口 #95 降级超限先例):两种重试都 = 一次新建会话尝试,遵守 + // MAX_SESSIONS_PER_WU,超限 terminal need_input 转人工,不静默绕过 MAX。 + if (ctx.sessionsUsed >= maxSessions) { + const summary = kind === 'session-resume-lost' + ? `续用会话已丢失且会话重建已达上限(${ctx.sessionsUsed}/${maxSessions}):已暂停自动执行。请人工评估后回复任意内容继续,或直接关闭任务` + : `CLI 上下文溢出且会话重建已达上限(${ctx.sessionsUsed}/${maxSessions}):已暂停自动执行。请人工评估后回复任意内容继续,或直接关闭任务`; + logger.warn(`[AgentLoop] ${kind === 'session-resume-lost' ? 'Resume session lost' : 'Context overflow'} and session limit reached — need human evaluation`, { + workUnitId: wu.id, sessionsUsed: ctx.sessionsUsed, max: maxSessions, + }); + deps.recordFailureOutcome(ctx.detail); + deps.emitFailedStep('need_input', ctx.detail, ctx.firstResult); + return { kind: 'terminal', result: { action: 'need_input' as const, summary, metadataUpdates } }; + } + + const fallbackSessionId = (deps.newSessionId ?? randomUUID)(); + logger.warn( + kind === 'session-resume-lost' + ? `[AgentLoop] Resume target session lost for ${wu.id} — falling back to a new session` + : `[AgentLoop] Context overflow for ${wu.id} — persisting rolling summary and retrying in a new session`, + ); + task.parameters!.sessionId = ctx.provider === 'claude' ? fallbackSessionId : undefined; + delete task.parameters!.sessionResume; + // 重试 = 一次新建会话尝试,成败均计入会话预算(#95 失败/超时尝试计入语义) + metadataUpdates.sessionCount = ctx.sessionsUsed + 1; + + // prompt 重算(复用 agent-loop 同一 ctx/deps;knowledge/skill 等段重复组装一次, + // 副作用均 fire-and-forget): + // - 降级:isNewSession=true —— 断链新会话(check 判命中、执行才发现丢失,未注入前序进展), + // 重算注入「前序进展」段 + 回放 waitingQuestion; + // - 溢出:isNewSession=false(避免与 handoff 前序进展段重复),摘要段单独追加。 + const recomposedPrompt = await deps.recomposePrompt({ isNewSession: kind === 'session-resume-lost' }); + task.prompt = kind === 'context-overflow' + ? `${recomposedPrompt}\n\n${OVERFLOW_SUMMARY_HEADER}\n\n${overflowSummary}` + : recomposedPrompt; + + const retryResult: ExecutionResult = await deps.execute(task); + if (retryResult.success === false) { + const retryDetail = (retryResult.error ?? '未知错误').slice(0, 500); + logger.error(`[AgentLoop] agentStep ${kind === 'session-resume-lost' ? 'fallback' : 'overflow'} retry failed for ${wu.id}: ${retryDetail}`); + deps.recordTokenEvent(retryResult); + // 新会话未建立:sessionId/lastSessionResumed 回滚;sessionCount 保留计入(#95); + // 溢出的 sessionSummary 保留落盘供人工参考 + resetUnestablishedSessionBookkeeping(metadataUpdates); + deps.recordFailureOutcome(retryDetail); + if (kind === 'session-resume-lost') { + // 降级再败 → 既有 failed 返回(记 consecutiveStuck,连续 3 次走 blocked 里程碑) + deps.emitFailedStep('failed', retryDetail, retryResult); + return { + kind: 'terminal', + result: { + action: 'failed' as const, + summary: `CLI 执行失败: ${retryDetail}`, + metadataUpdates: { + ...metadataUpdates, + errorType: 'execution_failed', + errorDetail: retryDetail, + errorAt: new Date().toISOString(), + }, + }, + }; + } + // 溢出再败 → NEED_INPUT(合流既有 need_input 路径,不再三连败静默 blocked) + deps.emitFailedStep('need_input', retryDetail, retryResult); + return { + kind: 'terminal', + result: { + action: 'need_input' as const, + summary: `CLI 上下文溢出:新会话带摘要重试一次仍失败(${retryDetail.slice(0, 200)}),已暂停自动执行。请人工评估后回复任意内容继续,或直接关闭任务`, + metadataUpdates, + }, + }; + } + + // 重试成功:换新号落盘(sessionCount 已在查过 MAX 后计入),agent-loop 走正常成功路径 + metadataUpdates.sessionId = fallbackSessionId; + metadataUpdates.lastSessionResumed = false; + return { kind: 'retried', result: retryResult, sessionId: fallbackSessionId }; +} From 35ee2901a8917bfb4a6e5c66fc41e36d9d653c8c Mon Sep 17 00:00:00 2001 From: dommaker Date: Wed, 16 Sep 2026 00:38:09 +0800 Subject: [PATCH 021/116] =?UTF-8?q?refactor(web):=20=E9=97=B8=E9=97=A8?= =?UTF-8?q?=E5=86=99=E8=B7=AF=E5=BE=84=E4=BA=94=E5=A4=84=E9=87=8D=E6=8A=84?= =?UTF-8?q?=E6=94=B6=E5=8F=A3=E4=B8=BA=E5=8D=95=E4=B8=80=E5=86=99=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=20gateWriter=20(#545)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 2026-09-15-web-gate-write-module(架构评审候选 B1)落地: - utils/gateWriter.ts = 唯一写模块(纯函数):一次 API 调用 + 响应体快照 双写落点(channelWorkStore.applyWorkunitSnapshot + markSuggestionsDirty + workunitStore.applyWorkunitEvent insertIfMissing:false,冻结契约镜像 status_changed SSE 路由)+ 宿主可选 sink onUpdated - WuGateActions 直消费写模块,onReviewPassed/onReviewRejected/onConfirmPending 三注入 props 删除;宿主只经 onUpdated 登记本地落点(drawer/详情页 setWu) - 旧路径全删不留委托:workunitStore 闸门三动作(API+全量重拉)、 channelWorkStore.applyGateResult(applyWorkunitSnapshot 裸副本)、 ChannelWorkBar gate prop、ChannelDetailPage workBarGate 接线 - ListPage 闸门动作从全量重拉变存量行 upsert(收口本体行为变化) - 写路径模块级测一次(gateWriter.test.ts),五处宿主测试退化为渲染级断言 --- apps/web/src/CONTEXT.md | 3 +- .../src/components/channel/ChannelWorkBar.tsx | 20 +-- .../src/components/channel/WorkUnitDrawer.tsx | 12 +- .../channel/__tests__/ChannelWorkBar.test.tsx | 52 +++--- .../channel/__tests__/WorkUnitDrawer.test.tsx | 9 ++ apps/web/src/components/pmo/DeliveryPanel.tsx | 8 +- .../src/components/workunit/WuGateActions.tsx | 38 ++--- .../workunit/__tests__/WuGateActions.test.tsx | 153 ++++++++++-------- apps/web/src/pages/ChannelDetailPage.tsx | 32 +--- apps/web/src/pages/WorkUnitDetailPage.tsx | 10 +- apps/web/src/pages/WorkUnitListPage.tsx | 23 +-- .../__tests__/WorkUnitDetailPage.test.tsx | 28 +++- .../pages/__tests__/WorkUnitListPage.test.tsx | 38 +++-- .../stores/__tests__/channelWorkStore.test.ts | 11 -- .../stores/__tests__/workunitStore.test.ts | 37 +---- apps/web/src/stores/channelWorkStore.ts | 14 +- apps/web/src/stores/workunitStore.ts | 21 +-- .../src/utils/__tests__/gateWriter.test.ts | 116 +++++++++++++ apps/web/src/utils/gateWriter.ts | 43 +++++ docs/adr/2026-09-15-web-gate-write-module.md | 2 +- 20 files changed, 387 insertions(+), 283 deletions(-) create mode 100644 apps/web/src/utils/__tests__/gateWriter.test.ts create mode 100644 apps/web/src/utils/gateWriter.ts diff --git a/apps/web/src/CONTEXT.md b/apps/web/src/CONTEXT.md index d36e9cbcc..7f0df42de 100644 --- a/apps/web/src/CONTEXT.md +++ b/apps/web/src/CONTEXT.md @@ -22,11 +22,12 @@ Web 前端主源码。路由、全局状态、API 客户端、UI 组件、样式 | `useGatedPoll` | `hooks/useGatedPoll.ts` | 共享门禁轮询(#313):挂载首拉 + 仅当(visible ∧ SSE≠connected)按 interval 轮询 + 回 visible 立即补拉 | | `useAsyncData` | `hooks/useAsyncData.ts` | 一次性拉取共享 hook(#350,与 useGatedPoll 互补):data/loading/error + `reload()`(重拉保留旧数据、即清 error)+ `setData`(SSE 就地更新等本地修补);deps 渲染期重置 + 微任务推迟首拉 + alive 守卫拒迟到响应;返回值 useMemo 稳身份,可直接进 effect deps(SSE 订阅类) | | `useWorkUnitStreamEvents` | `hooks/` | WU 步内流式订阅(Layer B) | +| `createGateWriter` | `utils/gateWriter.ts` | WU 闸门写路径唯一正本(#545,ADR 2026-09-15-web-gate-write-module):「闸门转移 = 一次 API 调用 + 响应体 WU 快照双写落点」——channelWorkStore.applyWorkunitSnapshot + markSuggestionsDirty + workunitStore.applyWorkunitEvent(insertIfMissing:false)(冻结契约,镜像 status_changed SSE 路由;主动写与被动写 #549 同口径);reviewPassed/reviewRejected/confirmPending 签名单点承载(confirm 载荷加字段只动这里)。WuGateActions 直消费(三注入 props 已删),宿主本地落点 = 可选 sink onUpdated(drawer/详情页 setWu);DeliveryPanel 直调同一核心。纯函数非 hook,只管写路径 | | `rosterStore` | `stores/rosterStore.ts` | roster 数据面(#346):三端点 TTL 缓存 + single-flight 去重(#403 起纪律走 `stores/fetchDiscipline.ts` 底座;含 `activeAgentsOf` 客户端切片助手——全量正本 → status=active 切片,默认排除 studio)、instance/workunit status_changed SSE 就地更新唯一一份(未匹配合成条目)、快照补查写回;切片独立(summary 403 → forbidden,profiles/channels 照常) | | `fetchDiscipline` | `stores/fetchDiscipline.ts` | 取数纪律底座(#403,ADR 2026-08-31 决策 6):`createFetchGate`(seq 守卫)+ `disciplinedFetch`(TTL 锚点 / single-flight 并入 / force 不并入在途 / bypassTtl 只跳 TTL / inflight 只清自己)。只抽数取纪律不抽数据存法——rosterStore(全局单份)与 channelDataStore(per-key map)各自组合 | | `useDataPlaneSync` | `hooks/useDataPlaneSync.ts` | 数据面接线底座(#403):引用计数单例 SSE 路由(handleEvent 须引用稳定)+ onReconnect 强刷(maxAgeMs 0,single-flight 收敛)+ useGatedPoll 兜底;useRosterStoreSync 迁移其上(只剩 roster 事件路由),新数据面 store 接线一律复用,禁止逐 store 复印机制 | | `channelDataStore` | `stores/channelDataStore.ts` | 频道数据面 store(#403,ADR 2026-08-31):per-channelId 三切片——文件词表(FIFO 驻留上限 10)/ current-pmo / 成员 ID 列表(源自频道记录:页面水合写穿 + 面板修改成功本地写穿);纪律走 fetchDiscipline,(slice, channelId) 粒度各自 TTL 30s / 单飞 / seq。新鲜度 = TTL + 白捡触发器(requirement.created/updated → invalidateCurrentPmo),无 SSE 失效事件;agent 档案不进本 store(rosterStore.listAllAgents 是全量正本,「members 为空 → 全部 active」回退在消费方) | -| `channelWorkStore` / `useChannelWorkStoreSync` | `stores/channelWorkStore.ts` / `hooks/useChannelWorkStoreSync.ts` | 频道工作面数据面 store(#528,ADR 2026-08-31 模式;与 channelDataStore 并列——TTL 型慢变数据与事件驱动数据不同质):per-channelId 三 slice(wus / reqs / suggestions+currentWuId 同响应同 slice,resolved 旗标 = #488 台账等价替代)+ per-wuId wuChangedFiles(失败记 [] 不重试);wus/reqs 30s TTL 走 fetchDiscipline,suggestions 无 TTL 纯事件驱动(500ms 防抖内化 store);SSE 只路由活跃频道(页面级单消费方,activeChannelId 模块级登记):status_changed 全量快照直替(编造时间戳已删)/ requirement.* envelope 解包 upsert / message_sent 标脏;闸门动作 applyGateResult write-through;workunit:removed(#538 删除出声,负载 { id, channelId },按活跃频道路由)→ applyWorkunitRemoved 删行;ChannelDetailPage 退回纯订阅者(4 份 state/手写订阅/防抖/重连对齐全删)。模块单例——页面测试 beforeEach 须 `__resetForTests()`(TTL 锚点/已打底 slice 跨用例短路首拉,ChannelDetailPage.test.tsx 顶部有样板) | +| `channelWorkStore` / `useChannelWorkStoreSync` | `stores/channelWorkStore.ts` / `hooks/useChannelWorkStoreSync.ts` | 频道工作面数据面 store(#528,ADR 2026-08-31 模式;与 channelDataStore 并列——TTL 型慢变数据与事件驱动数据不同质):per-channelId 三 slice(wus / reqs / suggestions+currentWuId 同响应同 slice,resolved 旗标 = #488 台账等价替代)+ per-wuId wuChangedFiles(失败记 [] 不重试);wus/reqs 30s TTL 走 fetchDiscipline,suggestions 无 TTL 纯事件驱动(500ms 防抖内化 store);SSE 只路由活跃频道(页面级单消费方,activeChannelId 模块级登记):status_changed 全量快照直替(编造时间戳已删)/ requirement.* envelope 解包 upsert / message_sent 标脏;闸门动作经 gateWriter sink 走同一 applyWorkunitSnapshot 落点(#545,applyGateResult 已删);workunit:removed(#538 删除出声,负载 { id, channelId },按活跃频道路由)→ applyWorkunitRemoved 删行;ChannelDetailPage 退回纯订阅者(4 份 state/手写订阅/防抖/重连对齐全删)。模块单例——页面测试 beforeEach 须 `__resetForTests()`(TTL 锚点/已打底 slice 跨用例短路首拉,ChannelDetailPage.test.tsx 顶部有样板) | | `useRosterStoreSync` | `hooks/useRosterStoreSync.ts` | rosterStore 实时接线(#346;#403 起机制抽至 useDataPlaneSync,本文件只剩 roster 事件路由与参数) | | `pmoDataStore` | `stores/pmoDataStore.ts` | company/project 数据面 store(#456):companies 全局单份 + projects per-companyId map(口径统一 limit=100,原 PMOPage 20 与 Library/FileRefChip 100 漂移收口);纪律走 fetchDiscipline(TTL 30s / 单飞 / seq),无 SSE 失效事件(ADR 决策 3),新鲜度 = TTL + 重连强刷 + 兜底轮询;错误落 *Error 状态(PMOPage 错误条用)、ensure 永不 reject、失败不落锚点。PmoProject = components/pmo/types.Project + gitRepo/deliveries(FileRefChip 匹配字段) | | `usePmoDataStoreSync` | `hooks/usePmoDataStoreSync.ts` | pmoDataStore 接线(#456):useDataPlaneSync 复用,无本域 SSE 事件 → noop handleEvent;ensureFresh = companies + 已驻留 projects 键全量对齐。App 级单点挂载(App.tsx PmoDataSync,同 RequirementChainSync 模式) | diff --git a/apps/web/src/components/channel/ChannelWorkBar.tsx b/apps/web/src/components/channel/ChannelWorkBar.tsx index b1a4bb82e..896e7a1a4 100644 --- a/apps/web/src/components/channel/ChannelWorkBar.tsx +++ b/apps/web/src/components/channel/ChannelWorkBar.tsx @@ -12,8 +12,8 @@ // 阶段语义 = deriveDisplayState 展示列(与 WU 详情页同口径,不发明第二套阶段模型); // useChannelLiveExecutions 由本组件自持有(沿用 #322 重渲边界:step 事件只重渲本组件)。 // 批次 D-2 项6(频道内闸门 1 击化,docs/plans/2026-09-ui-interaction-polish.md):currentWu 处于闸门态 -// (pending / in_review / done 缺 l3)且调用方注入 gate 写路径时,工作条右端直挂共享 WuGateActions -// (btn-sm 紧凑动作,不离开消息流完成闸门);写路径与锁存/弹窗逻辑全在 WuGateActions 与调用方,本组件只挂载。 +// (pending / in_review / done 缺 l3)时工作条右端直挂共享 WuGateActions(btn-sm 紧凑动作,不离开消息流 +// 完成闸门);#545 起写路径内建于 WuGateActions(gateWriter 双写落点),本组件只挂载。 import { useState } from 'react'; import { deriveDisplayState, parseAttestations } from '@dommaker/studio-shared/web'; import type { WorkUnit } from '../../api/workunit'; @@ -23,13 +23,10 @@ import { buildLifecycle, type WuStation } from '../../utils/wuLifecycle'; import { parseWuMeta } from '../../utils/wuMeta'; import type { LiveExecution } from '../workunit/execution-rows'; import { StationStepper } from '../workunit/StationStepper'; -import { WuGateActions, type WuGateActionsProps } from '../workunit/WuGateActions'; +import { WuGateActions } from '../workunit/WuGateActions'; // stepper 样式类(wu-stepper-bar/wu-bstep/wu-st-*)定义在 wu-detail.css,顶层作用域可直接复用 import '../../styles/wu-detail.css'; -/** D-2 项6:工作条闸门动作写路径(调用方注入,口径同 WuGateActions 既有三处挂载) */ -export type WorkBarGateHandlers = Pick; - interface Props { channelId: string | null; /** 频道当前工单(端点 currentWuId × channelWus;未命中 → null,主区 fail-closed 不渲染) */ @@ -39,8 +36,6 @@ interface Props { /** #488:空闲信号——调用方保证语义 = 建议端点已成功返回且 currentWuId=null(频道确实无工作); * 缺省 false → 占位保持加载态「状态同步中…」(端点未返回/请求失败/skew 未命中均不误显空闲) */ wuIdle?: boolean; - /** D-2 项6:闸门动作写路径;传入且 currentWu 处于闸门态时工作条右端渲染 WuGateActions */ - gate?: WorkBarGateHandlers; } /** live 条目文案:WU 短 id + 正在执行 + 步号(缺省不显)+ 动作(缺省不显),沿用 #242 口径 */ @@ -61,7 +56,7 @@ function LiveItem({ exec, onOpenWorkUnit }: { exec: LiveExecution; onOpenWorkUni ); } -export function ChannelWorkBar({ channelId, currentWu, onOpenWorkUnit, gate, wuIdle = false }: Props) { +export function ChannelWorkBar({ channelId, currentWu, onOpenWorkUnit, wuIdle = false }: Props) { const liveExecs = useChannelLiveExecutions(channelId); const [overflowOpen, setOverflowOpen] = useState(false); // #474:未命中时不再整条静默消失——留占位条;#488:wuIdle 区分加载/空闲文案 @@ -127,10 +122,11 @@ export function ChannelWorkBar({ channelId, currentWu, onOpenWorkUnit, gate, wuI )} {/* D-2 项6:闸门态 currentWu 的 1 击处置位(共享 WuGateActions;pending 锁存/失败内联/结构化 - 确认弹窗均为组件自带;动作成功后调用方写路径更新 channelWus,status_changed SSE 兜底) */} - {gate && gateState && currentWu && ( + 确认弹窗均为组件自带;#545 起写路径内建 gateWriter——store 双写更新 channelWus, + status_changed SSE 兜底) */} + {gateState && currentWu && (
- +
)} diff --git a/apps/web/src/components/channel/WorkUnitDrawer.tsx b/apps/web/src/components/channel/WorkUnitDrawer.tsx index cf0aebb95..b8d7a40e3 100644 --- a/apps/web/src/components/channel/WorkUnitDrawer.tsx +++ b/apps/web/src/components/channel/WorkUnitDrawer.tsx @@ -243,16 +243,10 @@ function WuDetail({ id, autoApprove = false, autoRuling = false, onOpenReq }: { const totalSum = (tokens ?? []).reduce((s, t) => s + t.totalTokens, 0); const maxBar = Math.max(totalSum, 1); - /** E2-4:闸门动作写路径 = 直调 API + 响应体直替本地 wu(决策 8;状态变化另有 status_changed SSE 兜底), - * 分支/锁存/错误内联/弹窗全部在共享 WuGateActions(与列表行/详情页同一组件) */ + /** E2-4:闸门动作写路径 #545 起内建于共享 WuGateActions(gateWriter 双写落点 + onUpdated 直替本地 wu; + * 决策 8:状态变化另有 status_changed SSE 兜底);分支/锁存/错误内联/弹窗全部组件自带 */ const gateActions = ( - { const r = await workunitApi.reviewPassed(id, summary, assigneeId, confirm); setWu(r.data); }} - onReviewRejected={async (reason) => { const r = await workunitApi.reviewRejected(id, reason); setWu(r.data); }} - onConfirmPending={async () => { const r = await workunitApi.transitionStatus(id, 'unassigned'); setWu(r.data); }} - /> + ); return ( diff --git a/apps/web/src/components/channel/__tests__/ChannelWorkBar.test.tsx b/apps/web/src/components/channel/__tests__/ChannelWorkBar.test.tsx index 1b13f4ad0..3f103a41a 100644 --- a/apps/web/src/components/channel/__tests__/ChannelWorkBar.test.tsx +++ b/apps/web/src/components/channel/__tests__/ChannelWorkBar.test.tsx @@ -4,14 +4,26 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -const { mockUseChannelLiveExecutions } = vi.hoisted(() => ({ +const { mockUseChannelLiveExecutions, mockReviewPassed, mockReviewRejected, mockConfirmPending } = vi.hoisted(() => ({ mockUseChannelLiveExecutions: vi.fn(), + mockReviewPassed: vi.fn(), + mockReviewRejected: vi.fn(), + mockConfirmPending: vi.fn(), })); vi.mock('../../../hooks/useChannelLiveExecutions', () => ({ useChannelLiveExecutions: (channelId: string | null) => mockUseChannelLiveExecutions(channelId), })); +// #545:写路径内建 gateWriter——本文件 mock 写模块,工作条只断「闸门态挂载 WuGateActions + 调用形状」 +vi.mock('../../../utils/gateWriter', () => ({ + createGateWriter: () => ({ + reviewPassed: mockReviewPassed, + reviewRejected: mockReviewRejected, + confirmPending: mockConfirmPending, + }), +})); + import { ChannelWorkBar } from '../ChannelWorkBar'; import type { WorkUnit } from '../../../api/workunit'; @@ -207,31 +219,23 @@ describe('ChannelWorkBar — 频道工作条', () => { expect(onOpenWorkUnit).toHaveBeenCalledWith('WU-1'); }); - // D-2 项6:currentWu 闸门态 → 工作条直挂共享 WuGateActions(写路径经 gate prop 注入) - const gateHandlers = () => ({ - onReviewPassed: vi.fn().mockResolvedValue({}), - onReviewRejected: vi.fn().mockResolvedValue({}), - onConfirmPending: vi.fn().mockResolvedValue({}), - }); - - it('D-2 项6:pending currentWu + gate → 工作条渲染「确认并开放领取」,点击调 onConfirmPending', async () => { + // D-2 项6:currentWu 闸门态 → 工作条直挂共享 WuGateActions(#545 起写路径内建 gateWriter,无需注入) + it('D-2 项6:pending currentWu → 工作条渲染「确认并开放领取」,点击调 gateWriter.confirmPending', async () => { mockUseChannelLiveExecutions.mockReturnValue([]); - const gate = gateHandlers(); - const { container } = render( {}} gate={gate} />); + const { container } = render( {}} />); const btn = screen.getByRole('button', { name: '确认并开放领取' }); expect(btn.className).toContain('btn-sm'); expect(container.querySelector('.mc-workbar-gate')).not.toBeNull(); fireEvent.click(btn); - await waitFor(() => expect(gate.onConfirmPending).toHaveBeenCalledTimes(1)); - expect(gate.onReviewPassed).not.toHaveBeenCalled(); + await waitFor(() => expect(mockConfirmPending).toHaveBeenCalledWith('WU-1')); + expect(mockReviewPassed).not.toHaveBeenCalled(); }); - it('D-2 项6:in_review currentWu(task 类型)→ 渲染「通过验收/拒绝」,通过直调 onReviewPassed', async () => { + it('D-2 项6:in_review currentWu(task 类型)→ 渲染「通过验收/拒绝」,通过直调 gateWriter.reviewPassed', async () => { mockUseChannelLiveExecutions.mockReturnValue([]); - const gate = gateHandlers(); - render( {}} gate={gate} />); + render( {}} />); fireEvent.click(screen.getByRole('button', { name: '通过验收' })); - await waitFor(() => expect(gate.onReviewPassed).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(mockReviewPassed).toHaveBeenCalledWith('WU-1')); expect(screen.getByRole('button', { name: '拒绝' })).toBeTruthy(); }); @@ -241,7 +245,7 @@ describe('ChannelWorkBar — 频道工作条', () => { attestations: { l2: { verdict: 'approved', by: 'rev', at: '2026-09-01T01:30:00Z', kind: 'agent-review' } }, }); const { unmount } = render( - {}} gate={gateHandlers()} />, + {}} />, ); expect(screen.getByRole('button', { name: '人工验收确认' })).toBeTruthy(); unmount(); @@ -253,20 +257,16 @@ describe('ChannelWorkBar — 频道工作条', () => { }, }); const { container } = render( - {}} gate={gateHandlers()} />, + {}} />, ); expect(container.querySelector('.mc-workbar-gate')).toBeNull(); }); - it('D-2 项6:active currentWu(非闸门态)→ 不渲染闸门区;无 gate prop → 闸门态也不渲染', () => { + it('D-2 项6:active currentWu(非闸门态)→ 不渲染闸门区', () => { mockUseChannelLiveExecutions.mockReturnValue([]); - const { container, unmount } = render( - {}} gate={gateHandlers()} />, + const { container } = render( + {}} />, ); expect(container.querySelector('.mc-workbar-gate')).toBeNull(); - unmount(); - - const { container: c2 } = render( {}} />); - expect(c2.querySelector('.mc-workbar-gate')).toBeNull(); }); }); diff --git a/apps/web/src/components/channel/__tests__/WorkUnitDrawer.test.tsx b/apps/web/src/components/channel/__tests__/WorkUnitDrawer.test.tsx index 8dc57c11d..2ecb85bb2 100644 --- a/apps/web/src/components/channel/__tests__/WorkUnitDrawer.test.tsx +++ b/apps/web/src/components/channel/__tests__/WorkUnitDrawer.test.tsx @@ -44,6 +44,15 @@ vi.mock('../../../api/requirements', () => ({ requirementApi: { getChain: mockGetChain }, })); +// #545:gateWriter 的 store 双写落点在模块级测试单点覆盖;本文件 stub 掉两个 store, +// 防真实 markSuggestionsDirty 防抖定时器跨测悬挂(API 透传 + onUpdated 直替仍走真实 gateWriter) +vi.mock('../../../stores/channelWorkStore', () => ({ + useChannelWorkStore: { getState: () => ({ applyWorkunitSnapshot: vi.fn(), markSuggestionsDirty: vi.fn() }) }, +})); +vi.mock('../../../stores/workunitStore', () => ({ + useWorkUnitStore: { getState: () => ({ applyWorkunitEvent: vi.fn() }) }, +})); + vi.mock('../../../api/monitoring', () => ({ monitoringApi: { getOverhead: mockGetOverhead, diff --git a/apps/web/src/components/pmo/DeliveryPanel.tsx b/apps/web/src/components/pmo/DeliveryPanel.tsx index 9fc93d4c6..3f1250766 100644 --- a/apps/web/src/components/pmo/DeliveryPanel.tsx +++ b/apps/web/src/components/pmo/DeliveryPanel.tsx @@ -9,6 +9,7 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { projectApi, type DeliveryStatus, type DeliveryGap } from '../../api'; import { workunitApi, type ReviewConfirmPayload } from '../../api/workunit'; +import { createGateWriter } from '../../utils/gateWriter'; import { formatFullTime } from '../../utils/datetime'; import { toast } from '../../utils/toast'; import { AnalysisApproveDialog } from './AnalysisApproveDialog'; @@ -58,6 +59,9 @@ export function DeliveryPanel({ projectId, delivery, onRefresh }: DeliveryPanelP setApproveGap({ gap, prefill, channelId }); }; + // #545:闸门写路径收口进 gateWriter(gaps 非 WU 快照宿主,无 sink——成功后 onRefresh 重拉台账) + const gateWriter = createGateWriter(); + // 🆕 F6-c: 缺口行动——重跑 L1 验证 / 补派 L2 评审 / L3 人工确认 const handleGapAction = async (gap: DeliveryGap, action: 'verify' | 'dispatchReview' | 'reviewPassed', summary?: string, assigneeId?: string, confirm?: ReviewConfirmPayload) => { const key = `${gap.id}:${action}`; @@ -77,7 +81,7 @@ export function DeliveryPanel({ projectId, delivery, onRefresh }: DeliveryPanelP toast.success('已创建评审任务,待 agent 领取'); await onRefresh(); } else { - await workunitApi.reviewPassed(gap.id, summary, assigneeId, confirm); + await gateWriter.reviewPassed(gap.id, summary, assigneeId, confirm); toast.success('人工确认已补齐'); await onRefresh(); } @@ -321,7 +325,7 @@ export function DeliveryPanel({ projectId, delivery, onRefresh }: DeliveryPanelP }} onReject={async reason => { const gap = approveGap.gap; - await workunitApi.reviewRejected(gap.id, reason); + await gateWriter.reviewRejected(gap.id, reason); toast.success('已打回,待补充修订'); setApproveGap(null); await onRefresh(); diff --git a/apps/web/src/components/workunit/WuGateActions.tsx b/apps/web/src/components/workunit/WuGateActions.tsx index 3091cac1a..09d8c6baf 100644 --- a/apps/web/src/components/workunit/WuGateActions.tsx +++ b/apps/web/src/components/workunit/WuGateActions.tsx @@ -7,31 +7,29 @@ // 反馈统一批次A 模式:pending 锁存防连点 + 失败 gateError 内联(errorMessage 服务端 error.message 优先) // + 弹窗成功才关窗 + #468 成功 toast 说明后续走向(自动派工/进待领取/打回返工)。 // blocked 处置(BlockedActions)不在此列——属状态处置非审查闸门,各页自挂。 -// 变更写路径留在调用方经 props 注入(列表=store 动作后重拉 / 抽屉=响应体直替本地 wu / 详情页=actionTick 重拉)。 +// 写路径内建(#545,ADR 2026-09-15-web-gate-write-module):组件直接消费 utils/gateWriter +// (一次 API 调用 + 响应体快照双写落点);宿主只经 onUpdated 登记本地落点(drawer/详情页 setWu), +// 列表行/工作条无需 sink(store 双写已覆盖)。 import { useState } from 'react'; import { deriveDisplayState } from '@dommaker/studio-shared/web'; -import type { WorkUnit, ReviewConfirmPayload } from '../../api/workunit'; +import type { WorkUnit } from '../../api/workunit'; import { AnalysisApproveDialog } from '../pmo/AnalysisApproveDialog'; import { DecisionApproveDialog } from '../pmo/DecisionApproveDialog'; import { SpecApproveDialog } from '../pmo/SpecApproveDialog'; import { buildAnalysisConfirmPrefill, buildDecisionConfirmPrefill, buildSpecConfirmPrefill } from '../pmo/mapUtils'; +import { createGateWriter, type GateUpdateSink } from '../../utils/gateWriter'; import { errorMessage } from '../../utils/errorMessage'; import { toast } from '../../utils/toast'; export interface WuGateActionsProps { wu: WorkUnit; - /** 审查硬门通过(analysis/decision/spec 由各自确认弹窗带 confirm 结构化表单回传,#463; - * 后端序列化进 l3.summary,存储契约不变);失败须 reject——弹窗据此保持打开 */ - onReviewPassed: (summary?: string, assigneeId?: string, confirm?: ReviewConfirmPayload) => Promise; - /** 审查硬门拒绝(reason 可选;弹窗的打回按钮带预设理由走同一入口) */ - onReviewRejected: (reason?: string) => Promise; - /** #284 pending 人闸确认(→ unassigned 进 frontier 可认领) */ - onConfirmPending: () => Promise; + /** #545:宿主本地快照落点(store 双写完成后由 gateWriter 调用);缺省 = 无本地态宿主 */ + onUpdated?: GateUpdateSink; /** #284(决策 #250 D6):接力卡「打开即弹」——挂载时 wu 为 in_review analysis 则自动弹确认弹窗(一次性) */ autoApprove?: boolean; } -export function WuGateActions({ wu, onReviewPassed, onReviewRejected, onConfirmPending, autoApprove = false }: WuGateActionsProps) { +export function WuGateActions({ wu, onUpdated, autoApprove = false }: WuGateActionsProps) { const [confirming, setConfirming] = useState(false); const [gateError, setGateError] = useState(''); const [showApproveModal, setShowApproveModal] = useState(false); @@ -39,6 +37,8 @@ export function WuGateActions({ wu, onReviewPassed, onReviewRejected, onConfirmP const [rejectReason, setRejectReason] = useState(''); // F6 派生(铁律:needsHuman 判断一律过 deriveDisplayState,不自行读 attestations) const derived = deriveDisplayState({ status: wu.status, metadata: wu.metadata }); + // #545:写路径唯一正本(API 调用 + store 双写 + onUpdated 回调全在 gateWriter) + const gateWriter = createGateWriter(onUpdated); // autoApprove(#284 决策 #250 D6):接力卡「打开即弹」一次性——渲染期派生(prevId 同款模式, // 组件仅在 wu 加载完成且命中闸门分支后挂载,id 切换经卸载重置) @@ -80,13 +80,13 @@ export function WuGateActions({ wu, onReviewPassed, onReviewRejected, onConfirmP if (CONFIRM_DIALOG_TYPES.has(wu.type)) { setShowApproveModal(true); } else { - void run(() => onReviewPassed()).then(() => toast.success(approveFollowUp())).catch(() => {}); + void run(() => gateWriter.reviewPassed(wu.id)).then(() => toast.success(approveFollowUp())).catch(() => {}); } }; /** 拒绝(含弹窗打回按钮):成功才关弹窗;失败错误行同时进闸门区与弹窗(同源 gateError) */ const handleReject = (reason?: string) => { - void run(() => onReviewRejected(reason)) + void run(() => gateWriter.reviewRejected(wu.id, reason)) .then(() => { setShowRejectModal(false); setRejectReason(''); setShowApproveModal(false); toast.info('已拒绝,工单打回返工'); // #468 @@ -108,7 +108,7 @@ export function WuGateActions({ wu, onReviewPassed, onReviewRejected, onConfirmP disabled={confirming} title="待确认人闸:扩范围单创建落待确认,确认后进入待领取(agent 可见可领取)" onClick={() => { - void run(onConfirmPending) + void run(() => gateWriter.confirmPending(wu.id)) .then(() => toast.success('已确认,工单进入待领取队列(agent 可认领)')) // #468 .catch(() => { /* 失败原因已内联 */ }); }} @@ -157,11 +157,11 @@ export function WuGateActions({ wu, onReviewPassed, onReviewRejected, onConfirmP confirmKind={wu.type === 'plan' ? 'plan' : 'analysis'} onConfirm={async (confirm, assigneeId) => { // 批次A 项7:成功才关窗(失败由弹窗内联展示,gateError 亦已置位) - await run(() => onReviewPassed(undefined, assigneeId, confirm)); + await run(() => gateWriter.reviewPassed(wu.id, undefined, assigneeId, confirm)); toast.success(approveFollowUp()); // #468 setShowApproveModal(false); }} - onReject={async reason => { await run(() => onReviewRejected(reason)); toast.info('已拒绝,工单打回返工'); setShowApproveModal(false); }} + onReject={async reason => { await run(() => gateWriter.reviewRejected(wu.id, reason)); toast.info('已拒绝,工单打回返工'); setShowApproveModal(false); }} onCancel={() => setShowApproveModal(false)} /> )} @@ -171,11 +171,11 @@ export function WuGateActions({ wu, onReviewPassed, onReviewRejected, onConfirmP question={(wu.scope ?? '').split('\n')[0] ?? ''} suggestion={buildDecisionConfirmPrefill(wu.metadata)} onConfirm={async confirm => { - await run(() => onReviewPassed(undefined, undefined, confirm)); + await run(() => gateWriter.reviewPassed(wu.id, undefined, undefined, confirm)); toast.success(approveFollowUp()); // #468 setShowApproveModal(false); }} - onReject={async reason => { await run(() => onReviewRejected(reason)); toast.info('已拒绝,工单打回返工'); setShowApproveModal(false); }} + onReject={async reason => { await run(() => gateWriter.reviewRejected(wu.id, reason)); toast.info('已拒绝,工单打回返工'); setShowApproveModal(false); }} onCancel={() => setShowApproveModal(false)} /> )} @@ -184,11 +184,11 @@ export function WuGateActions({ wu, onReviewPassed, onReviewRejected, onConfirmP { - await run(() => onReviewPassed(undefined, undefined, confirm)); + await run(() => gateWriter.reviewPassed(wu.id, undefined, undefined, confirm)); toast.success(approveFollowUp()); // #468 setShowApproveModal(false); }} - onReject={async reason => { await run(() => onReviewRejected(reason)); toast.info('已拒绝,工单打回返工'); setShowApproveModal(false); }} + onReject={async reason => { await run(() => gateWriter.reviewRejected(wu.id, reason)); toast.info('已拒绝,工单打回返工'); setShowApproveModal(false); }} onCancel={() => setShowApproveModal(false)} /> )} diff --git a/apps/web/src/components/workunit/__tests__/WuGateActions.test.tsx b/apps/web/src/components/workunit/__tests__/WuGateActions.test.tsx index 5a34cb26b..3fbf811c7 100644 --- a/apps/web/src/components/workunit/__tests__/WuGateActions.test.tsx +++ b/apps/web/src/components/workunit/__tests__/WuGateActions.test.tsx @@ -1,12 +1,28 @@ // WuGateActions — E2-4 三处合一闸门动作组件契约:分支 / pending 锁存 / 失败内联 / 弹窗成功才关 / autoApprove 一次性 // (三消费方接线各自由 WorkUnitListPage / WorkUnitDrawer / WorkUnitDetailPage 测试覆盖,本文件锚共享行为) +// #545:写路径内建 gateWriter——本文件 mock 写模块,只断组件到模块的调用形状(渲染级); +// API 透传 + store 双写契约由 utils/__tests__/gateWriter.test.ts 单点覆盖。 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -const { mockToast } = vi.hoisted(() => ({ - mockToast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn(), dismiss: vi.fn() }, -})); +const { mockToast, mockReviewPassed, mockReviewRejected, mockConfirmPending, mockCreateGateWriter } = vi.hoisted(() => { + const mockReviewPassed = vi.fn(); + const mockReviewRejected = vi.fn(); + const mockConfirmPending = vi.fn(); + return { + mockToast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn(), dismiss: vi.fn() }, + mockReviewPassed, + mockReviewRejected, + mockConfirmPending, + mockCreateGateWriter: vi.fn(() => ({ + reviewPassed: mockReviewPassed, + reviewRejected: mockReviewRejected, + confirmPending: mockConfirmPending, + })), + }; +}); vi.mock('../../../utils/toast', () => ({ toast: mockToast })); +vi.mock('../../../utils/gateWriter', () => ({ createGateWriter: mockCreateGateWriter })); import { WuGateActions } from '../WuGateActions'; import type { WorkUnit } from '../../api/workunit'; @@ -39,38 +55,43 @@ const L2_ONLY = JSON.stringify({ }, }); -function setup(wu: WorkUnit, extra: { autoApprove?: boolean } = {}) { - const props = { - onReviewPassed: vi.fn().mockResolvedValue({}), - onReviewRejected: vi.fn().mockResolvedValue({}), - onConfirmPending: vi.fn().mockResolvedValue({}), - }; - const utils = render( - , +function setup(wu: WorkUnit, extra: { autoApprove?: boolean; onUpdated?: (w: WorkUnit) => void } = {}) { + return render( + , ); - return { ...props, ...utils }; } describe('WuGateActions — 状态分支(E2-4)', () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + mockReviewPassed.mockResolvedValue({}); + mockReviewRejected.mockResolvedValue({}); + mockConfirmPending.mockResolvedValue({}); + }); - it('pending → 「确认并开放领取」调 onConfirmPending', () => { - const { onConfirmPending } = setup(makeWu({ status: 'pending' })); + it('pending → 「确认并开放领取」调 gateWriter.confirmPending(id)', () => { + setup(makeWu({ status: 'pending' })); fireEvent.click(screen.getByText('确认并开放领取')); - expect(onConfirmPending).toHaveBeenCalledTimes(1); + expect(mockConfirmPending).toHaveBeenCalledWith('wu-1'); }); - it('in_review → 「通过验收」+「拒绝」;task 直调 onReviewPassed', () => { - const { onReviewPassed } = setup(makeWu({ status: 'in_review' })); + it('in_review → 「通过验收」+「拒绝」;task 直调 gateWriter.reviewPassed(id)', () => { + setup(makeWu({ status: 'in_review' })); fireEvent.click(screen.getByText('通过验收')); - expect(onReviewPassed).toHaveBeenCalledWith(); + expect(mockReviewPassed).toHaveBeenCalledWith('wu-1'); expect(screen.getByText('拒绝')).toBeTruthy(); }); - it('done 缺 l3 → 「人工验收确认」调 onReviewPassed(同端点幂等补写)', () => { - const { onReviewPassed } = setup(makeWu({ status: 'done', metadata: L2_ONLY })); + it('done 缺 l3 → 「人工验收确认」调 gateWriter.reviewPassed(同端点幂等补写)', () => { + setup(makeWu({ status: 'done', metadata: L2_ONLY })); fireEvent.click(screen.getByText('人工验收确认')); - expect(onReviewPassed).toHaveBeenCalledWith(); + expect(mockReviewPassed).toHaveBeenCalledWith('wu-1'); + }); + + it('宿主 onUpdated 落点透传给 createGateWriter(#545 单一可选 sink)', () => { + const onUpdated = vi.fn(); + setup(makeWu({ status: 'pending' }), { onUpdated }); + expect(mockCreateGateWriter).toHaveBeenCalledWith(onUpdated); }); it('active / blocked / done 有 l3 → 不渲染任何闸门动作', () => { @@ -82,9 +103,7 @@ describe('WuGateActions — 状态分支(E2-4)', () => { }, }); for (const wu of [makeWu({ status: 'active' }), makeWu({ status: 'blocked' }), makeWu({ status: 'done', metadata: l3 })]) { - const { container, unmount } = render( - , - ); + const { container, unmount } = render(); expect(container.querySelector('button')).toBeNull(); unmount(); } @@ -92,35 +111,36 @@ describe('WuGateActions — 状态分支(E2-4)', () => { }); describe('WuGateActions — 反馈兜底(批次A 模式)', () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + mockReviewPassed.mockResolvedValue({}); + mockReviewRejected.mockResolvedValue({}); + mockConfirmPending.mockResolvedValue({}); + }); it('pending 锁存:未结算前连击只调一次,按钮禁用', async () => { let resolve: () => void = () => {}; - const onReviewPassed = vi.fn().mockImplementation(() => new Promise(r => { resolve = r; })); - render( - , - ); + mockReviewPassed.mockImplementation(() => new Promise(r => { resolve = r; })); + setup(makeWu({ status: 'in_review' })); const btn = screen.getByText('通过验收').closest('button')!; fireEvent.click(btn); await waitFor(() => expect(btn.disabled).toBe(true)); fireEvent.click(btn); - expect(onReviewPassed).toHaveBeenCalledTimes(1); + expect(mockReviewPassed).toHaveBeenCalledTimes(1); resolve(); await waitFor(() => expect(btn.disabled).toBe(false)); }); it('失败 → gateError 内联(服务端 error.message 优先);再次尝试前清除', async () => { - const onReviewPassed = vi.fn() + mockReviewPassed .mockRejectedValueOnce(Object.assign(new Error('Request failed with status code 409'), { isAxiosError: true, response: { status: 409, data: { error: { message: '状态机不允许该迁移' } } }, })) .mockResolvedValueOnce({}); - render( - , - ); + setup(makeWu({ status: 'in_review' })); fireEvent.click(screen.getByText('通过验收')); expect(await screen.findByText('状态机不允许该迁移')).toBeTruthy(); @@ -130,15 +150,12 @@ describe('WuGateActions — 反馈兜底(批次A 模式)', () => { }); it('拒绝弹窗:原因 trim 后回传,空原因 → undefined;成功才关窗', async () => { - const onReviewRejected = vi.fn().mockResolvedValue({}); - render( - , - ); + setup(makeWu({ status: 'in_review' })); fireEvent.click(screen.getByText('拒绝')); fireEvent.change(screen.getByPlaceholderText(/拒绝原因/), { target: { value: ' 返工 ' } }); fireEvent.click(screen.getByText('确认拒绝')); - await waitFor(() => expect(onReviewRejected).toHaveBeenCalledWith('返工')); + await waitFor(() => expect(mockReviewRejected).toHaveBeenCalledWith('wu-1', '返工')); await waitFor(() => expect(screen.queryByText('拒绝原因')).toBeNull()); // 成功关窗 }); @@ -146,7 +163,7 @@ describe('WuGateActions — 反馈兜底(批次A 模式)', () => { const onRowClick = vi.fn(); render(
- +
, ); fireEvent.click(screen.getByText('通过验收')); @@ -155,10 +172,15 @@ describe('WuGateActions — 反馈兜底(批次A 模式)', () => { }); describe('WuGateActions — #463 结构化确认弹窗(analysis/decision/spec)与 autoApprove', () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + mockReviewPassed.mockResolvedValue({}); + mockReviewRejected.mockResolvedValue({}); + mockConfirmPending.mockResolvedValue({}); + }); it('analysis 点通过 → AnalysisApproveDialog 结构化预填,确认开图后 confirm 表单回传', async () => { - const { onReviewPassed } = setup(makeWu({ + setup(makeWu({ status: 'in_review', type: 'analysis', metadata: JSON.stringify({ analysisDestination: '目的地', analysisFog: ['问题1'], analysisTasks: ['干活'] }), @@ -168,16 +190,16 @@ describe('WuGateActions — #463 结构化确认弹窗(analysis/decision/spec expect((await screen.findByLabelText('目标') as HTMLInputElement).value).toBe('目的地'); expect((screen.getByLabelText('待决问题 1') as HTMLInputElement).value).toBe('问题1'); expect((screen.getByLabelText('派工任务 1') as HTMLInputElement).value).toBe('干活'); - expect(onReviewPassed).not.toHaveBeenCalled(); + expect(mockReviewPassed).not.toHaveBeenCalled(); fireEvent.click(screen.getByText('确认开图')); - await waitFor(() => expect(onReviewPassed).toHaveBeenCalledWith(undefined, undefined, { + await waitFor(() => expect(mockReviewPassed).toHaveBeenCalledWith('wu-1', undefined, undefined, { kind: 'analysis', destination: '目的地', fog: ['问题1'], tasks: ['干活'], })); }); it('#471 plan 点通过 → 同一结构化弹窗(标题「确认规划结论」),confirm kind=plan 回传', async () => { - const { onReviewPassed } = setup(makeWu({ + setup(makeWu({ status: 'in_review', type: 'plan', metadata: JSON.stringify({ analysisDestination: '目的地', analysisFog: ['问题1'], analysisTasks: ['干活'] }), @@ -186,16 +208,16 @@ describe('WuGateActions — #463 结构化确认弹窗(analysis/decision/spec fireEvent.click(screen.getByText('通过验收')); expect(await screen.findByText('确认规划结论')).toBeTruthy(); expect((screen.getByLabelText('待决问题 1') as HTMLInputElement).value).toBe('问题1'); - expect(onReviewPassed).not.toHaveBeenCalled(); + expect(mockReviewPassed).not.toHaveBeenCalled(); fireEvent.click(screen.getByText('确认开图')); - await waitFor(() => expect(onReviewPassed).toHaveBeenCalledWith(undefined, undefined, { + await waitFor(() => expect(mockReviewPassed).toHaveBeenCalledWith('wu-1', undefined, undefined, { kind: 'plan', destination: '目的地', fog: ['问题1'], tasks: ['干活'], })); }); it('decision 点通过 → DecisionApproveDialog 预填 agent 建议结论,采纳后 confirm 回传', async () => { - const { onReviewPassed } = setup(makeWu({ + setup(makeWu({ status: 'in_review', type: 'decision', scope: '待决问题 PMO-1: 存储选型?', @@ -204,16 +226,16 @@ describe('WuGateActions — #463 结构化确认弹窗(analysis/decision/spec fireEvent.click(screen.getByText('通过验收')); expect((await screen.findByLabelText('决策结论') as HTMLTextAreaElement).value).toBe('用 SQLite'); - expect(onReviewPassed).not.toHaveBeenCalled(); + expect(mockReviewPassed).not.toHaveBeenCalled(); fireEvent.click(screen.getByText('采纳结论')); - await waitFor(() => expect(onReviewPassed).toHaveBeenCalledWith(undefined, undefined, { + await waitFor(() => expect(mockReviewPassed).toHaveBeenCalledWith('wu-1', undefined, undefined, { kind: 'decision', conclusion: '用 SQLite', })); }); - it('decision 弹窗「转人工讨论」→ onReviewRejected 预设理由并关窗', async () => { - const { onReviewRejected } = setup(makeWu({ + it('decision 弹窗「转人工讨论」→ reviewRejected 预设理由并关窗', async () => { + setup(makeWu({ status: 'in_review', type: 'decision', metadata: JSON.stringify({ decisionSuggestion: '用 SQLite' }), @@ -221,12 +243,12 @@ describe('WuGateActions — #463 结构化确认弹窗(analysis/decision/spec fireEvent.click(screen.getByText('通过验收')); fireEvent.click(await screen.findByText('转人工讨论')); - await waitFor(() => expect(onReviewRejected).toHaveBeenCalledWith(expect.stringContaining('转人工讨论'))); + await waitFor(() => expect(mockReviewRejected).toHaveBeenCalledWith('wu-1', expect.stringContaining('转人工讨论'))); await waitFor(() => expect(screen.queryByText('确认决策结论')).toBeNull()); }); it('spec 点通过 → SpecApproveDialog 卡片墙预填,确认物化后 confirm 回传勾选集', async () => { - const { onReviewPassed } = setup(makeWu({ + setup(makeWu({ status: 'in_review', type: 'spec', metadata: JSON.stringify({ specTasks: [{ title: '实现存储层', ac: ['单测覆盖'], blockedBy: [] }] }), @@ -234,22 +256,22 @@ describe('WuGateActions — #463 结构化确认弹窗(analysis/decision/spec fireEvent.click(screen.getByText('通过验收')); expect((await screen.findByLabelText('任务标题 1') as HTMLInputElement).value).toBe('实现存储层'); - expect(onReviewPassed).not.toHaveBeenCalled(); + expect(mockReviewPassed).not.toHaveBeenCalled(); fireEvent.click(screen.getByText('确认物化(1)')); - await waitFor(() => expect(onReviewPassed).toHaveBeenCalledWith(undefined, undefined, { + await waitFor(() => expect(mockReviewPassed).toHaveBeenCalledWith('wu-1', undefined, undefined, { kind: 'spec', tasks: [{ title: '实现存储层', ac: ['单测覆盖'] }], })); }); it('spec 无 specTasks(旧数据/agent 未拆)→ 空卡片墙,可添加任务后物化(不再一键烧掉哨兵)', async () => { - const { onReviewPassed } = setup(makeWu({ status: 'in_review', type: 'spec', metadata: null })); + setup(makeWu({ status: 'in_review', type: 'spec', metadata: null })); fireEvent.click(screen.getByText('通过验收')); fireEvent.click(await screen.findByText('添加任务')); fireEvent.change(screen.getByLabelText('任务标题 1'), { target: { value: '补录的任务' } }); fireEvent.click(screen.getByText('确认物化(1)')); - await waitFor(() => expect(onReviewPassed).toHaveBeenCalledWith(undefined, undefined, { + await waitFor(() => expect(mockReviewPassed).toHaveBeenCalledWith('wu-1', undefined, undefined, { kind: 'spec', tasks: [{ title: '补录的任务', ac: [] }], })); }); @@ -272,7 +294,12 @@ describe('WuGateActions — #463 结构化确认弹窗(analysis/decision/spec }); describe('WuGateActions — #468 闸门动作成功 toast 说明后续', () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + mockReviewPassed.mockResolvedValue({}); + mockReviewRejected.mockResolvedValue({}); + mockConfirmPending.mockResolvedValue({}); + }); it('pending 确认成功 → toast 说明进待领取队列', async () => { setup(makeWu({ status: 'pending' })); @@ -304,10 +331,8 @@ describe('WuGateActions — #468 闸门动作成功 toast 说明后续', () => { }); it('动作失败 → 不弹 toast(错误仍走 gateError 内联)', async () => { - const onReviewPassed = vi.fn().mockRejectedValue(new Error('boom')); - render( - , - ); + mockReviewPassed.mockRejectedValue(new Error('boom')); + setup(makeWu({ status: 'in_review' })); fireEvent.click(screen.getByText('通过验收')); await waitFor(() => expect(screen.getByText('boom')).toBeTruthy()); expect(mockToast.success).not.toHaveBeenCalled(); diff --git a/apps/web/src/pages/ChannelDetailPage.tsx b/apps/web/src/pages/ChannelDetailPage.tsx index 6703ce1f9..05c722654 100644 --- a/apps/web/src/pages/ChannelDetailPage.tsx +++ b/apps/web/src/pages/ChannelDetailPage.tsx @@ -23,8 +23,7 @@ import { ChannelRail } from '../components/channel/ChannelRail'; import { ChannelActivityRail } from '../components/channel/ChannelActivityRail'; import { WorkUnitDrawer, type DrawerState } from '../components/channel/WorkUnitDrawer'; import { useMediaQuery } from '../hooks/useMediaQuery'; -import { workunitApi } from '../api/workunit'; -import type { ReviewConfirmPayload, WorkUnit } from '../api/workunit'; +import type { WorkUnit } from '../api/workunit'; import { renderSuggestionCopy } from '../utils/suggestionCopy'; import { toast } from '../utils/toast'; import { getSuggestionAction } from '../utils/suggestionActions'; @@ -217,30 +216,9 @@ export function ChannelDetailPage() { [channelWus, currentWuId], ); - // 批次 D-2 项6(频道内闸门 1 击化):工作条闸门动作写路径——直调 API + 响应体 write-through 进 - // channelWorkStore(仿 setMembers;抽屉同款直替口径);状态变化另有 status_changed SSE 直替 + - // 建议重拉(currentWuId 重拣选)兜底 - const applyGateResult = useCallback((updated: WorkUnit) => { - if (id) useChannelWorkStore.getState().applyGateResult(id, updated); - }, [id]); - const workBarGate = useMemo(() => { - if (!currentWu) return undefined; - const wuId = currentWu.id; - return { - onReviewPassed: async (summary?: string, assigneeId?: string, confirm?: ReviewConfirmPayload) => { - const r = await workunitApi.reviewPassed(wuId, summary, assigneeId, confirm); - applyGateResult(r.data); - }, - onReviewRejected: async (reason?: string) => { - const r = await workunitApi.reviewRejected(wuId, reason); - applyGateResult(r.data); - }, - onConfirmPending: async () => { - const r = await workunitApi.transitionStatus(wuId, 'unassigned'); - applyGateResult(r.data); - }, - }; - }, [currentWu, applyGateResult]); + // 批次 D-2 项6(频道内闸门 1 击化):工作条闸门动作写路径 #545 起内建于 WuGateActions + // (gateWriter 双写落点含 channelWorkStore 快照 + 建议标脏,本页零接线); + // 状态变化另有 status_changed SSE 直替 + 建议重拉(currentWuId 重拣选)兜底 // #443:端点派生建议 → 文案模板渲染成引导片(未知模板 id → 跳过,fail-closed) // #446:prompt 形态的预填指令本体由后端 text 字段承载,透传给 SuggestionChips(点击 → onPick(text)) @@ -546,7 +524,7 @@ export function ChannelDetailPage() { 一条横带回答「这个频道的工作现在什么状态」;hook 自持有,step 事件只重渲该组件边界; currentWu = 建议端点 currentWuId × channelWus(拣选口径单源在后端,未命中 fail-closed 主区不渲染); 点击条目打开对应 WU 抽屉(过程明细仍在抽屉) */} - + {/* Message list #325:头部块(空态/加载更早/折叠 toggle)与消息体分离—— diff --git a/apps/web/src/pages/WorkUnitDetailPage.tsx b/apps/web/src/pages/WorkUnitDetailPage.tsx index e33a13c47..4bb64231b 100644 --- a/apps/web/src/pages/WorkUnitDetailPage.tsx +++ b/apps/web/src/pages/WorkUnitDetailPage.tsx @@ -130,7 +130,8 @@ export function WorkUnitDetailPage() { const title = wu ? (typeof meta.title === 'string' && meta.title ? meta.title : wu.scope) : ''; /** E2-4:闸门动作 = 共享 WuGateActions(与列表行/抽屉同一组件,文案视觉唯一); - * 写路径留在本页:动作成功经 actionTick 重拉详情(与 BlockedActions.onChanged 同一路径) */ + * 写路径 #545 起内建于组件(gateWriter 双写落点 + onUpdated 直替本地 wu,不再 actionTick 整页重拉); + * BlockedActions 状态处置仍走 actionTick 重拉 */ const reloadOnGate = () => setActionTick(t => t + 1); // 批次 E-2:错误条「重试」——清 error 后经 actionTick 复用同一 effect 重拉 const retryLoad = () => { setError(''); setActionTick(t => t + 1); }; @@ -291,12 +292,7 @@ export function WorkUnitDetailPage() {

闸门动作

- { await workunitApi.reviewPassed(wu.id, summary, assigneeId, confirm); reloadOnGate(); }} - onReviewRejected={async (reason) => { await workunitApi.reviewRejected(wu.id, reason); reloadOnGate(); }} - onConfirmPending={async () => { await workunitApi.transitionStatus(wu.id, 'unassigned'); reloadOnGate(); }} - /> +
diff --git a/apps/web/src/pages/WorkUnitListPage.tsx b/apps/web/src/pages/WorkUnitListPage.tsx index fcb394bd3..c67035509 100644 --- a/apps/web/src/pages/WorkUnitListPage.tsx +++ b/apps/web/src/pages/WorkUnitListPage.tsx @@ -6,7 +6,7 @@ import { SelfReviewBadge } from '../components/workunit/SelfReviewBadge'; import { AssigneeLabel } from '../components/workunit/AssigneeLabel'; import { StaleSleepBadge } from '../components/workunit/StaleSleepBadge'; import { WuGateActions } from '../components/workunit/WuGateActions'; -import type { ReviewConfirmPayload, WorkUnit } from '../api/workunit'; +import type { WorkUnit } from '../api/workunit'; import { parseBlockedBy } from '../components/pmo/mapUtils'; import { useWebSocketContext } from '../api/websocketHooks'; import { Select, Button, SkeletonText } from '../components/ui'; @@ -33,7 +33,7 @@ const STATUS_CHIPS = [ export function WorkUnitListPage() { const { workunits, total, allTotal, loading, error, - loadWorkUnits, loadMoreWorkUnits, loadAllCount, createWorkUnit, reviewPassed, reviewRejected, confirmPending, + loadWorkUnits, loadMoreWorkUnits, loadAllCount, createWorkUnit, statusFilter, setStatusFilter, unattributedOnly, unattributedTotal, setUnattributedOnly, loadUnattributedCount, searchQuery, setSearchQuery, @@ -303,9 +303,6 @@ export function WorkUnitListPage() { wu={wu} fresh={freshWuIds.has(wu.id)} onOpen={() => navigate(`/workunits/${wu.id}`)} - onReviewPassed={(summary, assigneeId, confirm) => reviewPassed(wu.id, summary, assigneeId, confirm)} - onReviewRejected={(reason) => reviewRejected(wu.id, reason)} - onConfirmPending={() => confirmPending(wu.id)} formatTime={formatShortTime} /> ))} @@ -333,17 +330,13 @@ export function WorkUnitListPage() { } function WorkUnitRow({ - wu, fresh, onOpen, onReviewPassed, onReviewRejected, onConfirmPending, formatTime, + wu, fresh, onOpen, formatTime, }: { wu: WorkUnit; /** 批次 E-3:SSE 新插入行渐隐高亮标记(.wu-row-new,2s 后页面自清) */ fresh?: boolean; /** 2026-09-10 第二轮:行点击直跳 /workunits/:id 详情页 */ onOpen: () => void; - onReviewPassed: (summary?: string, assigneeId?: string, confirm?: ReviewConfirmPayload) => Promise; - onReviewRejected: (reason?: string) => Promise; - /** #284(决策 #250 D1):pending 人闸确认(行内快速处置入口,与详情页同组件) */ - onConfirmPending: () => Promise; formatTime: (ts: string | null) => string; }) { // F6-b:徽章/按钮的展示判断一律过派生函数(通过/拒绝的调用资格仍看存储状态, @@ -404,13 +397,9 @@ function WorkUnitRow({
- {/* E2-4:行内闸门按钮 = 共享 WuGateActions(快速处置不进详情页;组件内吞冒泡) */} - + {/* E2-4:行内闸门按钮 = 共享 WuGateActions(快速处置不进详情页;组件内吞冒泡; + #545 起写路径内建 gateWriter——store 双写 upsert 存量行,不再全量重拉) */} +
diff --git a/apps/web/src/pages/__tests__/WorkUnitDetailPage.test.tsx b/apps/web/src/pages/__tests__/WorkUnitDetailPage.test.tsx index 66577e6c1..a30b8f989 100644 --- a/apps/web/src/pages/__tests__/WorkUnitDetailPage.test.tsx +++ b/apps/web/src/pages/__tests__/WorkUnitDetailPage.test.tsx @@ -78,6 +78,15 @@ vi.mock('../../api/websocketHooks', () => ({ useWebSocketContext: () => ({ onEvent: () => () => {}, onReconnect: () => () => {} }), })); +// #545:gateWriter 的 store 双写落点在模块级测试单点覆盖;本文件 stub 掉两个 store, +// 防真实 markSuggestionsDirty 防抖定时器跨测悬挂(API 透传 + onUpdated 直替仍走真实 gateWriter) +vi.mock('../../stores/channelWorkStore', () => ({ + useChannelWorkStore: { getState: () => ({ applyWorkunitSnapshot: vi.fn(), markSuggestionsDirty: vi.fn() }) }, +})); +vi.mock('../../stores/workunitStore', () => ({ + useWorkUnitStore: { getState: () => ({ applyWorkunitEvent: vi.fn() }) }, +})); + // #174: TranscriptViewer 桩(组件自身契约在 __tests__/TranscriptViewer.test.tsx 覆盖) vi.mock('../../components/workunit/TranscriptViewer', () => ({ TranscriptViewer: ({ workUnitId }: { workUnitId: string }) => @@ -437,7 +446,7 @@ describe('WorkUnitDetailPage', () => { }); // #284(决策 #250 D1/F7-F9):详情页(「新页面打开」落点)补齐闸门入口,与列表行/抽屉一致 - it('#284:pending → 闸门动作节出「确认并开放领取」调 transitionStatus(unassigned) 并重拉详情;人闸 chip 上 stepper 下', async () => { + it('#284:pending → 闸门动作节出「确认并开放领取」调 transitionStatus(unassigned),响应体快照直替本地 wu(#545 不再重拉);人闸 chip 上 stepper 下', async () => { mockWuGet.mockResolvedValue({ data: { ...baseWu, status: 'pending', completedAt: null, metadata: JSON.stringify({ title: '登录功能开发' }) }, }); @@ -446,10 +455,12 @@ describe('WorkUnitDetailPage', () => { expect(screen.getByText(/待确认人闸/)).toBeDefined(); fireEvent.click(screen.getByText('确认并开放领取')); await waitFor(() => expect(mockTransitionStatus).toHaveBeenCalledWith('wu-1', 'unassigned')); - await waitFor(() => expect(mockWuGet.mock.calls.length).toBeGreaterThanOrEqual(2)); + // 快照直替:unassigned 无闸门 → 动作节消失;整页不重拉(get 仍只首屏一次) + await waitFor(() => expect(screen.queryByText('闸门动作')).toBeNull()); + expect(mockWuGet).toHaveBeenCalledTimes(1); }); - it('#284:in_review task → 通过直调 reviewPassed;拒绝带原因调 reviewRejected(与列表行一致)', async () => { + it('#284:in_review task → 通过直调 reviewPassed,响应体快照直替(done → 闸门节消失,不重拉)', async () => { mockWuGet.mockResolvedValue({ data: { ...baseWu, status: 'in_review', completedAt: null, metadata: JSON.stringify({ title: '登录功能开发' }) }, }); @@ -457,8 +468,17 @@ describe('WorkUnitDetailPage', () => { fireEvent.click(await screen.findByText('通过验收')); await waitFor(() => expect(mockReviewPassed).toHaveBeenCalledWith('wu-1', undefined, undefined, undefined)); + await waitFor(() => expect(screen.queryByText('闸门动作')).toBeNull()); + expect(mockWuGet).toHaveBeenCalledTimes(1); + }); - fireEvent.click(screen.getByText('拒绝')); + it('#284:in_review task → 拒绝带原因调 reviewRejected(与列表行一致)', async () => { + mockWuGet.mockResolvedValue({ + data: { ...baseWu, status: 'in_review', completedAt: null, metadata: JSON.stringify({ title: '登录功能开发' }) }, + }); + render(); + + fireEvent.click(await screen.findByText('拒绝')); fireEvent.change(screen.getByPlaceholderText(/拒绝原因/), { target: { value: '实现不符合预期' } }); fireEvent.click(screen.getByText('确认拒绝')); await waitFor(() => expect(mockReviewRejected).toHaveBeenCalledWith('wu-1', '实现不符合预期')); diff --git a/apps/web/src/pages/__tests__/WorkUnitListPage.test.tsx b/apps/web/src/pages/__tests__/WorkUnitListPage.test.tsx index ff643f028..d5377b67c 100644 --- a/apps/web/src/pages/__tests__/WorkUnitListPage.test.tsx +++ b/apps/web/src/pages/__tests__/WorkUnitListPage.test.tsx @@ -9,10 +9,22 @@ vi.mock('react', async () => { return { ...actual, default: actual }; }); -const { mockSearchParamsValue, mockNavigate } = vi.hoisted(() => ({ +const { mockSearchParamsValue, mockNavigate, mockGateReviewPassed, mockGateReviewRejected, mockGateConfirmPending } = vi.hoisted(() => ({ mockSearchParamsValue: { value: '' }, // 2026-09-10 第二轮:行点击直跳详情页——navigate 提为 hoisted spy 供断言 mockNavigate: vi.fn(), + // #545:行闸门写路径内建 gateWriter——页面测试 mock 写模块,只断行 → 模块的调用形状(渲染级) + mockGateReviewPassed: vi.fn(), + mockGateReviewRejected: vi.fn(), + mockGateConfirmPending: vi.fn(), +})); + +vi.mock('../../utils/gateWriter', () => ({ + createGateWriter: () => ({ + reviewPassed: mockGateReviewPassed, + reviewRejected: mockGateReviewRejected, + confirmPending: mockGateConfirmPending, + }), })); vi.mock('react-router-dom', () => ({ @@ -28,9 +40,6 @@ const mockStore = { statusFilter: null as string | null, /** E2-5:分页测试用 total 覆盖(缺省 = 当页条数) */ total: null as number | null, - reviewPassed: vi.fn(), - reviewRejected: vi.fn(), - confirmPending: vi.fn(), loadWorkUnits: vi.fn(), loadMoreWorkUnits: vi.fn(), // 并行改动(allTotal 全量总数徽标):页面新依赖的 store action,mock 补齐 @@ -66,9 +75,6 @@ vi.mock('../../stores/workunitStore', () => ({ loadAllCount: mockStore.loadAllCount, allTotal: mockStore.allTotal, createWorkUnit: mockStore.createWorkUnit, - reviewPassed: mockStore.reviewPassed, - reviewRejected: mockStore.reviewRejected, - confirmPending: mockStore.confirmPending, setStatusFilter: mockStore.setStatusFilter, setUnattributedOnly: mockStore.setUnattributedOnly, loadUnattributedCount: mockStore.loadUnattributedCount, @@ -350,7 +356,7 @@ describe('WorkUnitListPage — analysis 确认弹窗(#106 M7;#463 起结构 fireEvent.click(screen.getByLabelText('删除待决问题 2')); fireEvent.click(screen.getByText('确认开图')); - expect(mockStore.reviewPassed).toHaveBeenCalledWith('wu-a1', undefined, undefined, { + expect(mockGateReviewPassed).toHaveBeenCalledWith('wu-a1', undefined, undefined, { kind: 'analysis', destination: '三仓特性联动上线', fog: ['存储选型用哪个?'], @@ -366,7 +372,7 @@ describe('WorkUnitListPage — analysis 确认弹窗(#106 M7;#463 起结构 expect((screen.getByLabelText('目标') as HTMLInputElement).value).toBe(''); fireEvent.click(screen.getByText('确认开图')); - expect(mockStore.reviewPassed).toHaveBeenCalledWith('wu-a2', undefined, undefined, { + expect(mockGateReviewPassed).toHaveBeenCalledWith('wu-a2', undefined, undefined, { kind: 'analysis', fog: [], tasks: [], }); }); @@ -378,7 +384,7 @@ describe('WorkUnitListPage — analysis 确认弹窗(#106 M7;#463 起结构 fireEvent.click(screen.getByText('通过验收')); expect(screen.queryByLabelText('目标')).toBeNull(); - expect(mockStore.reviewPassed).toHaveBeenCalledWith('wu-t1', undefined, undefined, undefined); + expect(mockGateReviewPassed).toHaveBeenCalledWith('wu-t1'); }); }); @@ -397,7 +403,7 @@ describe('WorkUnitListPage — pending 人闸入口(#284 / E2-4)', () => { fireEvent.click(await screen.findByText('确认并开放领取')); - expect(mockStore.confirmPending).toHaveBeenCalledWith('wu-p1'); + expect(mockGateConfirmPending).toHaveBeenCalledWith('wu-p1'); // 闸门点击不触发行点击跳转(组件内吞冒泡) expect(mockNavigate).not.toHaveBeenCalled(); }); @@ -716,7 +722,7 @@ describe('WorkUnitListPage — 行闸门反馈兜底(批次A 项4 / E2-4)', it('行「通过验收」pending 锁存:未结算前连击只调一次,按钮禁用', async () => { let resolve: () => void = () => {}; - mockStore.reviewPassed.mockImplementation(() => new Promise(r => { resolve = r; })); + mockGateReviewPassed.mockImplementation(() => new Promise(r => { resolve = r; })); mockStore.workunits = [makeWu({ id: 'wu-l1', type: 'task', status: 'in_review' })]; render(); @@ -725,7 +731,7 @@ describe('WorkUnitListPage — 行闸门反馈兜底(批次A 项4 / E2-4)', await waitFor(() => expect(btn.disabled).toBe(true)); expect(screen.getByText('拒绝').closest('button')!.disabled).toBe(true); fireEvent.click(btn); - expect(mockStore.reviewPassed).toHaveBeenCalledTimes(1); + expect(mockGateReviewPassed).toHaveBeenCalledTimes(1); resolve(); await waitFor(() => expect(btn.disabled).toBe(false)); @@ -740,7 +746,7 @@ describe('WorkUnitListPage — 行闸门反馈兜底(批次A 项4 / E2-4)', }); it('行「通过」失败 → 内联错误行透传服务端 error.message,按钮恢复可点', async () => { - mockStore.reviewPassed.mockRejectedValue(Object.assign(new Error('Request failed with status code 409'), { + mockGateReviewPassed.mockRejectedValue(Object.assign(new Error('Request failed with status code 409'), { isAxiosError: true, response: { status: 409, data: { error: { message: '状态机不允许该迁移' } } }, })); @@ -754,7 +760,7 @@ describe('WorkUnitListPage — 行闸门反馈兜底(批次A 项4 / E2-4)', }); it('pending 行「确认并开放领取」失败 → 内联错误行(Error.message 回退)不静默', async () => { - mockStore.confirmPending.mockRejectedValue(new Error('boom')); + mockGateConfirmPending.mockRejectedValue(new Error('boom')); mockStore.workunits = [makeWu({ id: 'wu-l3', type: 'task', status: 'pending' })]; render(); @@ -779,7 +785,7 @@ describe('WorkUnitListPage — 行闸门反馈兜底(批次A 项4 / E2-4)', }); it('analysis 弹窗确认失败 → 弹窗不关 + 内联错误(批次A 项7 成功才关窗)', async () => { - mockStore.reviewPassed.mockRejectedValue(new Error('服务端挂了')); + mockGateReviewPassed.mockRejectedValue(new Error('服务端挂了')); mockStore.workunits = [makeWu({ id: 'wu-l4', status: 'in_review' })]; // 默认 type=analysis render(); diff --git a/apps/web/src/stores/__tests__/channelWorkStore.test.ts b/apps/web/src/stores/__tests__/channelWorkStore.test.ts index 692c1f459..b568f9b4c 100644 --- a/apps/web/src/stores/__tests__/channelWorkStore.test.ts +++ b/apps/web/src/stores/__tests__/channelWorkStore.test.ts @@ -256,17 +256,6 @@ describe('channelWorkStore — applyWorkunitRemoved(workunit:removed 删行分 }); }); -describe('channelWorkStore — applyGateResult(闸门动作 write-through)', () => { - it('直替命中条目;未打底频道 no-op 不建 slice', async () => { - mockWuList.mockResolvedValue({ data: { success: true, data: [wuFixture('wu-1')] } }); - await useChannelWorkStore.getState().ensureWus('ch-1'); - useChannelWorkStore.getState().applyGateResult('ch-1', wuFixture('wu-1', { status: 'review' })); - expect(useChannelWorkStore.getState().wus['ch-1']![0].status).toBe('review'); - useChannelWorkStore.getState().applyGateResult('ch-9', wuFixture('wu-1', { channelId: 'ch-9' })); - expect(useChannelWorkStore.getState().wus['ch-9']).toBeUndefined(); - }); -}); - describe('channelWorkStore — applyRequirementEvent(envelope 解包就地 upsert,#415 修法)', () => { beforeEach(() => { mockReqList.mockResolvedValue({ data: { success: true, data: [reqFixture('REQ-1', { title: '旧标题' })] } }); diff --git a/apps/web/src/stores/__tests__/workunitStore.test.ts b/apps/web/src/stores/__tests__/workunitStore.test.ts index d94c5f9fb..a2951ab25 100644 --- a/apps/web/src/stores/__tests__/workunitStore.test.ts +++ b/apps/web/src/stores/__tests__/workunitStore.test.ts @@ -6,47 +6,14 @@ vi.mock('../../api/workunit', () => ({ workunitApi: { list: vi.fn().mockResolvedValue({ data: { data: [], pagination: { total: 0, page: 1, limit: 20, totalPages: 0 } } }), create: vi.fn().mockResolvedValue({ data: {} }), - reviewPassed: vi.fn().mockResolvedValue({ data: {} }), - reviewRejected: vi.fn().mockResolvedValue({ data: {} }), }, })); import { useWorkUnitStore } from '../workunitStore'; import { workunitApi } from '../../api/workunit'; -describe('workunitStore reviewRejected', () => { - beforeEach(() => { - vi.clearAllMocks(); - useWorkUnitStore.setState({ - workunits: [], - total: 0, - page: 1, - limit: 20, - statusFilter: null, - typeFilter: null, - loading: false, - error: null, - }); - }); - - it('should pass reason to API when rejecting', async () => { - const store = useWorkUnitStore.getState(); - await store.reviewRejected('wu-1', '质量不达标'); - expect(workunitApi.reviewRejected).toHaveBeenCalledWith('wu-1', '质量不达标'); - }); - - it('should work without reason', async () => { - const store = useWorkUnitStore.getState(); - await store.reviewRejected('wu-1'); - expect(workunitApi.reviewRejected).toHaveBeenCalledWith('wu-1', undefined); - }); - - it('should reload workunits after rejection', async () => { - const store = useWorkUnitStore.getState(); - await store.reviewRejected('wu-1', '原因'); - expect(workunitApi.list).toHaveBeenCalled(); - }); -}); +// #545:闸门三动作(reviewPassed/reviewRejected/confirmPending)已从 store 删除—— +// 写路径唯一正本为 utils/gateWriter(WuGateActions 直消费),其契约由 gateWriter.test.ts 单点覆盖 // #280:store 从 pagination.total 解析总数(旧版类型把 total 扁平化导致恒 0) describe('workunitStore loadWorkUnits - pagination.total 解析(#280)', () => { diff --git a/apps/web/src/stores/channelWorkStore.ts b/apps/web/src/stores/channelWorkStore.ts index b2f9c162c..6c5116dc0 100644 --- a/apps/web/src/stores/channelWorkStore.ts +++ b/apps/web/src/stores/channelWorkStore.ts @@ -51,8 +51,8 @@ interface ChannelWorkState { markSuggestionsDirty: (channelId: string) => void; /** agent 消息所属 WU 的产出文件集(distinct wuId 各拉一次并缓存;模块级台账防重拉) */ ensureWuChangedFiles: (wuIds: string[]) => void; - /** workunit.status_changed 全量快照直替 upsert(17 字段快照原样落库,删除编造时间戳); - * 未打底的频道 no-op(等 REST 打底);坏负载 no-op */ + /** SSE status_changed 与闸门动作(#545 gateWriter sink)共用快照落点:全量快照直替 upsert + * (17 字段快照原样落库,删除编造时间戳);未打底的频道 no-op(等 REST 打底);坏负载 no-op */ applyWorkunitSnapshot: (channelId: string, wu: WorkUnit | null | undefined) => void; /** #538(ADR 2026-09-15 决策 5):workunit:removed 删行分支——GC/TTL 删除即时下榜, * 不再悬挂到 SSE 重连 refetch;未打底频道/未知 id/坏负载 no-op */ @@ -60,8 +60,6 @@ interface ChannelWorkState { /** requirement.created/updated 就地 upsert(#415:负载全量零补拉):created 追加去重 / * updated 全量覆盖已有条目(列表没有则不动,交由重连 refetch);未打底频道 no-op */ applyRequirementEvent: (channelId: string, kind: 'created' | 'updated', req: Requirement | null | undefined) => void; - /** 工作条闸门动作 write-through(仿 setMembers):响应体就地并入,不改数组顺序 */ - applyGateResult: (channelId: string, updated: WorkUnit) => void; /** 测试隔离:清空数据面 + 纪律簿记(模块级 loadedAt/inflight/gate/防抖定时器不在 zustand 内) */ __resetForTests: () => void; } @@ -286,14 +284,6 @@ export const useChannelWorkStore = create((set, get) => ({ }); }, - applyGateResult: (channelId, updated) => { - set((st) => { - const list = st.wus[channelId]; - if (!list) return {}; - return { wus: { ...st.wus, [channelId]: list.map((w) => (w.id === updated.id ? { ...w, ...updated } : w)) } }; - }); - }, - __resetForTests: () => { book.clear(); wuFilesFetched.clear(); diff --git a/apps/web/src/stores/workunitStore.ts b/apps/web/src/stores/workunitStore.ts index 4e467ea85..d179a3deb 100644 --- a/apps/web/src/stores/workunitStore.ts +++ b/apps/web/src/stores/workunitStore.ts @@ -1,6 +1,6 @@ // WorkUnit Store — Agent Network §3.28c-1 import { create } from 'zustand'; -import { workunitApi, type PaginatedResponse, type ReviewConfirmPayload, type WorkUnit } from '../api/workunit'; +import { workunitApi, type PaginatedResponse, type WorkUnit } from '../api/workunit'; /** * #405:未归属判定 —— 无 reqId 且归因戳(canonical pmoId ‖ legacy ownershipProjectId) @@ -61,10 +61,6 @@ interface WorkUnitState { * 未知行/空 id no-op */ removeWorkunit: (id: string) => void; createWorkUnit: (data: { scope: string; type?: string }) => Promise; - reviewPassed: (id: string, summary?: string, defaultAssigneeId?: string, confirm?: ReviewConfirmPayload) => Promise; - reviewRejected: (id: string, reason?: string) => Promise; - /** #284(决策 #250 D1):pending 人闸确认(→ unassigned 进 frontier 可认领),列表行展开态入口 */ - confirmPending: (id: string) => Promise; setStatusFilter: (status: string | null) => void; setTypeFilter: (type: string | null) => void; /** #405:切换未归属过滤(重置到第 1 页并重拉;on 时顺带同步徽标计数) */ @@ -203,21 +199,6 @@ export const useWorkUnitStore = create((set, get) => ({ return wu; }, - reviewPassed: async (id, summary, defaultAssigneeId, confirm) => { - await workunitApi.reviewPassed(id, summary, defaultAssigneeId, confirm); - await get().loadWorkUnits(); - }, - - reviewRejected: async (id, reason) => { - await workunitApi.reviewRejected(id, reason); - await get().loadWorkUnits(); - }, - - confirmPending: async (id) => { - await workunitApi.transitionStatus(id, 'unassigned'); - await get().loadWorkUnits(); - }, - setStatusFilter: (status) => { set({ statusFilter: status, page: 1 }); get().loadWorkUnits({ status: status ?? undefined, page: 1 }); diff --git a/apps/web/src/utils/__tests__/gateWriter.test.ts b/apps/web/src/utils/__tests__/gateWriter.test.ts new file mode 100644 index 000000000..417d643f1 --- /dev/null +++ b/apps/web/src/utils/__tests__/gateWriter.test.ts @@ -0,0 +1,116 @@ +// #545:gateWriter 模块级契约——「闸门转移 = 一次 API 调用 + 响应体快照双写落点」写路径只测这一份 +// (ADR 2026-09-15-web-gate-write-module 决策 2 冻结契约);五处宿主只留渲染级断言。 +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { + mockReviewPassed, mockReviewRejected, mockTransitionStatus, + mockApplyWorkunitSnapshot, mockMarkSuggestionsDirty, mockApplyWorkunitEvent, +} = vi.hoisted(() => ({ + mockReviewPassed: vi.fn(), + mockReviewRejected: vi.fn(), + mockTransitionStatus: vi.fn(), + mockApplyWorkunitSnapshot: vi.fn(), + mockMarkSuggestionsDirty: vi.fn(), + mockApplyWorkunitEvent: vi.fn(), +})); + +vi.mock('../../api/workunit', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + workunitApi: { + ...actual.workunitApi, + reviewPassed: mockReviewPassed, + reviewRejected: mockReviewRejected, + transitionStatus: mockTransitionStatus, + }, + }; +}); + +vi.mock('../../stores/channelWorkStore', () => ({ + useChannelWorkStore: { + getState: () => ({ + applyWorkunitSnapshot: mockApplyWorkunitSnapshot, + markSuggestionsDirty: mockMarkSuggestionsDirty, + }), + }, +})); + +vi.mock('../../stores/workunitStore', () => ({ + useWorkUnitStore: { + getState: () => ({ applyWorkunitEvent: mockApplyWorkunitEvent }), + }, +})); + +import { createGateWriter } from '../gateWriter'; +import type { WorkUnit } from '../../api/workunit'; + +const updatedWu = { id: 'wu-1', status: 'done', channelId: 'ch-1' } as unknown as WorkUnit; + +beforeEach(() => { + vi.clearAllMocks(); + mockReviewPassed.mockResolvedValue({ data: updatedWu }); + mockReviewRejected.mockResolvedValue({ data: updatedWu }); + mockTransitionStatus.mockResolvedValue({ data: updatedWu }); +}); + +describe('createGateWriter — 一次 API 调用 + 快照双写落点(ADR 决策 2 冻结契约)', () => { + it('reviewPassed:参数(含 confirm 载荷)原样透传,响应体 WU 双写落点并返回', async () => { + const onUpdated = vi.fn(); + const writer = createGateWriter(onUpdated); + const confirm = { kind: 'analysis', destination: '目标', fog: ['问题1'], tasks: [] } as const; + const result = await writer.reviewPassed('wu-1', '摘要', 'role-1', confirm); + expect(mockReviewPassed).toHaveBeenCalledWith('wu-1', '摘要', 'role-1', confirm); + // 双写:channelWorkStore 快照 + 建议标脏(镜像 status_changed 路由)+ workunitStore 存量行 upsert + expect(mockApplyWorkunitSnapshot).toHaveBeenCalledWith('ch-1', updatedWu); + expect(mockMarkSuggestionsDirty).toHaveBeenCalledWith('ch-1'); + expect(mockApplyWorkunitEvent).toHaveBeenCalledWith(updatedWu, { insertIfMissing: false }); + expect(onUpdated).toHaveBeenCalledWith(updatedWu); + expect(result).toBe(updatedWu); + }); + + it('reviewRejected / confirmPending:同一双写契约(confirmPending 固定 transitionStatus(id, unassigned))', async () => { + const writer = createGateWriter(); + await writer.reviewRejected('wu-1', '质量不达标'); + expect(mockReviewRejected).toHaveBeenCalledWith('wu-1', '质量不达标'); + await writer.confirmPending('wu-1'); + expect(mockTransitionStatus).toHaveBeenCalledWith('wu-1', 'unassigned'); + expect(mockApplyWorkunitSnapshot).toHaveBeenCalledTimes(2); + expect(mockApplyWorkunitEvent).toHaveBeenCalledTimes(2); + }); + + it('store 双写先于宿主 onUpdated(宿主见到的快照已落 store)', async () => { + const order: string[] = []; + mockApplyWorkunitEvent.mockImplementation(() => order.push('store')); + const writer = createGateWriter(() => { order.push('host'); }); + await writer.reviewPassed('wu-1'); + expect(order).toEqual(['store', 'host']); + }); + + it('channelId 缺省(未归属 WU):跳过频道侧落点,workunitStore upsert 仍执行', async () => { + const noChannelWu = { id: 'wu-2', status: 'done', channelId: null } as unknown as WorkUnit; + mockReviewPassed.mockResolvedValue({ data: noChannelWu }); + const writer = createGateWriter(); + await writer.reviewPassed('wu-2'); + expect(mockApplyWorkunitSnapshot).not.toHaveBeenCalled(); + expect(mockMarkSuggestionsDirty).not.toHaveBeenCalled(); + expect(mockApplyWorkunitEvent).toHaveBeenCalledWith(noChannelWu, { insertIfMissing: false }); + }); + + it('无 onUpdated(列表行/工作条宿主):store 双写后正常返回响应体', async () => { + const writer = createGateWriter(); + const result = await writer.reviewPassed('wu-1'); + expect(result).toBe(updatedWu); + }); + + it('API 失败:错误向上传播,任何落点都不被调用(失败不得污染快照)', async () => { + const onUpdated = vi.fn(); + const writer = createGateWriter(onUpdated); + mockReviewPassed.mockRejectedValue(new Error('状态机不允许该迁移')); + await expect(writer.reviewPassed('wu-1')).rejects.toThrow('状态机不允许该迁移'); + expect(mockApplyWorkunitSnapshot).not.toHaveBeenCalled(); + expect(mockMarkSuggestionsDirty).not.toHaveBeenCalled(); + expect(mockApplyWorkunitEvent).not.toHaveBeenCalled(); + expect(onUpdated).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/utils/gateWriter.ts b/apps/web/src/utils/gateWriter.ts new file mode 100644 index 000000000..c51868e53 --- /dev/null +++ b/apps/web/src/utils/gateWriter.ts @@ -0,0 +1,43 @@ +// #545(ADR 2026-09-15-web-gate-write-module,架构评审候选 B1):WU 闸门写路径唯一正本—— +// 「闸门转移 = 一次 API 调用 + 响应体 WU 快照双写落点」。此前同一行为在五处各抄一遍 +// (ChannelDetailPage 工作条 / WorkUnitDrawer / WorkUnitDetailPage / workunitStore 动作 / DeliveryPanel), +// confirm 载荷加一个字段要动五处;收口后动作签名单点承载(leverage)。 +// 落点集合(ADR 决策 2 冻结契约,精确镜像 useChannelWorkStoreSync 对 status_changed 的路由): +// channelWorkStore.applyWorkunitSnapshot + markSuggestionsDirty + workunitStore.applyWorkunitEvent +// (仅存量行 upsert——闸门作用于已存在 WU,不插行)。主动写(本模块)与被动写(SSE,#549)同口径。 +// 宿主本地落点 = 单一可选 sink onUpdated(drawer/详情页的 setWu 等);错误 rethrow, +// toast/弹窗错误展示留在 WuGateActions 与 DeliveryPanel。纯函数核心,非 hook(ADR 决策 1)。 +import { workunitApi, type ReviewConfirmPayload, type WorkUnit } from '../api/workunit'; +import { useChannelWorkStore } from '../stores/channelWorkStore'; +import { useWorkUnitStore } from '../stores/workunitStore'; + +/** 宿主本地落点(ADR 决策 3):store 双写完成后调用;缺省 = 宿主无需本地快照(列表行/工作条) */ +export type GateUpdateSink = (updated: WorkUnit) => void | Promise; + +async function settle(call: Promise<{ data: WorkUnit }>, onUpdated?: GateUpdateSink): Promise { + const wu = (await call).data; // 失败时异常先行——任何落点都不会被污染快照 + if (wu.channelId) { + useChannelWorkStore.getState().applyWorkunitSnapshot(wu.channelId, wu); + useChannelWorkStore.getState().markSuggestionsDirty(wu.channelId); + } + useWorkUnitStore.getState().applyWorkunitEvent(wu, { insertIfMissing: false }); + await onUpdated?.(wu); + return wu; +} + +export interface GateWriter { + /** 审查硬门通过(confirm 结构化评审载荷原样透传,#463 契约不变) */ + reviewPassed: (id: string, summary?: string, assigneeId?: string, confirm?: ReviewConfirmPayload) => Promise; + /** 审查硬门拒绝(reason 可选) */ + reviewRejected: (id: string, reason?: string) => Promise; + /** #284 pending 人闸确认(→ unassigned 进 frontier 可认领) */ + confirmPending: (id: string) => Promise; +} + +export function createGateWriter(onUpdated?: GateUpdateSink): GateWriter { + return { + reviewPassed: (id, summary, assigneeId, confirm) => settle(workunitApi.reviewPassed(id, summary, assigneeId, confirm), onUpdated), + reviewRejected: (id, reason) => settle(workunitApi.reviewRejected(id, reason), onUpdated), + confirmPending: (id) => settle(workunitApi.transitionStatus(id, 'unassigned'), onUpdated), + }; +} diff --git a/docs/adr/2026-09-15-web-gate-write-module.md b/docs/adr/2026-09-15-web-gate-write-module.md index dcf6bc96f..f4ac13554 100644 --- a/docs/adr/2026-09-15-web-gate-write-module.md +++ b/docs/adr/2026-09-15-web-gate-write-module.md @@ -1,7 +1,7 @@ # web 闸门写路径单模块(2026-09-15) > 来源:架构评审候选 B1(#545)grilling 定案。 -> 状态:**accepted**(实现未落地——按票执行)。 +> 状态:**accepted**(#545 已落地:utils/gateWriter.ts + WuGateActions 直消费 + 五宿主重接)。 ## 背景 From 76d156489d3f3cc220f9f8df8936eb024e6bd195 Mon Sep 17 00:00:00 2001 From: dommaker Date: Wed, 16 Sep 2026 00:48:49 +0800 Subject: [PATCH 022/116] =?UTF-8?q?refactor(web):=20per-channel=20need-inp?= =?UTF-8?q?ut=20=E6=8A=95=E5=BD=B1=E6=94=B6=E5=8F=A3=20needInputViewOf=20?= =?UTF-8?q?=E5=8D=95=E6=BA=90=E9=80=89=E6=8B=A9=E5=99=A8=20(#546)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stateItems → 频道页 NEED_INPUT 四种消费形状(待办列表 / wu→mid 映射 / promoted 集 / isWaitingForInput 判定)收为 notificationStore 旁纯函数 needInputViewOf(照 channelWorkStore.wuIdleOf 派生单源模式), ChannelDetailPage 退回订阅并渲染,口径变更只落选择器一处。 投影语义零漂移:reply+本频道过滤、messageId 缺省 fail-closed 不进映射, 前端不从已加载消息反推(#533/#483 边界不破)。 --- apps/web/src/CONTEXT.md | 4 +- apps/web/src/pages/ChannelDetailPage.tsx | 35 ++++--------- .../__tests__/notificationStore.test.ts | 50 ++++++++++++++++++- apps/web/src/stores/notificationStore.ts | 29 +++++++++++ 4 files changed, 91 insertions(+), 27 deletions(-) diff --git a/apps/web/src/CONTEXT.md b/apps/web/src/CONTEXT.md index 7f0df42de..9cd5b0b53 100644 --- a/apps/web/src/CONTEXT.md +++ b/apps/web/src/CONTEXT.md @@ -33,7 +33,7 @@ Web 前端主源码。路由、全局状态、API 客户端、UI 组件、样式 | `usePmoDataStoreSync` | `hooks/usePmoDataStoreSync.ts` | pmoDataStore 接线(#456):useDataPlaneSync 复用,无本域 SSE 事件 → noop handleEvent;ensureFresh = companies + 已驻留 projects 键全量对齐。App 级单点挂载(App.tsx PmoDataSync,同 RequirementChainSync 模式) | | `useAgentRoster` | `hooks/useAgentRoster.ts` | Agent 作战视图私有面:roles 派生 + 执行动态 SSE 写入(execution.step/stream→rosterActivityStore,#348)+ 空闲卡最近完成(#387 批量端点单请求)+ 快照补查;数据面全在 rosterStore | | `rosterActivityStore` / `useRosterActivities` | `stores/rosterActivityStore.ts` | 执行动态 store(#348):step/stream chunk 按 roleId 切片(pushActivity 同 key 刷新尾条、上限 10 条),卡片级订阅 + 卸载 reset(页面私有实时面,不跨挂载残留) | -| `useNotificationStore` | `stores/notificationStore.ts` | 行动中心共享 store(#468 统一行动中心,自通知中心重塑):一个端点 `GET /action-center` 三段——stateItems(reply/review/confirm 状态派生,无已读概念、整体替换)+ notifications(事件持久,#468 起带 wuId/channelId 结构化直链,老数据行回退 link `?highlight=` 正则解析)+ unreadCount;已读动作 markRead/markAllRead/markChannelRead 本地乐观(含 unreadCount 同步递减/归零)+ 后端 POST;SSE 只作失效触发(pushSse/SSE 入列已删),重连对齐 = NotificationBell 直连 `onReconnect` 注册(#415)。**#525 P2-1(2026-09-12,#517 项②)**:`load()` 接 fetchDiscipline 底座(`NOTIFICATION_TTL_MS`=30s TTL + single-flight + seq 守卫,失败不更新锚点;挂载走 TTL、重连/SSE 触发 `maxAgeMs:0` 强拉),NotificationBell 三类 SSE 触发共享 500ms trailing 防抖合并一次重拉(标题闪烁/浏览器通知保持即时) | +| `useNotificationStore` | `stores/notificationStore.ts` | 行动中心共享 store(#468 统一行动中心,自通知中心重塑):一个端点 `GET /action-center` 三段——stateItems(reply/review/confirm 状态派生,无已读概念、整体替换)+ notifications(事件持久,#468 起带 wuId/channelId 结构化直链,老数据行回退 link `?highlight=` 正则解析)+ unreadCount;已读动作 markRead/markAllRead/markChannelRead 本地乐观(含 unreadCount 同步递减/归零)+ 后端 POST;SSE 只作失效触发(pushSse/SSE 入列已删),重连对齐 = NotificationBell 直连 `onReconnect` 注册(#415)。**#525 P2-1(2026-09-12,#517 项②)**:`load()` 接 fetchDiscipline 底座(`NOTIFICATION_TTL_MS`=30s TTL + single-flight + seq 守卫,失败不更新锚点;挂载走 TTL、重连/SSE 触发 `maxAgeMs:0` 强拉),NotificationBell 三类 SSE 触发共享 500ms trailing 防抖合并一次重拉(标题闪烁/浏览器通知保持即时)。**#546**:per-channel need-input 投影单源 `needInputViewOf(stateItems, channelId)`(store 旁纯函数,照 wuIdleOf 模式)——待办列表 / wu→mid 映射(messageId 缺省 fail-closed 不进映射)/ promoted 集 / isWaitingForInput 判定四种形状同出一份,ChannelDetailPage 退回订阅并渲染,口径变更只落选择器 | | `unreadStore` / `useUnreadStoreSync` | `stores/unreadStore.ts` / `hooks/useUnreadStoreSync.ts` | 频道未读面共享 store(#413):per-channel 计数 + `activeChannelId`「正在看」语义(ChannelDetailPage 写入/卸载清空;active 频道不计、进页即清零);SSE channel.message_sent 接线引用计数单例(useChannelList 消费方共享一份,纯实时面无 REST 底故无轮询/重连强刷) | | `RoleCard` | `components/monitoring/RoleCard.tsx` | AgentDashboard 信息全卡(#397,redesign §6 变体 B):四层构成(pill 头行→WU 锚点→可点动态 3 条→错误行),状态色经 data-status(4 态展示键 working/attention/idle/offline,`styles/agent-dashboard.css`)+--st,细分=error 时角色名前红点角标 `.agd-dot-err`;memo + 自订动态切片,chunk 只重渲对应卡、静态卡壳零重渲(#348 契约)。**批次 D-2 项7**(docs/plans/2026-09-ui-interaction-polish.md):WU 锚点/空闲卡「最近完成」/动态行(有当前 WU 时)自 `` 整页跳改 `onOpenWu(wuId)` 开就地抽屉(AgentDashboardPage 自挂 `.ac-drawer-host` 懒加载 WorkUnitDrawer,「↗ 详情页」深链在抽屉 subject 行保留);onOpenWu 是 memo 契约 prop(页面 useCallback 稳定引用);PMO/频道/角色名/无 WU 动态落点仍 `` 跳页 | | `CreateRoleModal` | `components/monitoring/CreateRoleModal.tsx` | 创建角色弹框(#397 §6.4,E8-3 后唯一主路径——原替代的 /setup/roles 僵尸向导页已随 E8 删除):勾选 /workspaces/runtimes + 命名 → channelApi.createAgent;保存=关弹框+onCreated 就地刷新,不跳页。**E8-4 起为创建角色表单唯一正本**(WorkspacePage 内嵌 dialog 已归并):可选 `presetProvider` prop 供调用方锁定 CLI(WorkspacePage 行内「设为角色」)——跳过 runtime 清单拉取、单项固定预选、provider 只读展示;preset 单项命名区不依赖 agent-dashboard.css 的 agd-cr-* 类(该样式仅由 AgentDashboardPage 引入),只用全局 utility 类。**2026-09-10 起候选清单只含本机 CLI 且按 provider 去重**(端点收敛,`nodeId`/`workspaceName` 不再下发,行内 `@ workspace` 节点名展示随之删除);空态区分「拉取失败」与「一个都没装」 | @@ -147,7 +147,7 @@ Web 前端主源码。路由、全局状态、API 客户端、UI 组件、样式 - **底部输入区视觉归组(channel 上下游优化 Phase 4 / AC6,2026-09-14)**:SuggestionChips / `.mc-agent-ack` / ChannelInput 三段收进 `.mc-composer-stack` 统一容器(纯结构包裹,交互不动)——bg-secondary + 顶部 hairline + 水平 padding(--space-6)由容器独揽;`.mc-inputbar` 只留 `position: relative` 作 mention 弹框定位锚(弹框 `left` 随 padding 上移归 0),`.mc-suggest`/`.mc-agent-ack` 去掉各自背景/内边距作容器内平铺分段;`.mc-input-reply` 间距走 --space token 对齐 quote 视觉语言(2px accent 左边线 + 圆角 3px) - **频道主链路前端埋点(#520 测量②,2026-09-12)**:`utils/clientPerf.ts` 常驻开启(read-metrics sink 模式但生产默认开、测试模式默认关),事件经既有 `POST /api/v1/events`(source `web-client`)落 studio-events 事件流。接线三点:ChannelInput.handleSend 守卫通过后 `emitSendClick`;`useChannelMessages` SSE `channel.message_sent` 仅新消息(不在列表的回声不记)`markReceiptArrived`;ChannelDetailPage messages effect(提交后 = 渲染完成)先 `emitPageFirstRender` 再逐条 `emitReceiptRendered`——首拉/翻页/水合的历史消息无标记天然跳过,空频道不发 page_load 属正常。 - **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 不再排除闸门类,排除改面板分区;点条目定位 = 该 WU 当前提问消息高亮(#533:锚点 = stateItems.messageId 后端唯一派生点,投影随 waitingWus 下发,前端 latestQuestionIdByWu 反推已删;缺省 → 不挂回复区/不提升/定位 toast 反馈,全 fail-closed),#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 频道线程最新非人类消息;#533 起收口为「WU 当前提问消息」唯一派生点,频道页回复区/提升/chip 定位全消费它),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` 行动按钮)给「下一个 →」跳下一条频道。**#523(#516 决议③④)人闸催办出声面**:服务端 gate-escalation tier1 补发 SSE `notification.created` 信封 → NotificationBell 监听后 `load()` 重拉 + 经 `utils/browserNotification.ts` 弹浏览器原生通知(granted 直发 / denied·unsupported 静默;default 不主动弹权限请求,首次点开铃铛面板这一既有手势时 `requestPermission`) | +- **行动中心**(#468 统一行动中心,自通知中心 B2-003/B2-004 重塑;#415 重连对齐保留):「需要我做什么」两类事实一个端点 `GET /action-center`——状态派生 stateItems(reply/review/confirm,无已读概念、状态变即消)+ 事件持久 notifications(已读/未读墓碑)+ unreadCount,三段住 `stores/notificationStore`(读态跨组件共享前提——NotificationBell 只是视图)。**不发明第五套信号**:ChannelNeedInputChip(ChannelDetailPage waitingWus = stateItems 本频道 reply 项投影(#546 起投影单源 = notificationStore 旁纯函数 `needInputViewOf`,页面内联派生已删),本地 blocked REST/SSE 维护已删;reply 不再排除闸门类,排除改面板分区;点条目定位 = 该 WU 当前提问消息高亮(#533:锚点 = stateItems.messageId 后端唯一派生点,投影随 waitingWus 下发,前端 latestQuestionIdByWu 反推已删;缺省 → 不挂回复区/不提升/定位 toast 反馈,全 fail-closed),#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 频道线程最新非人类消息;#533 起收口为「WU 当前提问消息」唯一派生点,频道页回复区/提升/chip 定位全消费它),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` 行动按钮)给「下一个 →」跳下一条频道。**#523(#516 决议③④)人闸催办出声面**:服务端 gate-escalation tier1 补发 SSE `notification.created` 信封 → NotificationBell 监听后 `load()` 重拉 + 经 `utils/browserNotification.ts` 弹浏览器原生通知(granted 直发 / denied·unsupported 静默;default 不主动弹权限请求,首次点开铃铛面板这一既有手势时 `requestPermission`) | - **`.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/pages/ChannelDetailPage.tsx b/apps/web/src/pages/ChannelDetailPage.tsx index 05c722654..a66d0479a 100644 --- a/apps/web/src/pages/ChannelDetailPage.tsx +++ b/apps/web/src/pages/ChannelDetailPage.tsx @@ -18,7 +18,7 @@ import { ChannelInput } from '../components/channel/ChannelInput'; import { SuggestionChips, type SuggestionChipItem } from '../components/channel/SuggestionChips'; import { ChannelTopbarMenu } from '../components/channel/ChannelTopbarMenu'; import { ChannelCurrentPmoChip } from '../components/channel/ChannelCurrentPmoChip'; -import { ChannelNeedInputChip, type NeedInputTodo } from '../components/channel/ChannelNeedInputChip'; +import { ChannelNeedInputChip } from '../components/channel/ChannelNeedInputChip'; import { ChannelRail } from '../components/channel/ChannelRail'; import { ChannelActivityRail } from '../components/channel/ChannelActivityRail'; import { WorkUnitDrawer, type DrawerState } from '../components/channel/WorkUnitDrawer'; @@ -30,7 +30,7 @@ import { getSuggestionAction } from '../utils/suggestionActions'; import { ConfirmDialog } from '../components/ui/ConfirmDialog'; import { SkeletonText } from '../components/ui'; import axios from 'axios'; -import { useNotificationStore } from '../stores/notificationStore'; +import { useNotificationStore, needInputViewOf } from '../stores/notificationStore'; import { useUnreadStore } from '../stores/unreadStore'; import { useChannelDataStore, parseChannelMembers } from '../stores/channelDataStore'; import { useChannelWorkStore, parseRequirementPayload, wuIdleOf } from '../stores/channelWorkStore'; @@ -142,13 +142,12 @@ export function ChannelDetailPage() { // #468 设计稿:reply 不再排除闸门类(decision/spec/plan),排除规则改为面板分区解决 // #533:messageId 随投影下发(后端 action-center 唯一派生点)——回复区/提升/chip 定位全消费它, // 前端不再从已加载消息反推(#483 类「提问掉出分页推不出」机制性消除) + // #546:投影四种消费形状(待办列表 / wu→mid 映射 / 提升集+判定 / 定位查找)收口 needInputViewOf + // 单源选择器(notificationStore 旁纯函数),本页退回订阅并渲染,口径变更只落选择器一处 const { onEvent, onReconnect } = useWebSocketContext(); const stateItems = useNotificationStore(s => s.stateItems); - const waitingWus = useMemo(() => - stateItems - .filter(i => i.kind === 'reply' && i.channelId === id) - .map(i => ({ wuId: i.wuId, question: i.waitingQuestion ?? i.scope, messageId: i.messageId })), - [stateItems, id]); + const needInput = useMemo(() => needInputViewOf(stateItems, id), [stateItems, id]); + const { waitingWus, promotedQuestionIds, isWaitingForInput } = needInput; // #528:频道工作面实时接线(页面级单点,ref-count=1)——挂载打底三 slice、SSE 事件路由 // (status_changed 全量直替 / requirement.* upsert / message_sent 标脏)、重连全 slice 强刷, @@ -201,12 +200,7 @@ export function ChannelDetailPage() { // #533:「WU 当前提问消息」唯一派生点 = 后端 action-center(stateItems.messageId 随 waitingWus 投影 // 下发)——前端反推(latestQuestionIdByWu / latestQuestionMessageOf)已删;缺省 → 不挂回复区/ - // 不提升/定位给可见反馈,全部 fail-closed 不回退推导 - const waitingQuestionIdByWu = useMemo(() => { - const map = new Map(); - for (const w of waitingWus) if (w.messageId) map.set(w.wuId, w.messageId); - return map; - }, [waitingWus]); + // 不提升/定位给可见反馈,全部 fail-closed 不回退推导。#546:映射本体在 needInputViewOf 产物上 // #447(spec #441 收尾):「频道当前工单」拣选唯一正本 = 后端建议端点 currentWuId // (前端静态映射 wuSuggestions 与 pickCurrentWu 本地副本已删,杜绝前后端口径分叉)。 @@ -267,15 +261,8 @@ export function ChannelDetailPage() { const pendingActionDef = pendingSuggestionAction ? getSuggestionAction(pendingSuggestionAction.id) : null; - // #279(走查 F4)/#533:挂起 WU 的当前提问消息(后端下发 messageId)若是线程回复(agent 追问),提升到主流可见 - const promotedQuestionIds = useMemo(() => - new Set([...waitingQuestionIdByWu.values()]), [waitingQuestionIdByWu]); - - // F5/#533: 消息是否为关联 WorkUnit 的当前提问(badge/内嵌回复区只落在这一条; - // 判定 = 命中后端下发锚点,messageId 缺省的 WU 恒 false) - const isWaitingForInput = useCallback((msg: ChannelMessage) => { - return !!msg.workUnitId && waitingQuestionIdByWu.get(msg.workUnitId) === msg.id; - }, [waitingQuestionIdByWu]); + // #279(走查 F4)/#533:挂起 WU 的当前提问消息(后端下发 messageId)若是线程回复(agent 追问), + // 提升到主流可见;promotedQuestionIds / isWaitingForInput 均为 needInputViewOf 产物(#546) // #285: agent 消息 inline-code 文件 chip 词表——#403 起读 channelDataStore(与 ChannelInput // 共享一份拉取;按 channelId 键控无跨频道串词表);失败静默降级,不渲染 chip @@ -348,13 +335,13 @@ export function ChannelDetailPage() { // #279(决策 #250 D4)/ #533:chip 点条目 → 定位后端下发的提问 messageId(wu→mid 不再前端反推); // messageId 缺省(fail-closed)→ toast 可见反馈,不静默不翻页 const locateWaitingQuestion = useCallback((wuId: string) => { - const mid = waitingQuestionIdByWu.get(wuId); + const mid = needInput.questionIdByWu.get(wuId); if (!mid) { toast.warning('提问消息缺少定位锚点,无法定位'); return; } locateMessage(mid); - }, [waitingQuestionIdByWu, locateMessage]); + }, [needInput, locateMessage]); // 通知中心点击直达(?highlight=):每个 mid 只消费一次(防消息流更新反复重置高亮); // 首拉未完成(loading)时等下一轮(防空列表误判不可达)。定位动作本体(含翻页/toast 兜底)在模块内 diff --git a/apps/web/src/stores/__tests__/notificationStore.test.ts b/apps/web/src/stores/__tests__/notificationStore.test.ts index 195f8e919..cc0266582 100644 --- a/apps/web/src/stores/__tests__/notificationStore.test.ts +++ b/apps/web/src/stores/__tests__/notificationStore.test.ts @@ -10,7 +10,7 @@ const { mockApi } = vi.hoisted(() => ({ vi.mock('../../api', () => ({ api: mockApi })); -import { useNotificationStore, parseLinkTargets, type Notification, type StateItem } from '../notificationStore'; +import { useNotificationStore, parseLinkTargets, needInputViewOf, type Notification, type StateItem } from '../notificationStore'; function notification(overrides: Partial = {}): Notification { return { @@ -264,3 +264,51 @@ describe('markChannelRead(打开频道即读)', () => { expect(useNotificationStore.getState().unreadCount).toBe(1); }); }); + +// #546:per-channel need-input 投影收口——needInputViewOf 纯函数(stateItems → 四种消费形状单源), +// 口径变更只落此处;ChannelDetailPage 退回订阅并渲染(页面内联 filter/map 派生已删) +describe('needInputViewOf(#546 per-channel need-input 投影单源)', () => { + it('只取 kind=reply 且 channelId 匹配的条目;review/confirm 与他频道条目不进投影', () => { + const view = needInputViewOf([ + stateItem({ wuId: 'WU-1', channelId: 'ch-1', messageId: 'm-1' }), + stateItem({ wuId: 'WU-2', kind: 'review', channelId: 'ch-1', messageId: 'm-2' }), + stateItem({ wuId: 'WU-3', kind: 'confirm', channelId: 'ch-1', messageId: 'm-3' }), + stateItem({ wuId: 'WU-4', channelId: 'ch-2', messageId: 'm-4' }), + ], 'ch-1'); + + expect(view.waitingWus).toEqual([ + { wuId: 'WU-1', question: '选哪个方案?', messageId: 'm-1' }, + ]); + }); + + it('条目 channelId 为 null 不匹配任何频道;channelId 参数缺省 → 空投影(fail-closed)', () => { + const items = [stateItem({ channelId: null, messageId: 'm-1' })]; + expect(needInputViewOf(items, 'ch-1').waitingWus).toEqual([]); + expect(needInputViewOf(items, undefined).waitingWus).toEqual([]); + }); + + it('question = waitingQuestion ?? scope(waitingQuestion 缺省回落 scope 摘要)', () => { + const view = needInputViewOf([ + stateItem({ wuId: 'WU-1', waitingQuestion: undefined, scope: '登录功能' }), + ], 'ch-1'); + expect(view.waitingWus[0].question).toBe('登录功能'); + }); + + it('messageId 缺省的 WU 不进 wu→mid 映射与提升集,isWaitingForInput 恒 false(fail-closed,不反推)', () => { + const view = needInputViewOf([ + stateItem({ wuId: 'WU-1', messageId: undefined }), + stateItem({ wuId: 'WU-2', messageId: 'm-2' }), + ], 'ch-1'); + + // 列表仍含两条(chip/右栏可见),但锚点映射只收有 messageId 的 + expect(view.waitingWus.map(w => w.wuId)).toEqual(['WU-1', 'WU-2']); + expect(view.questionIdByWu.get('WU-1')).toBeUndefined(); + expect(view.questionIdByWu.get('WU-2')).toBe('m-2'); + expect([...view.promotedQuestionIds]).toEqual(['m-2']); + expect(view.isWaitingForInput({ id: 'm-x', workUnitId: 'WU-1' })).toBe(false); + expect(view.isWaitingForInput({ id: 'm-2', workUnitId: 'WU-2' })).toBe(true); + // 无 workUnitId / id 不匹配 → false + expect(view.isWaitingForInput({ id: 'm-2' })).toBe(false); + expect(view.isWaitingForInput({ id: 'm-9', workUnitId: 'WU-2' })).toBe(false); + }); +}); diff --git a/apps/web/src/stores/notificationStore.ts b/apps/web/src/stores/notificationStore.ts index 744881ece..06e9c42b2 100644 --- a/apps/web/src/stores/notificationStore.ts +++ b/apps/web/src/stores/notificationStore.ts @@ -30,6 +30,35 @@ export interface StateItem { since: string; } +/** #546:per-channel need-input 视图投影(四种消费形状单源,照 channelWorkStore.wuIdleOf 派生单源模式)—— + * stateItems → 频道页 NEED_INPUT 面全部消费形状。口径变更(过滤规则 / fail-closed 语义)只落本函数, + * ChannelDetailPage / ChannelNeedInputChip / 右栏 / useChannelStream 消费同一份产物 */ +export interface NeedInputView { + /** 待办列表(chip / 右栏共用):本频道 reply 项,question = waitingQuestion ?? scope */ + waitingWus: Array<{ wuId: string; question: string; messageId?: string }>; + /** wuId → 当前提问 messageId——fail-closed:messageId 缺省的 WU 不进映射(前端不从已加载消息反推,#483 边界) */ + questionIdByWu: ReadonlyMap; + /** 提问提升集(= questionIdByWu values)——线程回复形态的提问消息提升到主流可见 */ + promotedQuestionIds: ReadonlySet; + /** 消息是否为本频道某 WU 的当前提问(badge/内嵌回复区只落这一条) */ + isWaitingForInput: (msg: { id: string; workUnitId?: string | null }) => boolean; +} + +/** stateItems → per-channel need-input 投影。channelId 缺省 / 条目 channelId 为 null → 不匹配(fail-closed) */ +export function needInputViewOf(stateItems: StateItem[], channelId: string | undefined): NeedInputView { + const waitingWus = stateItems + .filter(i => i.kind === 'reply' && i.channelId === channelId) + .map(i => ({ wuId: i.wuId, question: i.waitingQuestion ?? i.scope, messageId: i.messageId })); + const questionIdByWu = new Map(); + for (const w of waitingWus) if (w.messageId) questionIdByWu.set(w.wuId, w.messageId); + return { + waitingWus, + questionIdByWu, + promotedQuestionIds: new Set(questionIdByWu.values()), + isWaitingForInput: (msg) => !!msg.workUnitId && questionIdByWu.get(msg.workUnitId) === msg.id, + }; +} + export interface Notification { id: string; /** 通知类型(旧五类 + #468 新三类 wu_milestone/monitor_alert/incident) */ From 2a04c942a2bb7c809d62a092cdc0d538da5b215f Mon Sep 17 00:00:00 2001 From: dommaker Date: Wed, 16 Sep 2026 01:25:44 +0800 Subject: [PATCH 023/116] =?UTF-8?q?refactor(web):=20ChannelMessageItem=20?= =?UTF-8?q?=E5=85=AC=E5=BC=80=20Props=20=E6=94=B6=E5=8F=A3=E2=80=94?= =?UTF-8?q?=E2=80=94=E6=A8=AA=E5=88=87=E5=80=BC=E7=BB=8F=20ChannelMessageE?= =?UTF-8?q?nv=20Context/store=20=E8=87=AA=E5=8F=96=20(#547)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ChannelMessageEnv 单 Provider Context:onAction/onReply/findMessage/channelId/ 三个开 WU 抽屉回调/onOpenRequirement/onInlineReply/onQuoteClick 共 10 个横切值下发, 消息项 useContext 自取;fileVocabulary 由消息项 useChannelDataStore selector 自取 - 公开 Props 收窄为 message + 5 个 per-message 派生值 + 6 个结构 props(12 字段,原 24); ChannelStreamBody extra 从 Partial 收紧为 6 键封闭 StreamMessageExtra Pick - 页面 renderMessageItem 手喂 6 prop、useCallback 依赖 16→5;env value useMemo 组装 全稳定引用,#322 memo 契约维持(volatile id 集禁入 Context,翻牌走 per-message boolean) - 9 个消息项测试文件迁移到 Provider 包裹;新增 ChannelMessageEnv 单测、flags 类断言、 store 词表染 chip、focus 翻牌零扇出(ChannelDetailPage-focus-fanout)四个测试 - 计划:docs/plans/2026-09-channel-message-env.md;CONTEXT.md 增 ChannelMessageEnv 条目 --- apps/web/src/CONTEXT.md | 1 + .../__tests__/ChannelMessageItem.test.tsx | 91 +++++++------- .../components/channel/ChannelMessageEnv.tsx | 38 ++++++ .../components/channel/ChannelMessageItem.tsx | 83 ++++++------- .../components/channel/ChannelStreamBody.tsx | 15 ++- .../__tests__/ChannelMessageEnv.test.tsx | 28 +++++ .../ChannelMessageItem-actions.test.tsx | 18 ++- .../ChannelMessageItem-flags.test.tsx | 58 +++++++++ .../ChannelMessageItem-footer.test.tsx | 15 ++- .../ChannelMessageItem-image.test.tsx | 5 +- .../ChannelMessageItem-inline.test.tsx | 13 +- .../ChannelMessageItem-layout.test.tsx | 15 ++- .../ChannelMessageItem-markdown.test.tsx | 28 ++++- .../ChannelMessageItem-pending.test.tsx | 15 ++- .../ChannelMessageItem-quote.test.tsx | 23 ++-- .../channel/__tests__/FileRefChip.test.tsx | 57 +++++---- apps/web/src/pages/ChannelDetailPage.tsx | 45 ++++--- .../ChannelDetailPage-focus-fanout.test.tsx | 112 ++++++++++++++++++ docs/plans/2026-09-channel-message-env.md | 34 ++++++ 19 files changed, 536 insertions(+), 158 deletions(-) create mode 100644 apps/web/src/components/channel/ChannelMessageEnv.tsx create mode 100644 apps/web/src/components/channel/__tests__/ChannelMessageEnv.test.tsx create mode 100644 apps/web/src/components/channel/__tests__/ChannelMessageItem-flags.test.tsx create mode 100644 apps/web/src/pages/__tests__/ChannelDetailPage-focus-fanout.test.tsx create mode 100644 docs/plans/2026-09-channel-message-env.md diff --git a/apps/web/src/CONTEXT.md b/apps/web/src/CONTEXT.md index 9cd5b0b53..fad64e4b1 100644 --- a/apps/web/src/CONTEXT.md +++ b/apps/web/src/CONTEXT.md @@ -54,6 +54,7 @@ Web 前端主源码。路由、全局状态、API 客户端、UI 组件、样式 | `useStreamFollow` / `useChannelCardActions` | `hooks/` | 频道流滚动状态机(#322 自 ChannelDetailPage 整块搬移)/ 卡片 action 路由(dispatch 单一入口;#352 起人审提案分支经 PROPOSAL_ACTION_INDEX 参数化调用 proposalCardConfigs.exec) | | `useMessageLocate` | `hooks/useMessageLocate.ts` | 消息定位单入口(#530,架构评审 2026-09-14 候选 2):mid→可见 完整语义——`locate(mid)` 已加载 → 根锚解析展开收起线程 + 高亮(滚动 + 2s 消退 + unpin 全内化),未加载 → #439 翻页定位循环(上限 10 页)翻到底/超限 → toast 兜底;#533 起对外只暴露 `locate(mid)`——wu→mid 锚点收口后端 action-center 下发 messageId,业务口径 find 退休,locateBy 收回模块内私有(翻页期间按新快照重评估);防重入单飞台账(同 key 跳过、后到者赢)。useStreamFollow 底座不动(unpin 由它提供,模块只负责调用时机)。入参产物(unpin/virtualizer/messageToItemIndex/setCollapsedThreads)#531 起由 useChannelStream 契约供给,平级单向依赖 | | `useChannelStream` / `ChannelStreamBody` | `hooks/useChannelStream.ts` / `components/channel/ChannelStreamBody.tsx` | 频道消息流组合层(#531,架构评审 2026-09-14 候选 3):「messages 到手之后到渲染之前」全部装配一个 hook——六根线顺序不变量由模块结构承载(deriveStreamView memo → buildMessageToItemIndex → streamHead ResizeObserver → useStreamFollow → firstVisibleMid 三 kind 反解 → syncPruning 仅虚拟路径),折叠 UI 状态(usePersistentStreamUI)与三 toggle 回调一并收编;入参 = channelId + 取数产物(取数 useChannelMessages 留页面)+ promotedQuestionIds/isWaitingForInput;返回契约 = 结构(streamHeadRef/items/completedCount/showCompleted)+ 滚动跟随(streamRef/handleStreamScroll/回底浮钮三件套/handleLoadMore/ownSendPendingRef)+ 特性消费产物(messageToItemIndex/virtualizer/virtualEnabled/setCollapsedThreads——locate/键盘导航/highlight 即插即用口)+ 结构渲染产物(streamInnerRef/streamHeadH/三 toggle,ChannelStreamBody 专属)。ChannelStreamBody 拥有 items→DOM 全部结构分支(virtual/non-virtual + spacer/translateY + measureElement + renderStreamItem 三 kind 分派 + skeleton 占位含骨架高亮),props = stream + renderMessage + highlightId。底座六件与取数一律不动 | +| `ChannelMessageEnv` | `components/channel/ChannelMessageEnv.tsx` | 频道消息环境 Context(#547,架构评审 2026-09-15 候选 B3,grilling 已决):横切值(onAction/onReply/findMessage/channelId/三个开 WU 抽屉回调/onOpenRequirement/onInlineReply/onQuoteClick)单 Provider 下发,ChannelMessageItem `useChannelMessageEnv()` 自取——公开 Props 收窄为 message + 5 个 per-message 派生值(waitingForInput/wuChangedFiles/highlight/fresh/focused)+ 6 个结构 props;fileVocabulary 不进 Context,消息项 `useChannelDataStore` selector 自取;ChannelStreamBody extra 收紧为 6 键封闭 `StreamMessageExtra` Pick(Partial 逃生口已删)。**#322 契约不变量**:Provider value 由 ChannelDetailPage useMemo 组装且成员全稳定引用(useCallback/镜像 ref),identity 不变 = 零扇出;highlightId/focusedId/freshMsgIds 等 volatile state 禁入本 Context(翻牌走 per-message boolean props),render-count + focus-fanout 测试兜底 | | `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,原整条静默消失;#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 同排) | diff --git a/apps/web/src/components/__tests__/ChannelMessageItem.test.tsx b/apps/web/src/components/__tests__/ChannelMessageItem.test.tsx index 86baff09e..e5827eaa8 100644 --- a/apps/web/src/components/__tests__/ChannelMessageItem.test.tsx +++ b/apps/web/src/components/__tests__/ChannelMessageItem.test.tsx @@ -19,7 +19,16 @@ vi.mock('../channel/KnowledgeConfirmCard', () => ({ KnowledgeConfirmCard: () => vi.mock('../channel/ConvertToTaskDialog', () => ({ ConvertToTaskDialog: () => null })); import { ChannelMessageItem } from '../channel/ChannelMessageItem'; +import { ChannelMessageEnvProvider } from '../channel/ChannelMessageEnv'; +import type { ChannelMessageEnv } from '../channel/ChannelMessageEnv'; import type { ChannelMessage } from '../../api/channel'; +import type { ReactElement } from 'react'; + +// #547:onAction/onInlineReply/onOpenWorkUnit 等横切值改经 Context 下发——包 Provider 渲染; +// 消息本体与 per-message 派生值(waitingForInput/highlight)仍走 props +const wrap = (node: ReactElement, env: Partial = {}) => ( + {node} +); const baseMessage: ChannelMessage = { id: 'msg-1', @@ -40,13 +49,13 @@ beforeEach(() => { describe('ChannelMessageItem — F5 waiting 内嵌回复区', () => { // E1:消息头「等待回复」badge 已删——内嵌回复框本身是流内信号与行动点,待办强信号唯一位 = 顶栏 chip it('waitingForInput → 渲染内嵌回复框(流内信号本体),无消息头 badge', () => { - render(); + render(wrap(, { onInlineReply: vi.fn() })); expect(screen.getByLabelText('回复 wu-1')).toBeInTheDocument(); expect(screen.queryByText('等待回复')).not.toBeInTheDocument(); }); it('非等待态不渲染内嵌回复框', () => { - render(); + render(wrap(, { onInlineReply: vi.fn() })); expect(screen.queryByLabelText('回复 wu-1')).not.toBeInTheDocument(); }); @@ -58,7 +67,7 @@ describe('ChannelMessageItem — F5 waiting 内嵌回复区', () => { const onInlineReply = vi.fn().mockImplementation( () => new Promise(resolve => { resolveReply = resolve; }), ); - render(); + render(wrap(, { onInlineReply })); expect(screen.getByLabelText('回复 wu-1')).toBeInTheDocument(); fireEvent.change(screen.getByLabelText('回复 wu-1'), { target: { value: '用 OAuth' } }); fireEvent.click(screen.getByText('回复')); @@ -77,7 +86,7 @@ describe('ChannelMessageItem — F5 waiting 内嵌回复区', () => { const onInlineReply = vi.fn().mockImplementation( () => new Promise((_, reject) => { rejectReply = reject; }), ); - render(); + render(wrap(, { onInlineReply })); expect(screen.getByLabelText('回复 wu-1')).toBeInTheDocument(); fireEvent.change(screen.getByLabelText('回复 wu-1'), { target: { value: '用 OAuth' } }); fireEvent.click(screen.getByText('回复')); @@ -95,7 +104,7 @@ describe('ChannelMessageItem — F5 waiting 内嵌回复区', () => { // #276 AC1:文案不再含未来时假承诺「WorkUnit 将继续执行」 it('#276 文案不再含「WorkUnit 将继续执行」假承诺(已回复文本 + placeholder)', () => { - render(); + render(wrap(, { onInlineReply: vi.fn() })); expect(screen.queryByText(/WorkUnit 将继续执行/)).not.toBeInTheDocument(); const input = screen.getByPlaceholderText(/直接在此回复/) as HTMLInputElement; expect(input).toBeTruthy(); @@ -108,7 +117,7 @@ describe('ChannelMessageItem — F5 waiting 内嵌回复区', () => { const onInlineReply = vi.fn().mockImplementation( () => new Promise(resolve => { resolveReply = resolve; }), ); - render(); + render(wrap(, { onInlineReply })); fireEvent.change(screen.getByLabelText('回复 wu-1'), { target: { value: '用 OAuth' } }); fireEvent.keyDown(screen.getByLabelText('回复 wu-1'), { key: 'Enter' }); await waitFor(() => expect(onInlineReply).toHaveBeenCalledTimes(1)); @@ -126,16 +135,16 @@ describe('ChannelMessageItem — F5 waiting 内嵌回复区', () => { const onInlineReply = vi.fn().mockImplementation( () => new Promise(resolve => { resolveReply = resolve; }), ); - const { rerender } = render(); + const { rerender } = render(wrap(, { onInlineReply })); fireEvent.change(screen.getByLabelText('回复 wu-1'), { target: { value: '用 OAuth' } }); fireEvent.click(screen.getByText('回复')); resolveReply(); expect(await screen.findByText(/已回复/)).toBeInTheDocument(); // WU 复活 -> 等待区整体收起 - rerender(); + rerender(wrap(, { onInlineReply })); expect(screen.queryByText(/已回复/)).not.toBeInTheDocument(); // WU 再度挂起(无新提问,仍落本条)-> 回到等待回复态,表单可再次使用 - rerender(); + rerender(wrap(, { onInlineReply })); expect(screen.getByLabelText('回复 wu-1')).toBeInTheDocument(); expect(screen.queryByText(/已回复/)).not.toBeInTheDocument(); }); @@ -146,7 +155,7 @@ describe('ChannelMessageItem — F5 waiting 内嵌回复区', () => { const onInlineReply = vi.fn().mockImplementation( () => new Promise((_, reject) => { rejectReply = reject; }), ); - render(); + render(wrap(, { onInlineReply })); fireEvent.change(screen.getByLabelText('回复 wu-1'), { target: { value: '用 OAuth' } }); fireEvent.click(screen.getByText('回复')); await waitFor(() => expect(screen.getByText('发送中…')).toBeInTheDocument()); @@ -157,7 +166,7 @@ describe('ChannelMessageItem — F5 waiting 内嵌回复区', () => { // #279(决策 #250 D4):顶栏 chip 定位高亮——highlight prop 挂 mc-msg-highlight class it('highlight prop → 消息根元素带 mc-msg-highlight class', () => { - const { container } = render(); + const { container } = render(wrap()); expect(container.querySelector('.mc-msg.mc-msg-highlight')).toBeTruthy(); expect(container.querySelector('[data-message-id="msg-1"]')).toBeTruthy(); }); @@ -167,35 +176,35 @@ describe('ChannelMessageItem — §5.7 WU/PMO chip', () => { // 2026-09 视觉批次1 ⑤:WU chip 旁 ↗ 直跳钮已删(同目的地不双入口),chip 自承载开抽屉 it('有 workUnitId 时渲染 WU chip,点击经 onOpenWorkUnit 开任务抽屉', () => { const onOpenWorkUnit = vi.fn(); - render(); + render(wrap(, { onOpenWorkUnit })); fireEvent.click(screen.getByTitle('打开任务详情:wu-1')); expect(onOpenWorkUnit).toHaveBeenCalledWith('wu-1'); }); it('无 workUnitId 时不渲染 WU chip', () => { - render(); + render(wrap(, { onOpenWorkUnit: vi.fn() })); expect(screen.queryByTitle(/^打开任务详情/)).not.toBeInTheDocument(); }); it('meta.pmoId 存在时渲染 PMO chip,点击跳 /pmo/project/:id', () => { const msg: ChannelMessage = { ...baseMessage, meta: JSON.stringify({ pmoId: 'proj-1' }) }; - render(); + render(wrap()); fireEvent.click(screen.getByTitle('打开项目详情')); expect(mockNavigate).toHaveBeenCalledWith('/pmo/project/proj-1'); }); it('老消息 meta 无 pmoId 时不渲染 PMO chip', () => { - render(); + render(wrap()); expect(screen.queryByTitle('打开项目详情')).not.toBeInTheDocument(); }); it('meta 缺失/非法 JSON 时不渲染 PMO chip(防御)', () => { const noMeta: ChannelMessage = { ...baseMessage, meta: undefined }; - render(); + render(wrap()); expect(screen.queryByTitle('打开项目详情')).not.toBeInTheDocument(); const badMeta: ChannelMessage = { ...baseMessage, meta: '{broken' }; - render(); + render(wrap()); expect(screen.queryByTitle('打开项目详情')).not.toBeInTheDocument(); }); }); @@ -211,7 +220,7 @@ describe('ChannelMessageItem — #264 object meta 双型兼容(线上 REST/SSE cardData: { entries: [{ id: 'k-1', title: 'session 过期未刷新导致 401', type: 'pitfall' }] }, }, }; - render(); + render(wrap()); // 卡片渲染出审批按钮;纯文本回退时只显示 content,无按钮 expect(screen.getByText('通过')).toBeTruthy(); expect(screen.getByText('拒绝')).toBeTruthy(); @@ -227,13 +236,13 @@ describe('ChannelMessageItem — #264 object meta 双型兼容(线上 REST/SSE cardData: { entries: [{ id: 'k-1', title: 'session 过期未刷新导致 401', type: 'pitfall' }] }, }), }; - render(); + render(wrap()); expect(screen.getByText('通过')).toBeTruthy(); }); it('meta 为 object 且含 pmoId 时渲染 PMO chip,点击跳 /pmo/project/:id', () => { const msg: ChannelMessage = { ...baseMessage, meta: { pmoId: 'proj-9' } }; - render(); + render(wrap()); fireEvent.click(screen.getByTitle('打开项目详情')); expect(mockNavigate).toHaveBeenCalledWith('/pmo/project/proj-9'); }); @@ -252,7 +261,7 @@ describe('ChannelMessageItem — #267 NEED_INPUT 结构化选项卡(meta.optio ]; it('waitingForInput + object meta 带 options → 渲染选项卡而非纯文本列表', () => { - render(); + render(wrap(, { onInlineReply: vi.fn() })); expect(screen.getByText('studio')).toBeTruthy(); expect(screen.getByText('/root/projects/studio-config')).toBeTruthy(); expect(screen.getByText('自定义…')).toBeTruthy(); @@ -264,7 +273,7 @@ describe('ChannelMessageItem — #267 NEED_INPUT 结构化选项卡(meta.optio it('点选选项 → onInlineReply(message, value) 走现有内嵌回复通道', async () => { const onInlineReply = vi.fn(); const msg = optionsMessage({ options: OPTIONS }); - render(); + render(wrap(, { onInlineReply })); // #276:点击后经 async 微任务置位状态,act 包裹 flush 微任务 await act(async () => { fireEvent.click(screen.getByText('studio-config')); @@ -275,7 +284,7 @@ describe('ChannelMessageItem — #267 NEED_INPUT 结构化选项卡(meta.optio // #276:选项卡发送成功 -> needSent 置位 -> 收起为已回复提示 it('#276 选项卡发送成功 -> 收起为已回复提示', async () => { const onInlineReply = vi.fn().mockResolvedValue(undefined); - render(); + render(wrap(, { onInlineReply })); fireEvent.click(screen.getByText('studio')); // await resolve 后:needSent=true -> 已回复,选项卡收起 expect(await screen.findByText(/已回复/)).toBeTruthy(); @@ -288,7 +297,7 @@ describe('ChannelMessageItem — #267 NEED_INPUT 结构化选项卡(meta.optio const onInlineReply = vi.fn().mockImplementation( () => new Promise((_, reject) => { rejectReply = reject; }), ); - render(); + render(wrap(, { onInlineReply })); fireEvent.click(screen.getByText('studio')); // 发送中:选项禁用 await waitFor(() => expect((screen.getByText('studio-config').closest('button') as HTMLButtonElement).disabled).toBe(true)); @@ -299,19 +308,19 @@ describe('ChannelMessageItem — #267 NEED_INPUT 结构化选项卡(meta.optio }); it('string 形态 meta 带 options 同样渲染(存量形态回归)', () => { - render(); + render(wrap(, { onInlineReply: vi.fn() })); expect(screen.getByText('studio')).toBeTruthy(); }); it('无 options 时保持现有单行回复框 fallback', () => { - render(); + render(wrap(, { onInlineReply: vi.fn() })); expect(screen.getByPlaceholderText(/直接在此回复/)).toBeTruthy(); expect(screen.queryByText('自定义…')).toBeNull(); }); it('options 元素非法(缺 label)时防御性过滤,不崩溃', () => { const msg = optionsMessage({ options: [{ description: '/x' }, { label: 'studio' }] }); - render(); + render(wrap(, { onInlineReply: vi.fn() })); expect(screen.getByText('studio')).toBeTruthy(); expect(screen.queryByText('/x')).toBeNull(); }); @@ -319,13 +328,13 @@ describe('ChannelMessageItem — #267 NEED_INPUT 结构化选项卡(meta.optio describe('ChannelMessageItem — #241 footer WU 链接截短显示', () => { it('长 UUID 截短为前 8 位 + …,title 保留全量 id', () => { const uuid = '160eeee8-aaaa-bbbb-cccc-dddddddddddd'; - render(); + render(wrap(, { onOpenWorkUnit: vi.fn() })); const link = screen.getByTitle(`打开任务详情:${uuid}`); expect(link.textContent).toBe('160eeee8… ›'); }); it('短 id(WU-N 形态)原样显示不截短', () => { - render(); + render(wrap(, { onOpenWorkUnit: vi.fn() })); expect(screen.getByText('wu-1 ›')).toBeTruthy(); }); }); @@ -343,10 +352,11 @@ describe('ChannelMessageItem — #278 D5 机制派生回执 = 系统播报形态 it('Studio 署名无卡消息(带 workUnitId 的派生回执)渲染为 mc-msg-system 且无消息头', () => { const { container } = render( - , + wrap( + , + ), ); expect(container.querySelector('.mc-msg-system')).toBeTruthy(); expect(container.querySelector('.mc-msg-head')).toBeNull(); @@ -356,13 +366,14 @@ describe('ChannelMessageItem — #278 D5 机制派生回执 = 系统播报形态 it('卡片消息不归系统播报(全宽卡片形态优先)', () => { const { container } = render( - , + wrap( + , + ), ); expect(container.querySelector('.mc-msg-system')).toBeNull(); expect(container.querySelector('.mc-msg-card')).toBeTruthy(); diff --git a/apps/web/src/components/channel/ChannelMessageEnv.tsx b/apps/web/src/components/channel/ChannelMessageEnv.tsx new file mode 100644 index 000000000..513ff7671 --- /dev/null +++ b/apps/web/src/components/channel/ChannelMessageEnv.tsx @@ -0,0 +1,38 @@ +// 频道消息环境(#547,架构评审 2026-09-15 候选 B3,grilling 已决): +// 横切值(卡片 action 路由 / 回复 / 消息查找 / 抽屉开启回调 / quote 定位)单 Provider 下发, +// ChannelMessageItem 经 useChannelMessageEnv() 自取——公开 Props 收窄到消息本体 + per-message 派生值, +// 页面 renderMessageItem 不再逐 prop 手喂横切值。 +// #322 契约不变量:Provider 的 value 必须全部由稳定引用组成(useMemo 组装 + useCallback 成员), +// identity 不变 = 零重渲扇出;高频 volatile state(highlightId/focusedId/freshMsgIds)禁止进本 Context +// (value 一变全量消息项扇出重渲),per-message boolean 仍走 props(memo 友好)。 +// fileVocabulary 不进本 Context——消息项直接复用 useChannelDataStore 既有 selector 模式自取(回调不进 store)。 +import { createContext, useContext } from 'react'; +import type { ChannelMessage } from '../../api/channel'; + +export interface ChannelMessageEnv { + /** 统一卡片 action 路由(useChannelCardActions dispatch 单一入口) */ + onAction: (messageId: string, action: string) => void; + onReply?: (message: ChannelMessage) => void; + findMessage?: (id: string) => ChannelMessage | undefined; + channelId?: string; + /** Mission Control: 打开右抽屉(WorkUnit 详情) */ + onOpenWorkUnit?: (workUnitId: string) => void; + /** #284(决策 #250 D6):analysis_confirm 接力卡「去确认」 */ + onOpenWorkUnitConfirm?: (workUnitId: string) => void; + /** #467:plan_ruling 裁决轮接力卡「去裁决」 */ + onOpenWorkUnitRuling?: (workUnitId: string) => void; + onOpenRequirement?: (reqId: string) => void; + /** F5: NEED_INPUT 卡片内嵌回复;#276:返回 Promise 以便按真实成功置位「已回复」 */ + onInlineReply?: (message: ChannelMessage, content: string) => void | Promise; + /** channel 上下游优化 Phase 1(AC1):quote 引用块点击定位上游消息 */ + onQuoteClick?: (messageId: string) => void; +} + +const ChannelMessageEnvContext = createContext(null); + +export const ChannelMessageEnvProvider = ChannelMessageEnvContext.Provider; + +/** 缺 Provider(不应出现于生产——频道页装配层必挂)→ null,消息项各横切行为 fail-closed 不渲染 */ +export function useChannelMessageEnv(): ChannelMessageEnv | null { + return useContext(ChannelMessageEnvContext); +} diff --git a/apps/web/src/components/channel/ChannelMessageItem.tsx b/apps/web/src/components/channel/ChannelMessageItem.tsx index 2f5139805..7ca655e45 100644 --- a/apps/web/src/components/channel/ChannelMessageItem.tsx +++ b/apps/web/src/components/channel/ChannelMessageItem.tsx @@ -4,7 +4,9 @@ // (Studio 无卡非等待消息)淡色小字一行(#437 起左对齐随文档流,不再居中)/ 卡片全宽不参与分侧;compact 省略重复头;双侧 @name 染 mention chip。 import { memo, useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import type { ChannelFileVocabulary, ChannelMessage } from '../../api/channel'; +import type { ChannelMessage } from '../../api/channel'; +import { useChannelDataStore } from '../../stores/channelDataStore'; +import { useChannelMessageEnv, type ChannelMessageEnv } from './ChannelMessageEnv'; import { toast } from '../../utils/toast'; import { AuthorAvatar } from './AuthorAvatar'; import { FileRefChip } from './FileRefChip'; @@ -24,54 +26,40 @@ import { parseMeta, type CardMeta, type MetaOption } from '../../utils/messageMe import { parseSeverityPrefix } from '../../utils/severityPrefix'; import { useImeEnterGuard } from '../../hooks/useImeEnterGuard'; -interface Props { +// #547(架构评审 2026-09-15 候选 B3,grilling 已决):公开 Props 收窄到消息本体 + +// per-message 派生值(5)+ 结构 props(6,ChannelStreamBody 经封闭 extra 通道喂入); +// 11 个横切值(onAction/onReply/findMessage/channelId/抽屉回调/onInlineReply/onQuoteClick) +// 经 ChannelMessageEnv Context 自取,fileVocabulary 经 useChannelDataStore selector 自取。 +export interface ChannelMessageItemProps { message: ChannelMessage; - onAction: (messageId: string, action: string) => void; - onReply?: (message: ChannelMessage) => void; - findMessage?: (id: string) => ChannelMessage | undefined; - channelId?: string; - /** AC-C3: thread rendering */ - isThreadAnchor?: boolean; - threadReplyCount?: number; - isExpanded?: boolean; - /** #322:稳定 props 契约——收锚点 id(父组件直传 useCallback 的 toggleThread,不再内联闭包) */ - onToggleThread?: (anchorId: string) => void; - isThreadReply?: boolean; /** F5: 关联 WorkUnit 挂起等待人类回复(NEED_INPUT) */ waitingForInput?: boolean; - /** Mission Control: 打开右抽屉(WorkUnit 详情 / REQ 全链路) */ - onOpenWorkUnit?: (workUnitId: string) => void; - /** #284(决策 #250 D6):analysis_confirm 接力卡「去确认」——开 WU 抽屉并自动弹确认对话框 */ - onOpenWorkUnitConfirm?: (workUnitId: string) => void; - /** #467:plan_ruling 裁决轮接力卡「去裁决」——开 WU 抽屉并自动弹 PlanRulingDialog */ - onOpenWorkUnitRuling?: (workUnitId: string) => void; - onOpenRequirement?: (reqId: string) => void; - /** F5: NEED_INPUT 卡片内嵌回复(与回复按钮同链路:sendMessage + replyToId); - * #276:返回 Promise 以便按真实成功置位「已回复」(失败可重试,不发假承诺) */ - onInlineReply?: (message: ChannelMessage, content: string) => void | Promise; - /** #285: agent 消息 inline-code 文件 chip 词表;经 MarkdownBody renderInlineCode 挂载(#271) */ - fileVocabulary?: ChannelFileVocabulary; /** #285 AC4: 该消息所属 WU 的产出/修改文件集(chip 第一优先词表,绝对路径;空/缺省 → 仅用候选集词表) */ wuChangedFiles?: string[]; - /** #277(决策 #248 D2):连续合并——省略重复头(头像/署名/时间),动作保留 */ - compact?: boolean; /** #279(决策 #250 D4):顶栏待办 chip 定位高亮 */ highlight?: boolean; - /** channel 上下游优化 Phase 1(AC1):quote 引用块点击定位上游消息—— - * 提供且父消息已加载(findMessage 命中)时 quote button 化;父组件须传 useCallback 稳定引用(#322 契约) */ - onQuoteClick?: (messageId: string) => void; /** 批次 E-3:SSE 新到达消息渐隐高亮(.mc-msg-new,accent-dim 底色,页面 2s 后自清) */ fresh?: boolean; /** Phase 3(AC5):j/k 键盘导航焦点环(.mc-msg-focused,accent outline;布尔按消息变化只影响焦点迁移的两条) */ focused?: boolean; + // —— 结构 props(#547 封闭集:ChannelStreamBody 的 StreamMessageExtra 只允许这 6 键)—— + /** AC-C3: thread rendering */ + isThreadAnchor?: boolean; + threadReplyCount?: number; + isExpanded?: boolean; + /** #322:稳定 props 契约——收锚点 id(父组件直传 useCallback 的 toggleThread,不再内联闭包) */ + onToggleThread?: (anchorId: string) => void; + /** #277(决策 #248 D2):连续合并——省略重复头(头像/署名/时间),动作保留 */ + compact?: boolean; + isThreadReply?: boolean; } function renderCard( meta: CardMeta, message: ChannelMessage, - onAction: Props['onAction'], - onOpenWorkUnitConfirm: Props['onOpenWorkUnitConfirm'], - onOpenWorkUnitRuling: Props['onOpenWorkUnitRuling'], + onAction: ChannelMessageEnv['onAction'], + onOpenWorkUnitConfirm: ChannelMessageEnv['onOpenWorkUnitConfirm'], + onOpenWorkUnitRuling: ChannelMessageEnv['onOpenWorkUnitRuling'], ) { switch (meta.cardType) { case 'requirements_doc': @@ -97,15 +85,28 @@ function renderCard( } // #322:React.memo 化——父组件已建立稳定 props 契约(useCallback/提升 + 镜像 ref), -// step 事件/无关 state 变化不再重渲既有消息项(render-count 测试兜底) +// step 事件/无关 state 变化不再重渲既有消息项(render-count 测试兜底)。 +// #547:横切值改经 ChannelMessageEnv Context——env value 全稳定引用时 context 不触发扇出, +// memo 边界不变;per-message boolean 翻牌只命中个别消息(focus-fanout 测试兜底)。 export const ChannelMessageItem = memo(function ChannelMessageItem({ - message, onAction, onReply, findMessage, channelId, - isThreadAnchor, threadReplyCount, isExpanded, onToggleThread, isThreadReply, - waitingForInput, onOpenWorkUnit, onOpenWorkUnitConfirm, onOpenWorkUnitRuling, onOpenRequirement, onInlineReply, fileVocabulary, wuChangedFiles, compact, highlight, onQuoteClick, fresh, focused, -}: Props) { + message, + waitingForInput, wuChangedFiles, highlight, fresh, focused, + isThreadAnchor, threadReplyCount, isExpanded, onToggleThread, compact, isThreadReply, +}: ChannelMessageItemProps) { + // #547:横切值自取——Context(回调/查找/channelId)+ 数据面 store(fileVocabulary,selector 既有模式) + const env = useChannelMessageEnv(); + const { + onAction, onReply, findMessage, channelId, + onOpenWorkUnit, onOpenWorkUnitConfirm, onOpenWorkUnitRuling, onOpenRequirement, + onInlineReply, onQuoteClick, + } = env ?? {}; + // #285: agent 消息 inline-code 文件 chip 词表;按 channelId 键控(无跨频道串词表); + // 缺 Provider/词表未加载 → undefined,chip 不渲染(失败静默降级语义不变) + const fileVocabulary = useChannelDataStore(s => (channelId ? s.vocabulary[channelId] : undefined)); const isHuman = message.authorType === 'human'; const meta = parseMeta(message.meta); - const card = renderCard(meta, message, onAction, onOpenWorkUnitConfirm, onOpenWorkUnitRuling); + // 缺 Provider(env=null)→ onAction 缺省 → 卡片不渲染(fail-closed;生产装配层必挂 Provider) + const card = onAction ? renderCard(meta, message, onAction, onOpenWorkUnitConfirm, onOpenWorkUnitRuling) : null; const parentMessage = message.replyToId && findMessage ? findMessage(message.replyToId) : undefined; const [convertOpen, setConvertOpen] = useState(false); const [needDraft, setNeedDraft] = useState(''); @@ -154,8 +155,8 @@ export const ChannelMessageItem = memo(function ChannelMessageItem({ const handleConverted = () => { setConvertOpen(false); - // Parent will refresh messages via onAction - onAction(message.id, 'converted'); + // Parent will refresh messages via onAction(canConvert 要求 channelId,即 Provider 在场,onAction 必有) + onAction?.(message.id, 'converted'); }; // F5: 卡片内嵌回复 —— 走与回复按钮完全相同的链路(sendMessage + replyToId), diff --git a/apps/web/src/components/channel/ChannelStreamBody.tsx b/apps/web/src/components/channel/ChannelStreamBody.tsx index 75a5439e0..486c5ccca 100644 --- a/apps/web/src/components/channel/ChannelStreamBody.tsx +++ b/apps/web/src/components/channel/ChannelStreamBody.tsx @@ -4,16 +4,23 @@ // (消息/线程组/告警组 × degraded × 日期分隔站位)、skeleton 占位行(含 highlight 目标为 // 骨架时的高亮)。整块自 ChannelDetailPage 渲染段搬移(PURE_MOVE 行为零变化); // 消息卡渲染本体(renderMessage)与 highlight 目标由页面经 props 注入。 -import { useCallback, type ComponentProps, type ReactNode } from 'react'; +import { useCallback, type ReactNode } from 'react'; import type { ChannelMessage } from '../../api/channel'; import type { ChannelStream } from '../../hooks/useChannelStream'; import { streamDateStrOf, streamDateLabelOf, type StreamItem } from '../../utils/streamView'; -import type { ChannelMessageItem } from './ChannelMessageItem'; +import type { ChannelMessageItemProps } from './ChannelMessageItem'; -/** 页面 renderMessageItem 的原签名(extra = ChannelMessageItem 可选 props 覆盖) */ +/** #547:extra 逃生口收口——只含 6 个结构字段的封闭 Pick(deriveStreamView 产物经本通道喂入), + * 编译期拒绝任意 prop 注入(原 Partial 已删);横切值走 ChannelMessageEnv,不走本通道 */ +export type StreamMessageExtra = Pick< + ChannelMessageItemProps, + 'isThreadAnchor' | 'threadReplyCount' | 'isExpanded' | 'onToggleThread' | 'compact' | 'isThreadReply' +>; + +/** 页面 renderMessageItem 的签名(extra = 封闭结构 props 覆盖,见上) */ export type RenderStreamMessage = ( msg: ChannelMessage, - extra?: Partial>, + extra?: StreamMessageExtra, ) => ReactNode; /** Phase 3(AC3):告警组摘要行的首末消息时间(HH:MM;解析失败回退空串) */ diff --git a/apps/web/src/components/channel/__tests__/ChannelMessageEnv.test.tsx b/apps/web/src/components/channel/__tests__/ChannelMessageEnv.test.tsx new file mode 100644 index 000000000..bdec87cd3 --- /dev/null +++ b/apps/web/src/components/channel/__tests__/ChannelMessageEnv.test.tsx @@ -0,0 +1,28 @@ +// ChannelMessageEnv(#547):Context 模块本体契约——缺 Provider → null(消息项各横切行为 fail-closed); +// 挂 Provider → value 原样下发(消息项 useChannelMessageEnv() 自取横切值)。 +import { describe, it, expect, vi } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { ChannelMessageEnvProvider, useChannelMessageEnv, type ChannelMessageEnv } from '../ChannelMessageEnv'; + +describe('ChannelMessageEnv(#547 频道消息环境)', () => { + it('缺 Provider → useChannelMessageEnv 返回 null(fail-closed)', () => { + const { result } = renderHook(() => useChannelMessageEnv()); + expect(result.current).toBeNull(); + }); + + it('挂 Provider → value 原样下发(引用不变)', () => { + const env: ChannelMessageEnv = { + onAction: vi.fn(), + onReply: vi.fn(), + channelId: 'ch-1', + onQuoteClick: vi.fn(), + }; + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useChannelMessageEnv(), { wrapper }); + expect(result.current).toBe(env); + expect(result.current?.channelId).toBe('ch-1'); + }); +}); diff --git a/apps/web/src/components/channel/__tests__/ChannelMessageItem-actions.test.tsx b/apps/web/src/components/channel/__tests__/ChannelMessageItem-actions.test.tsx index ccbb2d6f3..4c431e392 100644 --- a/apps/web/src/components/channel/__tests__/ChannelMessageItem-actions.test.tsx +++ b/apps/web/src/components/channel/__tests__/ChannelMessageItem-actions.test.tsx @@ -17,6 +17,8 @@ vi.mock('../../../utils/toast', () => ({ }), })); +import { ChannelMessageEnvProvider } from '../ChannelMessageEnv'; +import type { ChannelMessageEnv } from '../ChannelMessageEnv'; import { ChannelMessageItem } from '../ChannelMessageItem'; const base: ChannelMessage = { @@ -37,10 +39,16 @@ const systemMsg: ChannelMessage = { content: '[CRITICAL] **[Monitor]** 队列积压', }; -const renderItem = (message: ChannelMessage, extra: Record = {}) => +const renderItem = ( + message: ChannelMessage, + extra: Record = {}, + envExtra: Partial = {}, +) => render( - + + + , ); @@ -88,20 +96,20 @@ describe('ChannelMessageItem — 复制按钮(Phase 3 / AC4)', () => { }); it('系统播报消息:有复制入口,无回复/转任务钮(即使传了 onReply)', () => { - renderItem(systemMsg, { onReply: vi.fn() }); + renderItem(systemMsg, {}, { onReply: vi.fn() }); expect(screen.getByLabelText('复制消息内容')).toBeTruthy(); expect(screen.queryByLabelText('回复消息')).toBeNull(); expect(screen.queryByLabelText('转为任务')).toBeNull(); }); it('compact(连续合并)消息:复制按钮随角落动作保留', () => { - renderItem(base, { compact: true, onReply: vi.fn() }); + renderItem(base, { compact: true }, { onReply: vi.fn() }); expect(screen.getByLabelText('复制消息内容')).toBeTruthy(); expect(screen.getByLabelText('回复消息')).toBeTruthy(); }); it('pending 乐观消息不出复制按钮(同既有回复/转任务守卫)', () => { - renderItem({ ...base, pending: true } as ChannelMessage, { onReply: vi.fn() }); + renderItem({ ...base, pending: true } as ChannelMessage, {}, { onReply: vi.fn() }); expect(screen.queryByLabelText('复制消息内容')).toBeNull(); }); }); diff --git a/apps/web/src/components/channel/__tests__/ChannelMessageItem-flags.test.tsx b/apps/web/src/components/channel/__tests__/ChannelMessageItem-flags.test.tsx new file mode 100644 index 000000000..a14c2b06d --- /dev/null +++ b/apps/web/src/components/channel/__tests__/ChannelMessageItem-flags.test.tsx @@ -0,0 +1,58 @@ +// ChannelMessageItem — #547:per-message 派生 flags 的消息项级断言(自页面分册迁移/补齐)—— +// highlight(#279 顶栏待办定位)→ mc-msg-highlight;fresh(批次 E-3 SSE 新到达渐隐)→ mc-msg-new; +// focused(Phase 3 AC5 键盘导航焦点环)→ mc-msg-focused。三 flag 互不染指,缺省全不带。 +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import type { ChannelMessage } from '../../../api/channel'; +import { ChannelMessageItem } from '../ChannelMessageItem'; + +const base: ChannelMessage = { + id: 'm-1', + channelId: 'ch-1', + authorType: 'agent', + agentName: 'dev-agent', + content: '正文内容', + replyToId: null, + meta: '{}', + createdAt: '2026-08-19T00:00:00.000Z', +}; + +const renderItem = (flags: { highlight?: boolean; fresh?: boolean; focused?: boolean } = {}) => + render( + + + , + ); + +const rootClass = (container: HTMLElement) => + (container.querySelector('[data-message-id="m-1"]') as HTMLElement).className; + +describe('ChannelMessageItem — per-message flags(#547 消息项级)', () => { + it('highlight → mc-msg-highlight(顶栏待办 chip 定位高亮)', () => { + const { container } = renderItem({ highlight: true }); + expect(rootClass(container)).toContain('mc-msg-highlight'); + expect(rootClass(container)).not.toContain('mc-msg-new'); + expect(rootClass(container)).not.toContain('mc-msg-focused'); + }); + + it('fresh → mc-msg-new(SSE 新到达渐隐高亮)', () => { + const { container } = renderItem({ fresh: true }); + expect(rootClass(container)).toContain('mc-msg-new'); + expect(rootClass(container)).not.toContain('mc-msg-highlight'); + }); + + it('focused → mc-msg-focused(j/k 键盘导航焦点环)', () => { + const { container } = renderItem({ focused: true }); + expect(rootClass(container)).toContain('mc-msg-focused'); + expect(rootClass(container)).not.toContain('mc-msg-highlight'); + }); + + it('缺省三 flag 全不带', () => { + const { container } = renderItem(); + const cls = rootClass(container); + expect(cls).not.toContain('mc-msg-highlight'); + expect(cls).not.toContain('mc-msg-new'); + expect(cls).not.toContain('mc-msg-focused'); + }); +}); diff --git a/apps/web/src/components/channel/__tests__/ChannelMessageItem-footer.test.tsx b/apps/web/src/components/channel/__tests__/ChannelMessageItem-footer.test.tsx index b6fb1fc16..39bf86096 100644 --- a/apps/web/src/components/channel/__tests__/ChannelMessageItem-footer.test.tsx +++ b/apps/web/src/components/channel/__tests__/ChannelMessageItem-footer.test.tsx @@ -5,6 +5,7 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; import type { ChannelMessage } from '../../../api/channel'; +import { ChannelMessageEnvProvider } from '../ChannelMessageEnv'; import { ChannelMessageItem } from '../ChannelMessageItem'; const msgWithMeta = (meta: Record): ChannelMessage => ({ @@ -23,7 +24,11 @@ const renderItem = (message: ChannelMessage, onOpenRequirement?: (reqId: string) } + element={ + + + + } /> 项目页} /> @@ -62,7 +67,13 @@ describe('ChannelMessageItem — footer REQ›/PMO›(#275 断点3)', () => } + element={ + + + + } /> 项目页} /> diff --git a/apps/web/src/components/channel/__tests__/ChannelMessageItem-image.test.tsx b/apps/web/src/components/channel/__tests__/ChannelMessageItem-image.test.tsx index 2e9168631..e27e00a49 100644 --- a/apps/web/src/components/channel/__tests__/ChannelMessageItem-image.test.tsx +++ b/apps/web/src/components/channel/__tests__/ChannelMessageItem-image.test.tsx @@ -4,6 +4,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import type { ChannelMessage } from '../../../api/channel'; +import { ChannelMessageEnvProvider } from '../ChannelMessageEnv'; import { ChannelMessageItem } from '../ChannelMessageItem'; import { useAuthStore } from '../../../stores/authStore'; @@ -20,7 +21,9 @@ const humanMsg = (content: string): ChannelMessage => ({ const renderItem = (message: ChannelMessage) => render( - + + + , ); diff --git a/apps/web/src/components/channel/__tests__/ChannelMessageItem-inline.test.tsx b/apps/web/src/components/channel/__tests__/ChannelMessageItem-inline.test.tsx index b5424ecdb..0e4dabc31 100644 --- a/apps/web/src/components/channel/__tests__/ChannelMessageItem-inline.test.tsx +++ b/apps/web/src/components/channel/__tests__/ChannelMessageItem-inline.test.tsx @@ -4,6 +4,7 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent, act } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import type { ChannelMessage } from '../../../api/channel'; +import { ChannelMessageEnvProvider } from '../ChannelMessageEnv'; import { ChannelMessageItem } from '../ChannelMessageItem'; const message: ChannelMessage = { @@ -20,12 +21,12 @@ function setup() { const onInlineReply = vi.fn(); render( - + + + , ); const input = screen.getByLabelText('回复 wu-1'); diff --git a/apps/web/src/components/channel/__tests__/ChannelMessageItem-layout.test.tsx b/apps/web/src/components/channel/__tests__/ChannelMessageItem-layout.test.tsx index d776ace11..b159e651b 100644 --- a/apps/web/src/components/channel/__tests__/ChannelMessageItem-layout.test.tsx +++ b/apps/web/src/components/channel/__tests__/ChannelMessageItem-layout.test.tsx @@ -5,6 +5,8 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import type { ChannelMessage } from '../../../api/channel'; +import { ChannelMessageEnvProvider } from '../ChannelMessageEnv'; +import type { ChannelMessageEnv } from '../ChannelMessageEnv'; import { ChannelMessageItem } from '../ChannelMessageItem'; const base: ChannelMessage = { @@ -18,10 +20,16 @@ const base: ChannelMessage = { createdAt: '2026-08-19T00:00:00.000Z', }; -const renderItem = (message: ChannelMessage, extra: Record = {}) => +const renderItem = ( + message: ChannelMessage, + extra: Record = {}, + envExtra: Partial = {}, +) => render( - + + + , ); @@ -81,7 +89,7 @@ describe('ChannelMessageItem — 头部规则(#277 D2)', () => { it('compact(连续合并)省略重复头:无头像/时间,回复动作仍可用', () => { const onReply = vi.fn(); - const { container } = renderItem(base, { compact: true, onReply }); + const { container } = renderItem(base, { compact: true }, { onReply }); const root = rootOf(container, 'm-1'); expect(root.classList.contains('mc-msg-compact')).toBe(true); expect(root.querySelector('.mc-msg-head')).toBeNull(); @@ -113,6 +121,7 @@ describe('ChannelMessageItem — 系统播报形态(#277 D3;#437 起左对 it('NEED_INPUT 等待回复的 Studio 消息不判系统(保留 agent 头与回复框)', () => { const { container } = renderItem(studioMsg({ content: '选哪个工程?' }), { waitingForInput: true, + }, { onInlineReply: vi.fn(), }); const root = rootOf(container, 'm-1'); diff --git a/apps/web/src/components/channel/__tests__/ChannelMessageItem-markdown.test.tsx b/apps/web/src/components/channel/__tests__/ChannelMessageItem-markdown.test.tsx index b204a83bf..e211d1e88 100644 --- a/apps/web/src/components/channel/__tests__/ChannelMessageItem-markdown.test.tsx +++ b/apps/web/src/components/channel/__tests__/ChannelMessageItem-markdown.test.tsx @@ -4,6 +4,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import type { ChannelFileVocabulary, ChannelMessage } from '../../../api/channel'; +import { useChannelDataStore } from '../../../stores/channelDataStore'; +import { ChannelMessageEnvProvider } from '../ChannelMessageEnv'; import { ChannelMessageItem } from '../ChannelMessageItem'; const mockWriteText = vi.hoisted(() => vi.fn()); @@ -17,12 +19,17 @@ const agentMsg = (content: string): ChannelMessage => ({ createdAt: '2026-08-19T00:00:00.000Z', }); -const renderItem = (message: ChannelMessage, fileVocabulary?: ChannelFileVocabulary) => - render( +const renderItem = (message: ChannelMessage, fileVocabulary?: ChannelFileVocabulary) => { + // #547:fileVocabulary 改经 channelDataStore 自取——测试按 env channelId 预置词表(无词表置空防串例) + useChannelDataStore.setState({ vocabulary: fileVocabulary ? { 'ch-1': fileVocabulary } : {} }); + return render( - + + + , ); +}; describe('ChannelMessageItem — agent 消息 Markdown 渲染(#271)', () => { beforeEach(() => { @@ -73,6 +80,21 @@ describe('ChannelMessageItem — agent 消息 Markdown 渲染(#271)', () => renderItem(agentMsg('改了 `src/index.ts`'), vocab); expect(screen.getByRole('button', { name: 'src/index.ts' })).toBeTruthy(); }); + + it('#547:fileVocabulary 不经 props——消息项按 env channelId 自 useChannelDataStore 取词表染 chip', () => { + const vocab: ChannelFileVocabulary = { + repos: [{ repo: '/repo/studio', files: ['src/store.ts'] }], + }; + useChannelDataStore.setState({ vocabulary: { 'ch-1': vocab } }); + render( + + + + + , + ); + expect(screen.getByRole('button', { name: 'src/store.ts' })).toBeTruthy(); + }); }); describe('ChannelMessageItem — 人类消息保持纯文本(#271)', () => { diff --git a/apps/web/src/components/channel/__tests__/ChannelMessageItem-pending.test.tsx b/apps/web/src/components/channel/__tests__/ChannelMessageItem-pending.test.tsx index e5ae875cf..21d9ce897 100644 --- a/apps/web/src/components/channel/__tests__/ChannelMessageItem-pending.test.tsx +++ b/apps/web/src/components/channel/__tests__/ChannelMessageItem-pending.test.tsx @@ -16,6 +16,7 @@ vi.mock('../KnowledgeConfirmCard', () => ({ KnowledgeConfirmCard: () => null })) vi.mock('../ConvertToTaskDialog', () => ({ ConvertToTaskDialog: () => null })); import { ChannelMessageItem } from '../ChannelMessageItem'; +import { ChannelMessageEnvProvider } from '../ChannelMessageEnv'; import type { ChannelMessage } from '../../../api/channel'; const pendingMessage: ChannelMessage = { @@ -33,7 +34,9 @@ const pendingMessage: ChannelMessage = { describe('ChannelMessageItem — pending 乐观回显(#486)', () => { it('pending 消息带 mc-msg-pending 类与「发送中」标记', () => { const { container } = render( - , + + + , ); const row = container.querySelector('.mc-msg'); expect(row?.className).toContain('mc-msg-pending'); @@ -41,14 +44,20 @@ describe('ChannelMessageItem — pending 乐观回显(#486)', () => { }); it('pending 消息不出回复动作(本地 id 服务端不存在,回复无的放矢)', () => { - render(); + render( + + + , + ); expect(screen.queryByLabelText('回复消息')).toBeNull(); }); it('非 pending 消息行为不变:无 pending 类、有回复动作', () => { const normal: ChannelMessage = { ...pendingMessage, id: 'm1', pending: undefined }; const { container } = render( - , + + + , ); expect(container.querySelector('.mc-msg')?.className).not.toContain('mc-msg-pending'); expect(screen.queryByText('发送中…')).toBeNull(); diff --git a/apps/web/src/components/channel/__tests__/ChannelMessageItem-quote.test.tsx b/apps/web/src/components/channel/__tests__/ChannelMessageItem-quote.test.tsx index 5c61d9e39..503fbd744 100644 --- a/apps/web/src/components/channel/__tests__/ChannelMessageItem-quote.test.tsx +++ b/apps/web/src/components/channel/__tests__/ChannelMessageItem-quote.test.tsx @@ -16,6 +16,7 @@ vi.mock('../KnowledgeConfirmCard', () => ({ KnowledgeConfirmCard: () => null })) vi.mock('../ConvertToTaskDialog', () => ({ ConvertToTaskDialog: () => null })); import { ChannelMessageItem } from '../ChannelMessageItem'; +import { ChannelMessageEnvProvider } from '../ChannelMessageEnv'; import type { ChannelMessage } from '../../../api/channel'; const parentMessage: ChannelMessage = { @@ -47,12 +48,9 @@ describe('ChannelMessageItem — quote 引用块点击定位(Phase 1 / AC1)' it('提供 onQuoteClick 且父消息已加载 → quote 渲染为 button,点击回调带父消息 id', () => { const onQuoteClick = vi.fn(); const { container } = render( - , + + + , ); const quote = container.querySelector('.mc-quote'); expect(quote?.tagName).toBe('BUTTON'); @@ -63,7 +61,9 @@ describe('ChannelMessageItem — quote 引用块点击定位(Phase 1 / AC1)' it('未提供 onQuoteClick → quote 保持纯 div 展示(不可点)', () => { const { container } = render( - , + + + , ); const quote = container.querySelector('.mc-quote'); expect(quote).not.toBeNull(); @@ -73,12 +73,9 @@ describe('ChannelMessageItem — quote 引用块点击定位(Phase 1 / AC1)' it('父消息掉出已加载分页(findMessage 未命中)→ 不渲染 quote', () => { const onQuoteClick = vi.fn(); const { container } = render( - undefined} - onQuoteClick={onQuoteClick} - />, + undefined, onQuoteClick }}> + + , ); expect(container.querySelector('.mc-quote')).toBeNull(); }); diff --git a/apps/web/src/components/channel/__tests__/FileRefChip.test.tsx b/apps/web/src/components/channel/__tests__/FileRefChip.test.tsx index e7f8cb88a..965a8c1f4 100644 --- a/apps/web/src/components/channel/__tests__/FileRefChip.test.tsx +++ b/apps/web/src/components/channel/__tests__/FileRefChip.test.tsx @@ -26,7 +26,9 @@ vi.mock('../../../api', () => ({ import type { ChannelFileVocabulary, ChannelMessage } from '../../../api/channel'; import { FileRefChip } from '../FileRefChip'; import { ChannelMessageItem } from '../ChannelMessageItem'; +import { ChannelMessageEnvProvider } from '../ChannelMessageEnv'; import { usePmoDataStore } from '../../../stores/pmoDataStore'; +import { useChannelDataStore } from '../../../stores/channelDataStore'; const vocab: ChannelFileVocabulary = { repos: [ @@ -104,19 +106,27 @@ describe('ChannelMessageItem 集成(#285;#271 起正文走 Markdown 渲染 content: '完成 `src/index.ts` 的修改', createdAt: '2026-08-19T00:00:00.000Z', }; + // #547:fileVocabulary prop 已删——消息项按 env channelId 自 useChannelDataStore 取词表; + // 每测渲染前显式 setState(无词表置 {})防同文件串状态 it('agent 消息 + 词表 → 正文命中 token 染 chip', () => { + useChannelDataStore.setState({ vocabulary: { ch1: vocab } }); render( - + + + , ); expect(screen.getByRole('button', { name: 'src/index.ts' })).toBeTruthy(); }); it('人类消息 + 词表 → 正文不动,无 chip', () => { + useChannelDataStore.setState({ vocabulary: { ch1: vocab } }); const { container } = render( - + + + , ); expect(container.querySelector('.mc-file-chip')).toBeNull(); @@ -124,9 +134,12 @@ describe('ChannelMessageItem 集成(#285;#271 起正文走 Markdown 渲染 }); it('agent 消息不传词表 → inline-code 走默认 chip 样式,无文件 chip', () => { + useChannelDataStore.setState({ vocabulary: {} }); const { container } = render( - + + + , ); expect(container.querySelector('.mc-file-chip')).toBeNull(); @@ -135,14 +148,15 @@ describe('ChannelMessageItem 集成(#285;#271 起正文走 Markdown 渲染 }); it('AC4:WU 文件集优先——token 不在候选集词表但在 wuChangedFiles → 仍染 chip(绝对路径 tooltip)', () => { + useChannelDataStore.setState({ vocabulary: { ch1: vocab } }); render( - + + + , ); const chip = screen.getByRole('button', { name: 'docs/wu-only.md' }); @@ -150,27 +164,30 @@ describe('ChannelMessageItem 集成(#285;#271 起正文走 Markdown 渲染 }); it('AC4:不传候选集词表但 wuChangedFiles 有命中 → 染 chip(WU 层不依赖词表)', () => { + useChannelDataStore.setState({ vocabulary: {} }); render( - + + + , ); expect(screen.getByRole('button', { name: 'src/index.ts' })).toBeTruthy(); }); it('AC4:wuChangedFiles 为空数组 → 回退候选集词表行为', () => { + useChannelDataStore.setState({ vocabulary: { ch1: vocab } }); render( - + + + , ); expect(screen.getByRole('button', { name: 'src/index.ts' })).toBeTruthy(); diff --git a/apps/web/src/pages/ChannelDetailPage.tsx b/apps/web/src/pages/ChannelDetailPage.tsx index a66d0479a..5432a1d3b 100644 --- a/apps/web/src/pages/ChannelDetailPage.tsx +++ b/apps/web/src/pages/ChannelDetailPage.tsx @@ -11,7 +11,8 @@ import { useActivityMessageItems } from '../hooks/useActivityMessageItems'; import { useChannelCardActions } from '../hooks/useChannelCardActions'; import { useWebSocketContext } from '../api/websocketHooks'; import { ChannelMessageItem } from '../components/channel/ChannelMessageItem'; -import { ChannelStreamBody } from '../components/channel/ChannelStreamBody'; +import { ChannelMessageEnvProvider, type ChannelMessageEnv } from '../components/channel/ChannelMessageEnv'; +import { ChannelStreamBody, type StreamMessageExtra } from '../components/channel/ChannelStreamBody'; import { ChannelWorkBar } from '../components/channel/ChannelWorkBar'; import { navigableIdsOf } from '../utils/streamView'; import { ChannelInput } from '../components/channel/ChannelInput'; @@ -265,8 +266,8 @@ export function ChannelDetailPage() { // 提升到主流可见;promotedQuestionIds / isWaitingForInput 均为 needInputViewOf 产物(#546) // #285: agent 消息 inline-code 文件 chip 词表——#403 起读 channelDataStore(与 ChannelInput - // 共享一份拉取;按 channelId 键控无跨频道串词表);失败静默降级,不渲染 chip - const fileVocabulary = useChannelDataStore((s) => (id ? s.vocabulary[id] : undefined)); + // 共享一份拉取;按 channelId 键控无跨频道串词表);失败静默降级,不渲染 chip。 + // #547:订阅本体已下沉到 ChannelMessageItem(selector 自取),本页只保留拉取触发 useEffect(() => { if (!id) return; void useChannelDataStore.getState().ensureVocabulary(id); @@ -452,30 +453,37 @@ export function ChannelDetailPage() { }, [messages]); useEffect(() => () => { if (freshMsgTimerRef.current) clearTimeout(freshMsgTimerRef.current); }, []); + // #547:频道消息环境——横切值单 Provider 下发,消息项 useContext 自取(公开 Props 收窄)。 + // #322 契约不变量:成员全为稳定引用(useCallback/镜像 ref),useMemo 组装后 value identity + // 不随页面重渲变化 → context 零扇出;highlightId/focusedId/freshMsgIds 等 volatile state 禁入 + const messageEnv = useMemo(() => ({ + onAction: handleAction, + onReply: handleReply, + findMessage, + channelId: id, + onOpenWorkUnit: openWu, + onOpenWorkUnitConfirm: openWuConfirm, + onOpenWorkUnitRuling: openWuRuling, + onOpenRequirement: openReq, + onInlineReply: handleInlineReply, + onQuoteClick: locateMessage, + }), [handleAction, handleReply, findMessage, id, openWu, openWuConfirm, openWuRuling, openReq, handleInlineReply, locateMessage]); + // #322:提升为 useCallback——消除每次渲染新建的内联 render props(memo 稳定 props 契约) - const renderMessageItem = useCallback((msg: ChannelMessage, extra: Partial[0]> = {}) => ( + // #547:手喂面收窄到 message + 5 个 per-message 派生值(共 6 个);横切值经 messageEnv 下发, + // 结构 props 经 ChannelStreamBody 封闭 extra(StreamMessageExtra)喂入 + const renderMessageItem = useCallback((msg: ChannelMessage, extra: StreamMessageExtra = {}) => ( - ), [handleAction, handleReply, findMessage, id, isWaitingForInput, openWu, openWuConfirm, openWuRuling, openReq, handleInlineReply, fileVocabulary, wuChangedFiles, highlightId, locateMessage, freshMsgIds, focusedId]); + ), [isWaitingForInput, wuChangedFiles, highlightId, freshMsgIds, focusedId]); if (!id) return
频道不存在或链接无效
; @@ -574,7 +582,10 @@ export function ChannelDetailPage() { {/* #531:items → DOM 结构分支(virtual/non-virtual + spacer/translateY + 三 kind 分派 + skeleton 占位)收编 ChannelStreamBody;renderMessageItem 与 highlightId 由本页注入 */} - + {/* #547:频道消息环境 Provider——横切值经 Context 下发到每条消息项(value 全稳定引用,见上方 useMemo) */} + + + {/* #289: 偏离底部时浮出「回到底部」(sticky 贴滚动视口底部,不占流内高度); 批次 E-3:钉底跟随期间到达的新消息计数进浮钮文案,点击回底后清零 */} {showJumpToBottom && ( diff --git a/apps/web/src/pages/__tests__/ChannelDetailPage-focus-fanout.test.tsx b/apps/web/src/pages/__tests__/ChannelDetailPage-focus-fanout.test.tsx new file mode 100644 index 000000000..8f3057c6a --- /dev/null +++ b/apps/web/src/pages/__tests__/ChannelDetailPage-focus-fanout.test.tsx @@ -0,0 +1,112 @@ +// ChannelDetailPage — #547 新增断言:focusedId 翻牌只重渲焦点迁移涉及的消息项, +// 不扇出到其他消息项(#322 memo 契约在横切值改经 Context 下发后仍成立—— +// env value 全稳定引用,翻牌不经 Context 通道)。 +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, act, fireEvent } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; + +const { mockSendMessage, mockListWorkunits, mockListReqs, mockApiGet, mockOnEvent, mockRefresh, mdRenderCounts } = vi.hoisted(() => ({ + mockSendMessage: vi.fn(), + mockListWorkunits: vi.fn(), + mockListReqs: vi.fn(), + mockApiGet: vi.fn(), + mockOnEvent: vi.fn(), + mockRefresh: vi.fn(), + mdRenderCounts: new Map(), +})); + +vi.mock('../../api', () => ({ api: { get: mockApiGet } })); + +vi.mock('../../hooks/useChannelEvents', () => ({ + useChannelMessages: () => ({ + messages: currentMessages, + loading: false, + hasMore: false, + sendMessage: mockSendMessage, + loadMore: vi.fn(), + refresh: mockRefresh, + }), +})); + +vi.mock('../../api/workunit', () => ({ workunitApi: { list: mockListWorkunits } })); +vi.mock('../../api/requirements', () => ({ requirementApi: { list: mockListReqs } })); +vi.mock('../../api/websocketHooks', () => ({ useWebSocketContext: () => ({ onEvent: mockOnEvent, onReconnect: () => () => {} }) })); + +// 渲染探针:agent 正文走 MarkdownBody——按内容分别计数,消息项重渲则对应计数必增 +vi.mock('../../components/knowledge/MarkdownBody', () => ({ + MarkdownBody: ({ content }: { content: string }) => { + mdRenderCounts.set(content, (mdRenderCounts.get(content) ?? 0) + 1); + return
{content}
; + }, +})); + +vi.mock('../../components/channel/ChannelRail', () => ({ ChannelRail: () =>
})); +vi.mock('../../components/channel/ChannelActivityRail', () => ({ ChannelActivityRail: () => null })); +vi.mock('../../components/channel/WorkUnitDrawer', () => ({ WorkUnitDrawer: () => null })); +vi.mock('../../components/channel/ChannelMemberManager', () => ({ ChannelMemberManager: () => null })); +vi.mock('../../components/channel/ChannelDefaultProjectSelect', () => ({ ChannelDefaultProjectSelect: () => null })); +vi.mock('../../components/channel/ChannelCurrentPmoChip', () => ({ ChannelCurrentPmoChip: () => null })); +vi.mock('../../components/channel/ChannelInput', () => ({ ChannelInput: () => null })); +vi.mock('../../components/channel/RequirementsDocCard', () => ({ RequirementsDocCard: () => null })); +vi.mock('../../components/channel/KnowledgeConfirmCard', () => ({ KnowledgeConfirmCard: () => null })); +vi.mock('../../components/channel/ConvertToTaskDialog', () => ({ ConvertToTaskDialog: () => null })); + +import { ChannelDetailPage } from '../ChannelDetailPage'; +import type { ChannelMessage } from '../../api/channel'; + +const t0 = new Date('2026-08-19T10:00:00.000Z').getTime(); +const iso = (offsetMin: number) => new Date(t0 + offsetMin * 60000).toISOString(); + +const msg = (id: string, over: Partial = {}): ChannelMessage => ({ + id, channelId: 'ch-1', authorType: 'agent', agentName: 'pm', + content: `内容-${id}`, replyToId: null, meta: '{}', createdAt: iso(0), ...over, +}); + +let currentMessages: ChannelMessage[] = []; + +const renderPage = () => + render( + + + } /> + + , + ); + +const countOf = (id: string) => mdRenderCounts.get(`内容-${id}`) ?? 0; + +describe('ChannelDetailPage — #547 focusedId 翻牌零扇出', () => { + beforeEach(() => { + vi.clearAllMocks(); + mdRenderCounts.clear(); + currentMessages = [msg('m1', { createdAt: iso(0) }), msg('m2', { createdAt: iso(10) }), msg('m3', { createdAt: iso(20) })]; + mockApiGet.mockResolvedValue({ data: { data: { id: 'ch-1', name: 'rnd', type: 'rnd', members: '[]' } } }); + mockListWorkunits.mockResolvedValue({ data: { data: [] } }); + mockListReqs.mockResolvedValue({ data: { data: [] } }); + mockOnEvent.mockImplementation(() => () => {}); + mockSendMessage.mockResolvedValue({}); + }); + + it('j 移焦点:只有焦点迁移涉及的消息项重渲,其余零重渲', async () => { + renderPage(); + await waitFor(() => expect(screen.getByText('内容-m3')).toBeTruthy()); + // 初始渲染落定(页面装配期多次重渲不锁定基数,落定后快照) + await act(async () => {}); + const before = { m1: countOf('m1'), m2: countOf('m2'), m3: countOf('m3') }; + + // 第一次 j:焦点落到 m1 —— 只有 m1 可重渲 + act(() => { fireEvent.keyDown(document.body, { key: 'j' }); }); + await waitFor(() => + expect(document.querySelector('.mc-msg-focused')?.getAttribute('data-message-id')).toBe('m1')); + expect(countOf('m2')).toBe(before.m2); + expect(countOf('m3')).toBe(before.m3); + + // 第二次 j:焦点 m1 → m2 —— m1(失焦点)与 m2(得焦点)可重渲,m3 零重渲 + const mid = { m1: countOf('m1'), m2: countOf('m2'), m3: countOf('m3') }; + act(() => { fireEvent.keyDown(document.body, { key: 'j' }); }); + await waitFor(() => + expect(document.querySelector('.mc-msg-focused')?.getAttribute('data-message-id')).toBe('m2')); + expect(countOf('m3')).toBe(mid.m3); + expect(countOf('m2')).toBeGreaterThan(mid.m2); // 命中项确实重渲(翻牌生效的佐证) + }); +}); diff --git a/docs/plans/2026-09-channel-message-env.md b/docs/plans/2026-09-channel-message-env.md new file mode 100644 index 000000000..62b4227f7 --- /dev/null +++ b/docs/plans/2026-09-channel-message-env.md @@ -0,0 +1,34 @@ +# ChannelMessageItem 公开 Props 收口(#547) + +来源:2026-09-15 架构评审候选 B3,grilling 四议题已决(结论钉死在 #547 票体 Agent Brief,本文件只做执行分解,不重复决策)。 + +## 决策摘要(票体为准) + +- 11 个横切值收走:`onAction`/`onReply`/`findMessage`/`channelId`/`onOpenWorkUnit`/ + `onOpenWorkUnitConfirm`/`onOpenWorkUnitRuling`/`onOpenRequirement`/`onInlineReply`/`onQuoteClick` + 经新增 `ChannelMessageEnv` React Context(单 Provider,挂频道页装配层)下发; + `fileVocabulary` 不进 Context,消息项直接 `useChannelDataStore` selector 自取。 +- 5 个 per-message 派生值留 props:`waitingForInput`/`wuChangedFiles`/`highlight`/`fresh`/`focused` + (boolean 翻牌只命中个别消息,memo 友好)。高频 volatile id 集禁止进 Context value。 +- 6 个结构 props 留但封闭:`isThreadAnchor`/`threadReplyCount`/`isExpanded`/`onToggleThread`/ + `compact`/`isThreadReply`;`ChannelStreamBody` 的 extra 从 `Partial` 收紧为这 6 键的封闭 `Pick`。 +- #322 契约维持:`React.memo` 保留;env value 全部由稳定引用组成(useMemo 组装), + identity 不变 = 零重渲扇出;render-count 测试保持绿 + 新增翻牌零扇出断言。 + +## 执行步骤 + +1. RED:新测试——store 自取 fileVocabulary 染 chip、highlight/fresh/focused 类断言(消息项级)、 + 页面级 focus 翻牌只重渲命中项。 +2. 新增 `components/channel/ChannelMessageEnv.tsx`(interface + Provider + hook)。 +3. `ChannelMessageItem` Props 收窄为 message + 5 派生 + 6 结构(导出 `ChannelMessageItemProps`), + 横切值改 `useChannelMessageEnv()` + store 自取。 +4. `ChannelStreamBody`:`StreamMessageExtra` 封闭 Pick 替换 `Partial>`。 +5. `ChannelDetailPage`:useMemo 组 env value + Provider 包 `ChannelStreamBody`; + renderMessageItem 手喂 ≤6 prop,依赖数组 16→5;删页面 fileVocabulary 订阅(ensure 拉取保留)。 +6. 8 个消息项测试文件迁移到 Provider 包裹。 +7. 验证:web 相关测试全绿 + tsc + lint;更新 `apps/web/src/CONTEXT.md`。 + +## 边界(Out of scope) + +- deriveStreamView / useChannelStream 渲染管线不动;姊妹票 #546/#548/#549 不做; + 5 个派生值不搬 Context;不单独落 ADR。 From 88713aba5e90933297ef4df986745362d084b27b Mon Sep 17 00:00:00 2001 From: dommaker Date: Wed, 16 Sep 2026 02:04:36 +0800 Subject: [PATCH 024/116] =?UTF-8?q?refactor(web):=20=E9=A2=91=E9=81=93=20m?= =?UTF-8?q?essages=20=E6=94=B6=E7=BC=96=20channelMessageStore=20=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E9=9D=A2=20store=20(#548)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/CONTEXT.md | 17 +- .../hooks/__tests__/useChannelEvents.test.ts | 5 + .../useChannelMessages-optimistic.test.ts | 3 + .../__tests__/useChannelMessages-perf.test.ts | 3 + .../useChannelMessages-pruning.test.ts | 3 + .../__tests__/useFreshMessageIds.test.ts | 85 ++++ apps/web/src/hooks/useChannelEvents.ts | 223 ++-------- apps/web/src/hooks/useFreshMessageIds.ts | 36 ++ apps/web/src/pages/ChannelDetailPage.tsx | 35 +- .../ChannelDetailPage-quote-locate.test.tsx | 112 +++-- .../__tests__/ChannelDetailPage.test.tsx | 390 ++++++++++-------- .../__tests__/channelMessageStore.test.ts | 348 ++++++++++++++++ apps/web/src/stores/channelMessageStore.ts | 332 +++++++++++++++ .../src/utils/__tests__/messageList.test.ts | 76 ++++ apps/web/src/utils/messageList.ts | 27 ++ 15 files changed, 1230 insertions(+), 465 deletions(-) create mode 100644 apps/web/src/hooks/__tests__/useFreshMessageIds.test.ts create mode 100644 apps/web/src/hooks/useFreshMessageIds.ts create mode 100644 apps/web/src/stores/__tests__/channelMessageStore.test.ts create mode 100644 apps/web/src/stores/channelMessageStore.ts create mode 100644 apps/web/src/utils/__tests__/messageList.test.ts create mode 100644 apps/web/src/utils/messageList.ts diff --git a/apps/web/src/CONTEXT.md b/apps/web/src/CONTEXT.md index fad64e4b1..3b91cd614 100644 --- a/apps/web/src/CONTEXT.md +++ b/apps/web/src/CONTEXT.md @@ -28,6 +28,7 @@ Web 前端主源码。路由、全局状态、API 客户端、UI 组件、样式 | `useDataPlaneSync` | `hooks/useDataPlaneSync.ts` | 数据面接线底座(#403):引用计数单例 SSE 路由(handleEvent 须引用稳定)+ onReconnect 强刷(maxAgeMs 0,single-flight 收敛)+ useGatedPoll 兜底;useRosterStoreSync 迁移其上(只剩 roster 事件路由),新数据面 store 接线一律复用,禁止逐 store 复印机制 | | `channelDataStore` | `stores/channelDataStore.ts` | 频道数据面 store(#403,ADR 2026-08-31):per-channelId 三切片——文件词表(FIFO 驻留上限 10)/ current-pmo / 成员 ID 列表(源自频道记录:页面水合写穿 + 面板修改成功本地写穿);纪律走 fetchDiscipline,(slice, channelId) 粒度各自 TTL 30s / 单飞 / seq。新鲜度 = TTL + 白捡触发器(requirement.created/updated → invalidateCurrentPmo),无 SSE 失效事件;agent 档案不进本 store(rosterStore.listAllAgents 是全量正本,「members 为空 → 全部 active」回退在消费方) | | `channelWorkStore` / `useChannelWorkStoreSync` | `stores/channelWorkStore.ts` / `hooks/useChannelWorkStoreSync.ts` | 频道工作面数据面 store(#528,ADR 2026-08-31 模式;与 channelDataStore 并列——TTL 型慢变数据与事件驱动数据不同质):per-channelId 三 slice(wus / reqs / suggestions+currentWuId 同响应同 slice,resolved 旗标 = #488 台账等价替代)+ per-wuId wuChangedFiles(失败记 [] 不重试);wus/reqs 30s TTL 走 fetchDiscipline,suggestions 无 TTL 纯事件驱动(500ms 防抖内化 store);SSE 只路由活跃频道(页面级单消费方,activeChannelId 模块级登记):status_changed 全量快照直替(编造时间戳已删)/ requirement.* envelope 解包 upsert / message_sent 标脏;闸门动作经 gateWriter sink 走同一 applyWorkunitSnapshot 落点(#545,applyGateResult 已删);workunit:removed(#538 删除出声,负载 { id, channelId },按活跃频道路由)→ applyWorkunitRemoved 删行;ChannelDetailPage 退回纯订阅者(4 份 state/手写订阅/防抖/重连对齐全删)。模块单例——页面测试 beforeEach 须 `__resetForTests()`(TTL 锚点/已打底 slice 跨用例短路首拉,ChannelDetailPage.test.tsx 顶部有样板) | +| `channelMessageStore` | `stores/channelMessageStore.ts` | 频道消息面数据面 store(#548,ADR 2026-08-31 模式延伸——五个数据面 store 就位后最后一处例外收编):per-channelId 切片 {messages, hasMore, loading, error, loaded};首拉替换 / 同频道 refetch 合并(#328,hasMore 头部方向规则)/ id 游标翻页 prepend(#319,pending 不作锚 #486)/ SSE 逐条直写(写批处理已裁决不做——message_sent 有序插入 #287 / message_updated 全量本体优先 #315)/ 乐观回显 sendMessage(#486)/ syncPruning 降级+防抖水合(#326,计时器按 channelId 模块级簿记)全部内化 store action;useChannelMessages 退为薄接线(useGatedPoll 取数时机 + SSE 订阅路由,对外契约不变)。旁挂纯函数 `agentAnsweredOf`(#493 判定自页面迁出,照 needInputViewOf 先例);fresh 集合 = `hooks/useFreshMessageIds.ts`(批次 E-3,自页面迁出)。模块单例——页面测试播种 store 初态(setState 写切片 loaded=true)替代手写有状态 mock,beforeEach 须 `__resetForTests()` | | `useRosterStoreSync` | `hooks/useRosterStoreSync.ts` | rosterStore 实时接线(#346;#403 起机制抽至 useDataPlaneSync,本文件只剩 roster 事件路由与参数) | | `pmoDataStore` | `stores/pmoDataStore.ts` | company/project 数据面 store(#456):companies 全局单份 + projects per-companyId map(口径统一 limit=100,原 PMOPage 20 与 Library/FileRefChip 100 漂移收口);纪律走 fetchDiscipline(TTL 30s / 单飞 / seq),无 SSE 失效事件(ADR 决策 3),新鲜度 = TTL + 重连强刷 + 兜底轮询;错误落 *Error 状态(PMOPage 错误条用)、ensure 永不 reject、失败不落锚点。PmoProject = components/pmo/types.Project + gitRepo/deliveries(FileRefChip 匹配字段) | | `usePmoDataStoreSync` | `hooks/usePmoDataStoreSync.ts` | pmoDataStore 接线(#456):useDataPlaneSync 复用,无本域 SSE 事件 → noop handleEvent;ensureFresh = companies + 已驻留 projects 键全量对齐。App 级单点挂载(App.tsx PmoDataSync,同 RequirementChainSync 模式) | @@ -96,13 +97,13 @@ 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 颜色语义不动(**2026-09-11 起 AgentDashboard StatFilter 对齐同档**:数字 --fs-stat→--fs-title 归 `.agd-stat-num`,激活态补同款 accent 底线,两页页头 chip 视觉一致);② 「待人工」行级提权——`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 方向状态)。**useChannelMessages 暴露 `error` 态**(#482):首拉/兜底轮询共用 fetchMessages,失败置 error(消息不清空),成功/频道切换清——ChannelDetailPage 据此三态分流:加载中骨架 / error 且 messages 空 → 错误态+重试钮(走 refresh)/ 真空态示例 chip;已有消息时轮询失败不整屏替换。已知留白: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;已有消息时轮询失败不整屏替换。**#548:合并/error 语义本体已迁 channelMessageStore**(useChannelMessages 退薄接线,行为口径不变)。已知留白: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 负载消费约定·message_updated**(#315,ADR 2026-08-24 D1/D2):`channel.message_updated` 优先读负载 `message` 全量本体(meta 为后端合并后真值)整体原位替换;旧形状(无 `message` 字段)回退顶层增量 `content`/`meta` patch。消费落点 #548 起 = `channelMessageStore.applyMessageUpdated`(自 useChannelMessages 迁入,口径不变)。`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 断开兜底。 - **频道列表唯一数据面 = rosterStore channels 切片**(#455,ADR 2026-08-31「同域数据不住第二份」覆盖率收口):PMOPage(Publish 弹窗频道列表)/ WorkUnitDetailPage(频道名解析)/ PublishProjectDialog(选项)/ CommandPalette(频道域搜索)的 4 处裸 `channelApi.list` 已全部改读切片 + `ensureFresh()`——30s TTL + single-flight 复用 fetchDiscipline 零新机制,新建频道经 appendChannel 写穿不滞后;PublishProjectDialog 切片为空时回退 props(对齐原拉取失败回退口径)。**例外维持现状**:`components/setup/joinChannel.ts`(#465 一次性首用流程,triage 决议)。新频道列表消费方一律订阅切片,禁止再写裸 `channelApi.list`(验收 grep:除 stores/ 与 __tests__/ 外唯一命中就是 joinChannel)。 - **视觉体系**:`theme.css` 深色变量 + `mission-control.css` 三栏布局(mc-*)与工具类(u-*)。#380/#392 起正文/UI = `--font-sans`,mono 限定代码/ID/时间戳/统计数字(`--fs-stat` 消费方须带 `font-mono`)。禁止写死浅色 Tailwind 类。规范:`docs/specs/ui/style-guide.md`。**2026-09 批次C**(docs/plans/2026-09-ui-ux-polish.md):① `index.css` 加 `@theme` 把 Tailwind `text-xs/sm/base/lg/xl` 重映射到字号 token 阶梯(11/12/13/16/22),`text-2xl+` 保留默认值仅限装饰图标;② 死 CSS 清理——`animations.css` 整文件删除(`prefers-reduced-motion` 段并入 theme.css),theme.css 删 `.tag-*`/`.nav-tab`/`.list-item`/`.table-*`/`.hero-*`/`.feature-*`/`.animate-*`/`.stagger-*`/`.card-glow` 零消费死族;③ toast 全 token 化(`--*-dim/-border`、圆角 4px;**「无进出场动效」决策 2026-09-10 批次 E-3 已推翻**——toast 允许 opacity 进出场 fade,`--motion-base` + `data-closing` 幂等守卫防重复关闭,归入白名单场景②语义扩展;治理变更当场批准,commit `11483225` 带 `Governance-Approved: session`);④ `.mc-status-running` 全局统一 accent 绿(running 单义),warning 黄只留「待处理/需介入」语义。 -- **新内容进场渐隐高亮模式**(批次 E-3,2026-09-10 治理确认):SSE 新插入 WU 行 `.wu-row-new`(workunits.css)/ 频道新消息 `.mc-msg-new`(mission-control.css)= `--accent-dim` 底色,页面 2s 后移类、经基类既有 `background-color`/`outline-color` 过渡(`--motion-base`)渐隐而非瞬隐;`.mc-msg-highlight` 同模式(基类挂透明 outline 提供过渡起点)。按 style-guide §2.5 白名单场景③「状态色切换」语义落地——仅颜色属性过渡,无 transform/位移;首拉历史行/消息不标 fresh,测试锁定。 +- **新内容进场渐隐高亮模式**(批次 E-3,2026-09-10 治理确认):SSE 新插入 WU 行 `.wu-row-new`(workunits.css)/ 频道新消息 `.mc-msg-new`(mission-control.css)= `--accent-dim` 底色,页面 2s 后移类、经基类既有 `background-color`/`outline-color` 过渡(`--motion-base`)渐隐而非瞬隐;`.mc-msg-highlight` 同模式(基类挂透明 outline 提供过渡起点)。按 style-guide §2.5 白名单场景③「状态色切换」语义落地——仅颜色属性过渡,无 transform/位移;首拉历史行/消息不标 fresh,测试锁定(#548:频道消息 fresh 集合本体 = `hooks/useFreshMessageIds.ts`,自 ChannelDetailPage 迁出)。 - **批次 E 汇总**(docs/plans/2026-09-ui-polish-batch-e.md,2026-09-10,三 commit):E-1 `1a615e18` token 合规清扫——workunits.css/responsive.css 硬编码 transition 归 `--motion-*`,BackButton/ProjectDetailPage/ProjectMap 内联间距字号归 token;E-2 `748ce59c` 状态反馈统一——Skeleton 骨架替代「加载中...」、WU 列表/详情与 ProjectDetail 错误条补重试、DiscussionPanel 失败 toast、WU 列表空态去 emoji 改 IconClipboard+双语境 CTA 与 PMO OKR 空态 CTA、WU 列表创建/加载更多改 ui/Button loading;E-3 `11483225` 微交互——wu-detail.css stepper/hover 白名单过渡 + `.wu-detail-headslot` 首屏占位、PMOPage tab 归 `.u-tab/.u-tab-active` 去内联 borderBottom、「✓ 已复制」u-accent 染色+过渡、toast 进出场 fade(决策变更见批次C 条目③)、新内容渐隐高亮(见上条)、useStreamFollow `awayNewCount` 回底浮钮未读计数。 - **批次 F-1**(docs/plans/2026-09-11-ui-polish-batch-fg.md,2026-09-11,`15873980`)假空态修复:LibraryPage fetchDocs / LibraryDocPage fetchDoc 的 catch 原先只 console.error,失败落「暂无文档 / 文档未找到」假空态——补 error state + `u-err-dim` 错误条 + 重试按钮(PMOPage/ProjectDetailPage 正本模式),错误时不再渲染空态文案;KnowledgePage 全局搜索 catch 原先静默 `setSearchResults([])` 落「无匹配结果」假空态——改 toast.error(serverErrorMessage 优先,对齐 handleLoadMore 批次A 模式)+ 回 tab 视图保留输入词;KnowledgePage tabQ.error 原先全程未渲染——内容区补错误条 + `tabQ.reload` 重试,unified/gap 两分支空态均加 `tabQ.error` 门控。 - **批次 F-2**(docs/plans/2026-09-11-ui-polish-batch-fg.md,2026-09-11)Modal a11y 基座:`ui/Modal` 补 Escape 关闭(document keydown,卸载清理)+ `role="dialog"`/`aria-modal="true"` + 焦点管理(打开聚焦弹窗内首个可聚焦元素、无则弹窗本体 tabIndex=-1;关闭/卸载还焦触发前 document.activeElement;焦点 effect 仅随 open 翻转执行,回调身份变化不抢焦)——props 契约不变,CreateRoleModal/RoleSkillsModal/ConfirmDialog 等消费方零改动受益;AuditLogsPage 手搓详情弹层归并 ui/Modal(获得遮罩点击关闭 + 标题栏 ✕ + Escape),表格行补 tabIndex+onKeyDown(Enter/Space 开详情);WorkUnitDrawer/NotificationBell 面板/MoreDropdown 补 Escape 关闭(抽屉内 Escape 事件源在 `.modal-overlay` 内时让弹层自管,避免一按双关),MoreDropdown 触发器补 `aria-expanded`。 @@ -125,7 +126,7 @@ Web 前端主源码。路由、全局状态、API 客户端、UI 组件、样式 - **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 跟随撑高,离底浮「回到底部」。 - **频道流虚拟化**(#325,ADR 2026-08-24 channel-stream-virtualization;2026-08-24 实施落地):`@tanstack/react-virtual`(core 3.17.8),一期仅渲染层、数据层裁剪归 #326。virtualizer 建在 `useStreamFollow`:`scrollToFn` 自定义过 observed-top 台账(一切 virtualizer 滚动写入记账,含库测量校正补偿);prepend 补偿 = `measurementsCache` 按 key 查 item start 位移 + item 内偏移(不用 prepend 后 DOM 查询——锚行已掉出窗口);阅读位置恢复两段式(`scrollToIndex` 粗定位 → **reconcile 收敛后**精校正,#339:scrollToIndex 会置内部 scrollState 跑 rAF reconcile 循环、测量落地期间反复把 scrollTop 改写回 align-start 目标,锚行进 DOM 即落地必被踩掉;现收敛(scrollState 清空,`virtualizerScrollSettled` 按版本运行时锚定)后经 rAF 轮询一次性落地,决策纯函数 `planFineAdjust`(wait/abandon/apply;读者滚动在途放弃;收敛后锚行缺席有 10 帧宽限——settle 观察帧可能早于锚行 React 提交一拍);读者滚动/换频道/回底意图均放弃挂起校正);钉底/跟随维持元素几何(spacer 高 = totalSize,距底即末行局部几何)。页面侧:头部块(加载更早/折叠 toggle/空态)与 spacer 分离,`scrollMargin` = 头部高(RO 测量);行渲染块平移 + `measureElement`。纯函数在 `utils/streamVirtual.ts`(key/mid→item 映射/补偿数学)。**测试 seam**:`STREAM_VIRTUAL_ENABLED`(`import.meta.env.MODE !== 'test'`)——jsdom 无布局走全量渲染,页面测试不碰虚拟化;虚拟化行为 = 纯函数单测 + 浏览器实测。实测基线(2026-08-24,#系统 599 条):DOM 常驻 18 行、prepend 补偿漂移 0px、恢复偏差 121px、跟随/回底正常。旧已知坑「向上滚入未测量区边滚边修正漂移」已由 **#449**(#438 方案 C,2026-09-02 实施)消除:D4-2 的 `shouldAdjustScrollPositionOnItemSizeChange=()=>false` 覆写已删,启用 virtual-core 3.17.8 库默认校正谓词(首测且行 top 在视口上方 → 补偿 scrollTop,正是漂移场景;重测分支仅整行在视口上方且非 backward 滚动时补偿);自家 prepend 补偿/两段式恢复/钉底跟随逻辑不变,理论互补(报告 §3-C),新实测基线待浏览器实测回写(#449 AC,旧基线 2026-08-24:DOM 常驻 18 行、prepend 漂移 0px、恢复偏差 121px)。#438 方案评估(`.studio/research/2026-09-02-virtual-scroll-updrift-options.md`)另探明耐久事实:`getMeasurements` memo 依赖不含 estimateSize 且每次 resizeItem 触发重算重读估计值——动态改估计值本身会成为漂移源,运行中不可行。**#450**(#438 方案 A,2026-09-02 实施):estimateSize 分型静态估计替代常量 120——`estimateStreamItemSize(item)` 按类型给值(系统播报 46 / human 88 / agent 332 / 卡片 820 / 骨架 40 / 日期分隔 +30 / compact −24 / 折叠线程 toggle +24 / 折叠过程组 36;展开线程 = anchor + 逐条回复累计),档位值 = #系统 全量实测行高分布定档(2373 条频道、2370 虚拟行覆盖,分布与对比见报告附录),同口径 mean|Δ| 70.7→5.7px(−92%)、totalSize 失真 +112%→−9%;静态常量表,禁止随测量动态改写(§3-B 证伪)。注意事项:**升级 @tanstack/react-virtual 须复核默认谓词语义**(本改动行为绑定库默认 `defaultShouldAdjust`,与既有 `scrollState` 运行时假设同级)。 -- **频道数据层降级**(#326,ADR 2026-08-25 channel-message-data-pruning):远离视口的上方历史原位骨架化(`degraded` 标记,剥 content/meta 大头、留结构字段 + meta `{status,cardType}` 标量子集——isCompleted/mergeable 判定输入)。判定纯函数 `utils/messagePruning.ts`(`degradeMessage`/`planPrune`,K=100 保底、D1=50 降级 / D2=30 水合迟滞);接线在 `useChannelMessages.syncPruning(anchorMid)`——ChannelDetailPage 以首个 virtual item 代表消息 id 驱动(仅虚拟化路径,jsdom 页面测试不降级)。水合 = 防抖 200ms 整页取(before=首个非骨架 id,limit 100)+ mergePage 归并原位复活,**不触碰 hasMore**;in-flight 期间触发重排不丢;`message_updated` 全量本体天然复活,legacy patch 仅带 content 才复活。骨架渲染 = `mc-msg-skeleton` 占位行(保留 data-message-id 供锚点定位;thread anchor 骨架 → 整线程项占位)。阅读位置粗锚/精锚分级:锚行骨架 ⇔ 存档 `coarse: true` 或恢复时命中骨架 → scrollToIndex 行级定位即完成,不像素精校正。 +- **频道数据层降级**(#326,ADR 2026-08-25 channel-message-data-pruning):远离视口的上方历史原位骨架化(`degraded` 标记,剥 content/meta 大头、留结构字段 + meta `{status,cardType}` 标量子集——isCompleted/mergeable 判定输入)。判定纯函数 `utils/messagePruning.ts`(`degradeMessage`/`planPrune`,K=100 保底、D1=50 降级 / D2=30 水合迟滞);接线 #548 起在 `channelMessageStore.syncPruning`(自 useChannelMessages 迁入,防抖/水合簿记按 channelId 模块级持有)——ChannelDetailPage 以首个 virtual item 代表消息 id 驱动(仅虚拟化路径,jsdom 页面测试不降级)。水合 = 防抖 200ms 整页取(before=首个非骨架 id,limit 100)+ mergePage 归并原位复活,**不触碰 hasMore**;in-flight 期间触发重排不丢;`message_updated` 全量本体天然复活,legacy patch 仅带 content 才复活。骨架渲染 = `mc-msg-skeleton` 占位行(保留 data-message-id 供锚点定位;thread anchor 骨架 → 整线程项占位)。阅读位置粗锚/精锚分级:锚行骨架 ⇔ 存档 `coarse: true` 或恢复时命中骨架 → scrollToIndex 行级定位即完成,不像素精校正。 - **WU 详情页**(`/workunits/:id`,#396 重构,spec §5 定稿变体 C)= Header(BackButton+徽章+标题)→ meta strip(#440:涉及角色/AC 数/当前阶段,缺项不占位)→ 四站 StationStepper + 关键事件 chip 行 → 左栏 260px 判断/操作输入(关键事实卡[含 Token 行入口]→证据台账→依赖与验收→巡检机会→闸门动作,后三节条件渲染)→ 右栏过程/记录(ExecutionFlow→TranscriptViewer→DiscussionPanel,后两者自带功能头不叠区标题);样式 `styles/wu-detail.css`(wu-detail-* 作用域,<768px 塌单栏;生命周期事件 chip 类 = `.wu-ev-chip`,2026-09 批次C 自 `.wu-chip` 改名——与 workunits.css 列表行弱化 chip 同名不同形的 lazy 注入顺序冲突解耦)。**认领人展示两处共存**:meta strip 与左栏事实卡各渲染一份 AssigneeLabel(断言取其一)。**E2-4**:闸门动作节改挂共享 `WuGateActions`(写路径 = 成功后 actionTick 重拉;BlockedActions 仍各页自挂)。 - **Router basename**(#275/#291):`main.tsx` 接 `import.meta.env.BASE_URL`(dev=`/dev/`、生产=`/`),深链/刷新依赖此对齐;路由层回归测试 `__tests__/App-basename.test.tsx`。 - **全局搜索(批次 D-2 项 8,docs/plans/2026-09-ui-interaction-polish.md)**:`CommandPalette`(App 根 lazy 挂载,open state 在 App)= 居中顶部面板(`.modal-overlay` 遮罩 + `.cmdk-*` 样式层,`styles/command-palette.css`,z-index 100 高于 modal-overlay 的 50 同 .select-panel 档)。Cmd/Ctrl+K 唤起/再按关闭(App 根 `useGlobalShortcuts` 注册,`allowInInput: true` 唤起键输入框聚焦也触发);Esc/点遮罩关闭。300ms 防抖(LibraryPage 先例)并行扇出四域走 `fanOut`:频道(channelApi.list 全量 + 客户端名称子串过滤)、任务(workunitApi.list 服务端 q+limit 5)、需求(requirementApi.list 全量 + 客户端标题/编号过滤)、知识(knowledgeApi.search,KnowledgePage 同端点),各取前 5,单域失败隔离(该组不渲染不炸整批);seq 守卫拒迟到响应,关闭即清防抖定时器无残留。行 = 名称 + 弱化 meta(频道=类型 / WU=状态词+id 前 8 mono / REQ=状态词+编号 mono / 知识=类型);↑↓ 循环高亮 + Enter 跳转(IME 选词 Enter 走 useImeEnterGuard 不跳)+ 鼠标 hover/点击同语义;导航 频道→/channels/:id、WU→/workunits/:id、REQ→/pmo?tab=reqs、知识→/knowledge。loading/结果态全派生(`resultQuery === 当前词` 门控,零额外 set-state-in-effect)。`useGlobalShortcuts` 是注册底座(GlobalShortcut[] 注册位,首个命中即消费;非唤起类快捷键输入聚焦不触发的守卫内建,后续 g c/g p 导航直接加注册项)。入口可发现性 = MoreDropdown「搜索 ⌘K」(可选 `onOpenSearch` prop,经 TopNav 透传 App 开面板)。 @@ -141,12 +142,12 @@ Web 前端主源码。路由、全局状态、API 客户端、UI 组件、样式 - **设置域 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)。 - **「默认执行机器」设置面退役(#481,2026-09-11)**:`components/settings/DefaultExecutionMachineSection.tsx`(+测试)删除,Settings 页剩工程候选/主题两节(E8 三节布局成为历史)——机器指针不再是配置概念,频道级唯一工程配置点 = 顶栏「默认工程」(`channel.defaultPath`)。`api/index.ts` 的 `workspaceTokenApi`(零 importer)随 API 侧 workspace token 子系统一并删除。WorkspacePage 保留(直开/历史链接可达),原唯一站内入口(设置页机器名直链)随节删除;#448「非 Admin 不发 workspaceApi.list()」短路逻辑随节一并移除。 - **「通知渠道」设置区(#525 P2-6,2026-09-12,决策 #516 项⑦⑧)**:`components/settings/NotifyChannelsSection.tsx`(挂 Settings 页工程候选之后)——企业微信 webhook URL 收编(`PUT /api/v1/notify-channels/wecom`,配置存储优先、env 兜底作部署默认,maskedUrl + 来源徽标 + 发送测试消息)+ ClawBot 个人微信扫码绑定(iLink 协议内嵌直连:`bind/start` 拿 qrcodeUrl → `qrcode` 包 toDataURL 渲染二维码 → 2s 轮询 `bind/status` 至 confirmed;已绑定展示 ilinkUserId + 解绑 + 测试消息)+ 浏览器通知开关(`utils/browserNotification.ts` 新增 localStorage 偏好 `studio:browser-notifications-enabled` 缺省开,`showBrowserNotification` 关闭时直接 return false)。API client = `api/notifyChannels.ts`;配置有真实消费方(notifyAlert fan-out 企微/ClawBot sink),非 #434 死配置复活。 -- **频道翻页游标(#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 兜底超时自动消失 +- **频道翻页游标(#319,2026-08-24)**:`loadMore` 的 `before` = 最老消息 **id**(原 createdAt 时间戳,同毫秒多条会漏/重);后端锚点 id 不存在时返回空页 + hasMore=false,loadMore 据此自然停止(#548:实现落 channelMessageStore.loadMore,经 useChannelMessages 薄接线透出) +- **频道发送乐观回显(#486,2026-09-11)**:`sendMessage` 先插本地 pending 消息(id 前缀 `pending-`,`ChannelMessage.pending` 为仅客户端字段,服务端不下发),成功后服务端本体原位替换(SSE 回声先到按 id 去重同样收敛不重复),失败回滚 pending + 上抛(ChannelInput 回灌草稿 + toast 的批次A路径不变);pending 不作 loadMore 分页游标(本地 id 服务端不存在,作锚点会翻空页)、ChannelMessageItem 不出回复/转任务动作,渲 `.mc-msg-pending` 降不透明度 + 气泡内「发送中…」标记;ChannelInput 发送中 textarea 不再禁用(可接着打下一条),sending 只保留发送钮/handleSend 防重复提交(#548:实现落 channelMessageStore.sendMessage) +- **线程回复「已送达/等待 agent」即时反馈(#493,2026-09-11)**:`ChannelDetailPage.handleSend` 线程回复送达且 `sent.workUnitId` 非空(= workUnitId 继承成功、会触达 agent;顶层消息与归档降级回复不出)→ composer 上方出 `.mc-agent-ack` 状态条「已送达,等待 agent 响应…」(state `awaitingAgent` = { wuId, since });该 WU 的 agent 新消息到达(SSE 进 messages 即判;#548 判定本体 = channelMessageStore 旁纯函数 `agentAnsweredOf`)或 30s 兜底超时自动消失 - **quote/reply 预览点击定位上游(channel 上下游优化 Phase 1 / AC1,2026-09-14)**:ChannelDetailPage 抽通用 `locateMessage(mid)`(useCallback 稳定引用,?highlight effect/quote 块/reply 预览条三入口共用)——已加载 → 展开所属线程根(Phase 2 起经 `expandThreadOf`/`rootAnchorIdOf` 求根,不再假设 replyToId=anchor)+ setHighlightId;未加载 → `pageBackToFind` 翻页循环,exhausted → toast 不静默;防重入按 mid(`locatingMidRef`)。ChannelMessageItem quote 在 `onQuoteClick` 提供且父消息已加载时 button 化(父掉出分页 findMessage 未命中 = 保持纯展示);ChannelInput reply 预览正文区 button 化 `.mc-input-reply-jump`(✕ 取消钮为兄弟节点,HTML 禁嵌套按钮) - **底部输入区视觉归组(channel 上下游优化 Phase 4 / AC6,2026-09-14)**:SuggestionChips / `.mc-agent-ack` / ChannelInput 三段收进 `.mc-composer-stack` 统一容器(纯结构包裹,交互不动)——bg-secondary + 顶部 hairline + 水平 padding(--space-6)由容器独揽;`.mc-inputbar` 只留 `position: relative` 作 mention 弹框定位锚(弹框 `left` 随 padding 上移归 0),`.mc-suggest`/`.mc-agent-ack` 去掉各自背景/内边距作容器内平铺分段;`.mc-input-reply` 间距走 --space token 对齐 quote 视觉语言(2px accent 左边线 + 圆角 3px) -- **频道主链路前端埋点(#520 测量②,2026-09-12)**:`utils/clientPerf.ts` 常驻开启(read-metrics sink 模式但生产默认开、测试模式默认关),事件经既有 `POST /api/v1/events`(source `web-client`)落 studio-events 事件流。接线三点:ChannelInput.handleSend 守卫通过后 `emitSendClick`;`useChannelMessages` SSE `channel.message_sent` 仅新消息(不在列表的回声不记)`markReceiptArrived`;ChannelDetailPage messages effect(提交后 = 渲染完成)先 `emitPageFirstRender` 再逐条 `emitReceiptRendered`——首拉/翻页/水合的历史消息无标记天然跳过,空频道不发 page_load 属正常。 +- **频道主链路前端埋点(#520 测量②,2026-09-12)**:`utils/clientPerf.ts` 常驻开启(read-metrics sink 模式但生产默认开、测试模式默认关),事件经既有 `POST /api/v1/events`(source `web-client`)落 studio-events 事件流。接线三点:ChannelInput.handleSend 守卫通过后 `emitSendClick`;SSE `channel.message_sent` 仅新消息(不在列表的回声不记)`markReceiptArrived`(#548:落点 channelMessageStore.applyMessageSent);ChannelDetailPage messages effect(提交后 = 渲染完成)先 `emitPageFirstRender` 再逐条 `emitReceiptRendered`——首拉/翻页/水合的历史消息无标记天然跳过,空频道不发 page_load 属正常。 - **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 项投影(#546 起投影单源 = notificationStore 旁纯函数 `needInputViewOf`,页面内联派生已删),本地 blocked REST/SSE 维护已删;reply 不再排除闸门类,排除改面板分区;点条目定位 = 该 WU 当前提问消息高亮(#533:锚点 = stateItems.messageId 后端唯一派生点,投影随 waitingWus 下发,前端 latestQuestionIdByWu 反推已删;缺省 → 不挂回复区/不提升/定位 toast 反馈,全 fail-closed),#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 频道线程最新非人类消息;#533 起收口为「WU 当前提问消息」唯一派生点,频道页回复区/提升/chip 定位全消费它),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` 行动按钮)给「下一个 →」跳下一条频道。**#523(#516 决议③④)人闸催办出声面**:服务端 gate-escalation tier1 补发 SSE `notification.created` 信封 → NotificationBell 监听后 `load()` 重拉 + 经 `utils/browserNotification.ts` 弹浏览器原生通知(granted 直发 / denied·unsupported 静默;default 不主动弹权限请求,首次点开铃铛面板这一既有手势时 `requestPermission`) | - **`.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-* 壳内按钮的单类样式不再需要双类复合绕行(既有绕行保留未清)。 diff --git a/apps/web/src/hooks/__tests__/useChannelEvents.test.ts b/apps/web/src/hooks/__tests__/useChannelEvents.test.ts index 7e68d1b63..621544f08 100644 --- a/apps/web/src/hooks/__tests__/useChannelEvents.test.ts +++ b/apps/web/src/hooks/__tests__/useChannelEvents.test.ts @@ -23,6 +23,8 @@ vi.mock('../../api/websocketHooks', () => ({ })); import { useChannelMessages } from '../useChannelEvents'; +// #548:messages 数据面收编模块单例 store——每用例清数据面+纪律簿记,防跨用例泄漏 +import { useChannelMessageStore } from '../../stores/channelMessageStore'; const iso = (s: number) => new Date(s * 1000).toISOString(); @@ -56,6 +58,7 @@ describe('useChannelMessages', () => { beforeEach(() => { vi.clearAllMocks(); seq = 0; + useChannelMessageStore.getState().__resetForTests(); mockListMessages.mockResolvedValue({ data: { data: [], hasMore: false } }); mockOnEvent.mockImplementation((h: (msg: WebSocketMessage) => void) => { handler = h; @@ -344,6 +347,7 @@ describe('useChannelMessages(#313 门禁轮询接线)', () => { beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); + useChannelMessageStore.getState().__resetForTests(); mockCtx.status = 'connected'; mockOnEvent.mockReturnValue(() => {}); mockListMessages.mockResolvedValue({ data: { data: [], hasMore: false } }); @@ -395,6 +399,7 @@ describe('useChannelMessages(#313 门禁轮询接线)', () => { describe('useChannelMessages 错误态(#482)', () => { beforeEach(() => { vi.clearAllMocks(); + useChannelMessageStore.getState().__resetForTests(); mockCtx.status = 'connected'; mockOnEvent.mockReturnValue(() => {}); }); diff --git a/apps/web/src/hooks/__tests__/useChannelMessages-optimistic.test.ts b/apps/web/src/hooks/__tests__/useChannelMessages-optimistic.test.ts index 9cfea5495..dec8ea53c 100644 --- a/apps/web/src/hooks/__tests__/useChannelMessages-optimistic.test.ts +++ b/apps/web/src/hooks/__tests__/useChannelMessages-optimistic.test.ts @@ -21,6 +21,8 @@ vi.mock('../../api/websocketHooks', () => ({ })); import { useChannelMessages } from '../useChannelEvents'; +// #548:messages 数据面收编模块单例 store——每用例清数据面+纪律簿记,防跨用例泄漏 +import { useChannelMessageStore } from '../../stores/channelMessageStore'; const iso = (s: number) => new Date(s * 1000).toISOString(); @@ -52,6 +54,7 @@ describe('useChannelMessages — 乐观回显(#486)', () => { beforeEach(() => { vi.clearAllMocks(); + useChannelMessageStore.getState().__resetForTests(); mockListMessages.mockResolvedValue({ data: { data: [], hasMore: false } }); mockOnEvent.mockImplementation((h: (msg: WebSocketMessage) => void) => { handler = h; diff --git a/apps/web/src/hooks/__tests__/useChannelMessages-perf.test.ts b/apps/web/src/hooks/__tests__/useChannelMessages-perf.test.ts index 2ff1447c1..16d4e6df3 100644 --- a/apps/web/src/hooks/__tests__/useChannelMessages-perf.test.ts +++ b/apps/web/src/hooks/__tests__/useChannelMessages-perf.test.ts @@ -20,6 +20,8 @@ vi.mock('../../api/websocketHooks', () => ({ })); import { useChannelMessages } from '../useChannelEvents'; +// #548:messages 数据面收编模块单例 store——每用例清数据面+纪律簿记,防跨用例泄漏 +import { useChannelMessageStore } from '../../stores/channelMessageStore'; import { setClientPerfSink, resetClientPerfSink, emitReceiptRendered } from '../../utils/clientPerf'; function msg(id: string, over: Partial = {}): ChannelMessage { @@ -45,6 +47,7 @@ describe('useChannelMessages — #520 回执渲染计时起点', () => { beforeEach(() => { vi.clearAllMocks(); resetClientPerfSink(); + useChannelMessageStore.getState().__resetForTests(); setClientPerfSink(mockSink); mockListMessages.mockResolvedValue({ data: { data: [], hasMore: false } }); mockOnEvent.mockImplementation((h: (msg: WebSocketMessage) => void) => { diff --git a/apps/web/src/hooks/__tests__/useChannelMessages-pruning.test.ts b/apps/web/src/hooks/__tests__/useChannelMessages-pruning.test.ts index ab554e999..d55e6e0c2 100644 --- a/apps/web/src/hooks/__tests__/useChannelMessages-pruning.test.ts +++ b/apps/web/src/hooks/__tests__/useChannelMessages-pruning.test.ts @@ -21,6 +21,8 @@ vi.mock('../../api/websocketHooks', () => ({ })); import { useChannelMessages } from '../useChannelEvents'; +// #548:messages 数据面收编模块单例 store——每用例清数据面+纪律簿记,防跨用例泄漏 +import { useChannelMessageStore } from '../../stores/channelMessageStore'; import { degradeMessage } from '../../utils/messagePruning'; // 小参数便于构造场景:K=3、D1=2、D2=1 @@ -47,6 +49,7 @@ describe('useChannelMessages 降级/水合', () => { beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers(); + useChannelMessageStore.getState().__resetForTests(); mockListMessages.mockResolvedValue({ data: { data: [], hasMore: false } }); mockOnEvent.mockImplementation(() => () => {}); }); diff --git a/apps/web/src/hooks/__tests__/useFreshMessageIds.test.ts b/apps/web/src/hooks/__tests__/useFreshMessageIds.test.ts new file mode 100644 index 000000000..60c2b55f6 --- /dev/null +++ b/apps/web/src/hooks/__tests__/useFreshMessageIds.test.ts @@ -0,0 +1,85 @@ +// useFreshMessageIds(#548 自 ChannelDetailPage 迁出): +// SSE 新到达消息 id 集 + 2s 定时清除(批次 E-3 渐隐高亮口径)—— +// 全部新到达消息(含自己发送的回显);首拉与翻页 prepend/水合归并的历史不标 +// (createdAt 早于到达前最新一条即历史)。 +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import type { ChannelMessage } from '../../api/channel'; +import { useFreshMessageIds } from '../useFreshMessageIds'; + +const iso = (s: number) => new Date(s * 1000).toISOString(); + +function msg(id: string, seq: number): ChannelMessage { + return { + id, + channelId: 'ch-1', + authorType: 'agent', + content: `内容-${id}`, + replyToId: null, + createdAt: iso(seq), + }; +} + +describe('useFreshMessageIds', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('首载:全部记为已见,不高亮', () => { + const { result } = renderHook(({ msgs }) => useFreshMessageIds(msgs), { + initialProps: { msgs: [msg('m1', 0), msg('m2', 1)] }, + }); + expect(result.current.size).toBe(0); + }); + + it('新到消息(createdAt ≥ 已见最新)→ 进 fresh 集', () => { + const { result, rerender } = renderHook(({ msgs }) => useFreshMessageIds(msgs), { + initialProps: { msgs: [msg('m1', 0)] }, + }); + rerender({ msgs: [msg('m1', 0), msg('m2', 1), msg('m3', 2)] }); + expect([...result.current].sort()).toEqual(['m2', 'm3']); + }); + + it('翻页 prepend / 水合归并的历史(createdAt 早于已见最新)→ 不标', () => { + const { result, rerender } = renderHook(({ msgs }) => useFreshMessageIds(msgs), { + initialProps: { msgs: [msg('m3', 2)] }, + }); + rerender({ msgs: [msg('m1', 0), msg('m2', 1), msg('m3', 2)] }); + expect(result.current.size).toBe(0); + }); + + it('2s 后清除(经基类过渡渐隐)', () => { + const { result, rerender } = renderHook(({ msgs }) => useFreshMessageIds(msgs), { + initialProps: { msgs: [msg('m1', 0)] }, + }); + rerender({ msgs: [msg('m1', 0), msg('m2', 1)] }); + expect(result.current.has('m2')).toBe(true); + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(result.current.size).toBe(0); + }); + + it('清除窗口内又有新到 → 重新计时(新旧同集,2s 后一并清除)', () => { + const { result, rerender } = renderHook(({ msgs }) => useFreshMessageIds(msgs), { + initialProps: { msgs: [msg('m1', 0)] }, + }); + rerender({ msgs: [msg('m1', 0), msg('m2', 1)] }); + act(() => { + vi.advanceTimersByTime(1000); + }); + rerender({ msgs: [msg('m1', 0), msg('m2', 1), msg('m3', 2)] }); + act(() => { + vi.advanceTimersByTime(1000); // 距首次 2s,但计时已重置 + }); + expect(result.current.has('m2')).toBe(true); + expect(result.current.has('m3')).toBe(true); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(result.current.size).toBe(0); + }); +}); diff --git a/apps/web/src/hooks/useChannelEvents.ts b/apps/web/src/hooks/useChannelEvents.ts index aa2ddd3e0..a7b2c3cf8 100644 --- a/apps/web/src/hooks/useChannelEvents.ts +++ b/apps/web/src/hooks/useChannelEvents.ts @@ -1,35 +1,17 @@ // Channel SSE hook — B2: EventSource 实时推送替代 3s 轮询 // #313:首拉 / SSE 断开 10s 兜底 / visibility 门禁统一收敛到 useGatedPoll -import { useState, useEffect, useCallback, useRef } from 'react'; -import { channelApi, type ChannelMessage, type FileRef } from '../api/channel'; +// #548:messages 数据面收编 channelMessageStore(ADR 2026-08-31 模式延伸)—— +// 本 hook 退为薄接线:状态全在 store(per-channelId 切片),此处只保留 +// 取数时机(useGatedPoll/频道切换重拉)与 SSE 订阅路由;对外契约形状不变。 +import { useEffect, useCallback, useRef } from 'react'; +import { type ChannelMessage, type FileRef } from '../api/channel'; import { useWebSocketContext } from '../api/websocketHooks'; import { useGatedPoll } from './useGatedPoll'; -import { degradeMessage, planPrune, PRUNE_KEEP_RECENT, PRUNE_DEGRADE_DISTANCE, PRUNE_HYDRATE_DISTANCE, PRUNE_HYDRATE_PAGE_LIMIT, type PruneOptions } from '../utils/messagePruning'; -import { markReceiptArrived } from '../utils/clientPerf'; +import { useChannelMessageStore, type MessageUpdatedPayload } from '../stores/channelMessageStore'; +import type { PruneOptions } from '../utils/messagePruning'; -/** #287(清单 P2 #19):增量到达按 createdAt 升序归位 + id 去重。 - * 下游 groupIntoThreads 单遍归组要求 anchor 先于 reply 出现;一律 push 尾部会让 - * 乱序/孤儿到达的线程回复滞留主流(走查 F17:同一消息刷新前后两种位置)。 - * 有序插入后,任意到达路径下的顺序与刷新全量列表一致,孤儿回复在 anchor 到达时归并。 */ -function insertMessage(prev: ChannelMessage[], msg: ChannelMessage): ChannelMessage[] { - if (prev.some(m => m.id === msg.id)) return prev; - const t = new Date(msg.createdAt).getTime(); - let idx = prev.length; - while (idx > 0 && new Date(prev[idx - 1].createdAt).getTime() > t) idx--; - return [...prev.slice(0, idx), msg, ...prev.slice(idx)]; -} - -/** #328:refetch 合并——最新一页按 id 归并进现有列表:已存在的以服务端版本刷新, - * 新消息按 createdAt 有序插入,已 prepend 的历史页原样保留。 */ -function mergePage(prev: ChannelMessage[], page: ChannelMessage[]): ChannelMessage[] { - const fresh = new Map(page.map(m => [m.id, m])); - const prevIds = new Set(prev.map(m => m.id)); - let next = prev.map(m => fresh.get(m.id) ?? m); - for (const m of page) { - if (!prevIds.has(m.id)) next = insertMessage(next, m); - } - return next; -} +/** 缺键(未加载)时的稳定空列表——防下游 memo 因每渲染新引用失效 */ +const EMPTY_MESSAGES: ChannelMessage[] = []; export interface UseChannelMessagesOptions { /** #326:降级/水合阈值覆盖(测试用小参数;缺省 = messagePruning 常量) */ @@ -37,211 +19,74 @@ export interface UseChannelMessagesOptions { } export function useChannelMessages(channelId: string | undefined, options?: UseChannelMessagesOptions) { - const [messages, setMessages] = useState([]); - // 初值覆盖挂载首拉;channelId undefined→defined 的上升沿由下方渲染期分支补齐 - const [loading, setLoading] = useState(!!channelId); - // #482:首拉/兜底轮询失败暴露 error 态(原仅 console.error → 页面把加载故障渲染成假空态) - const [error, setError] = useState(null); - const [hasMore, setHasMore] = useState(false); const { onEvent } = useWebSocketContext(); - - // 切换频道时渲染期置 loading(替代原 effect 内同步置位,早一帧); - // 旧频道消息保留到新数据到达,不做清空 - const [prevChannelId, setPrevChannelId] = useState(channelId); - if (prevChannelId !== channelId) { - setPrevChannelId(channelId); - if (channelId) setLoading(true); - setError(null); - } - - // #328:记录当前已完成首拉的频道——同频道 refetch 走合并,首拉/频道切换仍替换 - const loadedChannelRef = useRef(null); - // hasMore 判定需要当前列表快照(本地最老消息是否落在最新一页内),用 ref 避免 - // fetchMessages 依赖 messages 导致频道切换 effect 随每条 SSE 消息重触发 - const messagesRef = useRef([]); - useEffect(() => { - messagesRef.current = messages; - }, [messages]); + const slice = useChannelMessageStore(s => (channelId ? s.channels[channelId] : undefined)); + // 缺键 = 首拉进行中:loading 初值 !!channelId(对齐收编前 useState 初值语义); + // 切换频道旧切片数据保留到新数据到达,不做清空(per-channelId 切片天然承载) + const messages = slice?.messages ?? EMPTY_MESSAGES; + const loading = slice?.loading ?? !!channelId; + const error = slice?.error ?? null; + const hasMore = slice?.hasMore ?? false; const fetchMessages = useCallback(async () => { if (!channelId) return; - try { - const res = await channelApi.listMessages(channelId); - if (loadedChannelRef.current === channelId) { - // 合并路径:prepend 的历史页不丢;hasMore 仅当本地最老消息落在最新一页内 - // (未 prepend 出页外)才以响应为准——页的 hasMore 描述头部方向,直接覆盖会 - // 把已耗尽的 prepend 方向状态错误重置 - const oldest = messagesRef.current[0]; - setMessages(prev => mergePage(prev, res.data.data)); - if (!oldest || res.data.data.some(m => m.id === oldest.id)) { - setHasMore(res.data.hasMore); - } - } else { - setMessages(res.data.data); - setHasMore(res.data.hasMore); - loadedChannelRef.current = channelId; - } - setError(null); - } catch (err) { - console.error('[Channel] Failed to fetch messages', err); - // #482:error 态供页面区分「加载失败(可重试)/ 真空频道」;已加载消息不清空 - setError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); - } + await useChannelMessageStore.getState().fetchMessages(channelId); }, [channelId]); // #313:挂载首拉 + SSE 断开 10s 兜底 + visibility 门禁统一走 useGatedPoll // (fetchMessages 自带 channelId 空值守卫) useGatedPoll(fetchMessages, 10000); - // 频道切换立即重拉(挂载首跳已由 useGatedPoll 首拉覆盖,本 effect 跳过挂载) + // 频道切换:loading/error 复位 + 立即重拉(挂载首跳已由 useGatedPoll 首拉覆盖,本 effect 跳过挂载) const prevChannelRef = useRef(channelId); useEffect(() => { if (prevChannelRef.current === channelId) return; prevChannelRef.current = channelId; + if (!channelId) return; + useChannelMessageStore.getState().beginLoad(channelId); void Promise.resolve().then(fetchMessages); }, [channelId, fetchMessages]); - // B2: SSE 实时推送替代轮询 + // B2: SSE 实时推送替代轮询——逐条直写 store(写批处理已裁决不做,见 #548 票体) useEffect(() => { if (!channelId) return; const unsub = onEvent((msg) => { if (msg.event_type === 'channel.message_sent') { const data = msg.data as { channelId?: string; message?: ChannelMessage }; if (data?.channelId === channelId && data?.message) { - // #520 测量②:回执渲染计时起点——仅 agent 新消息记(人类消息不是回执,复审 minor 修复); - // 已在列表的 SSE 回声不记(其渲染由 REST 替换完成,非本次到达) - if (data.message.authorType === 'agent' && !messagesRef.current.some(m => m.id === data.message!.id)) { - markReceiptArrived(data.message.id); - } - setMessages(prev => insertMessage(prev, data.message!)); + useChannelMessageStore.getState().applyMessageSent(channelId, data.message); } } else if (msg.event_type === 'channel.message_updated') { - const data = msg.data as { channelId?: string; messageId?: string; meta?: string | Record; content?: string; message?: ChannelMessage }; + const data = msg.data as ({ channelId?: string } & MessageUpdatedPayload) | undefined; if (data?.channelId === channelId) { - // #315(ADR 2026-08-24 D1/D2):优先读全量 message 本体——其 meta 为后端合并后 - // 真值,消除顶层增量 meta 整体替换丢旧 key 的分叉;旧形状(无 message 字段)回退增量 patch - if (data.message) { - const full = data.message; - setMessages(prev => prev.map(m => (m.id === full.id ? full : m))); - } else if (data.messageId) { - setMessages(prev => prev.map(m => - m.id === data.messageId - // #326:patch 带 content = 拿到本体即复活清标记(「拿到全量本体即复活」唯一规则); - // 仅 meta 的 patch 骨架不假复活(空正文不应渲染为全量行) - ? { - ...m, - meta: data.meta ?? m.meta, - ...(data.content != null ? { content: data.content as string, degraded: false } : {}), - } - : m - )); - } + useChannelMessageStore.getState().applyMessageUpdated(channelId, data); } } }); return unsub; }, [channelId, onEvent]); - // #486:乐观回显——本地 pending 消息(id 前缀 pending-,仅客户端)随发送即插入, - // 不再等 REST 往返;成功后服务端本体原位替换(SSE 回声先到则已按 id 插入,替换同样收敛 - // 不重复);失败回滚 pending + 上抛(ChannelInput 回灌草稿 + toast 的既有路径不变) + // #486:乐观回显本体在 store action(pending 插入 / 成功原位替换 / 失败回滚上抛); + // ChannelInput 回灌草稿 + toast 的既有路径不变 const sendMessage = useCallback(async (content: string, replyToId?: string, files?: FileRef[]) => { - if (!channelId || !content.trim()) return null; - const pendingId = `pending-${Date.now()}-${Math.random().toString(36).slice(2)}`; - const pendingMsg: ChannelMessage = { - id: pendingId, - channelId, - authorType: 'human', - content, - replyToId: replyToId ?? null, - createdAt: new Date().toISOString(), - pending: true, - }; - setMessages(prev => insertMessage(prev, pendingMsg)); - try { - const res = await channelApi.sendMessage(channelId, content, replyToId, files); - const msg = res.data.data; - setMessages(prev => insertMessage(prev.filter(m => m.id !== pendingId), msg)); - return msg; - } catch (err) { - setMessages(prev => prev.filter(m => m.id !== pendingId)); - throw err; - } + if (!channelId) return null; + return useChannelMessageStore.getState().sendMessage(channelId, content, replyToId, files); }, [channelId]); // #290(清单 #22):返回是否真实前插(供调用方在失败/无更多时清理行锚点,防视口乱跳) const loadMore = useCallback(async (): Promise => { - if (!channelId || !hasMore) return false; - // #486:游标取最老非 pending 消息——pending 是本地乐观 id,服务端不存在,作锚点会翻出空页 - const oldest = messages.find(m => !m.pending); - if (!oldest) return false; - try { - // #319:游标 = 锚点消息 id(原 createdAt 时间戳同毫秒撞车会漏/重) - const res = await channelApi.listMessages(channelId, { before: oldest.id }); - const older = res.data.data; - setMessages(prev => [...older, ...prev]); - setHasMore(res.data.hasMore); - return older.length > 0; - } catch (err) { - console.error('[Channel] Failed to load more', err); - return false; - } - }, [channelId, hasMore, messages]); + if (!channelId) return false; + return useChannelMessageStore.getState().loadMore(channelId); + }, [channelId]); // #326 数据层降级(ADR 2026-08-25):syncPruning 由渲染侧在首个可见消息变化时调用; - // 降级 = 原位骨架化(planPrune 纯函数判定);水合 = 防抖整页取数 + mergePage 归并, - // 不触碰 hasMore(prepend 方向状态归 loadMore 独有) + // 判定/防抖/水合全在 store(prune 阈值覆盖经 ref 透传,防每渲染换引用重挂) const pruneOptsRef = useRef(options?.prune); pruneOptsRef.current = options?.prune; - const hydrateTimerRef = useRef | null>(null); - const hydrateInFlightRef = useRef(false); - // 频道切换/卸载清防抖计时器,防旧频道水合落到新频道数据上 - useEffect(() => { - hydrateInFlightRef.current = false; - return () => { - if (hydrateTimerRef.current) clearTimeout(hydrateTimerRef.current); - hydrateTimerRef.current = null; - }; - }, [channelId]); - - const scheduleHydration = useCallback((before: string) => { + const syncPruning = useCallback((anchorMid: string | null) => { if (!channelId) return; - if (hydrateTimerRef.current) clearTimeout(hydrateTimerRef.current); - hydrateTimerRef.current = setTimeout(async () => { - hydrateTimerRef.current = null; - // in-flight 中不丢触发:重排等其落地后再取(防同区并发重复请求) - if (hydrateInFlightRef.current) { - scheduleHydration(before); - return; - } - hydrateInFlightRef.current = true; - try { - const res = await channelApi.listMessages(channelId, { before, limit: PRUNE_HYDRATE_PAGE_LIMIT }); - // 骨架原位复活(按 id 归并);hasMore 不动——本路径与 prepend 方向无关 - setMessages(prev => mergePage(prev, res.data.data)); - } catch (err) { - console.error('[Channel] Failed to hydrate messages', err); - } finally { - hydrateInFlightRef.current = false; - } - }, 200); + useChannelMessageStore.getState().syncPruning(channelId, anchorMid, pruneOptsRef.current); }, [channelId]); - const syncPruning = useCallback((anchorMid: string | null) => { - const plan = planPrune(messagesRef.current, anchorMid, { - keepRecent: PRUNE_KEEP_RECENT, - degradeDistance: PRUNE_DEGRADE_DISTANCE, - hydrateDistance: PRUNE_HYDRATE_DISTANCE, - ...pruneOptsRef.current, - }); - if (plan.degradeIds.length > 0) { - const ids = new Set(plan.degradeIds); - setMessages(prev => prev.map(m => (ids.has(m.id) ? degradeMessage(m) : m))); - } - if (plan.hydrateBefore) scheduleHydration(plan.hydrateBefore); - }, [scheduleHydration]); - return { messages, loading, error, hasMore, sendMessage, loadMore, refresh: fetchMessages, syncPruning }; } diff --git a/apps/web/src/hooks/useFreshMessageIds.ts b/apps/web/src/hooks/useFreshMessageIds.ts new file mode 100644 index 000000000..1fc645e83 --- /dev/null +++ b/apps/web/src/hooks/useFreshMessageIds.ts @@ -0,0 +1,36 @@ +// useFreshMessageIds — #548 自 ChannelDetailPage 迁出(逻辑逐行不变): +// 批次 E-3 SSE 新到达消息渐隐高亮的 id 集(白名单③状态色切换:accent-dim 底色 → 常态)。 +// 口径 = 全部新到达消息(含自己发送的回显——消息模型只有 authorType 无 authorId,区分不到个人, +// 与 useStreamFollow ownSendPending 窗口同一局限);首拉与翻页 prepend/水合归并的历史不标 +// (createdAt 早于到达前最新一条即历史)。2s 后移类,经 .mc-msg 基类过渡渐隐。 +import { useEffect, useRef, useState } from 'react'; +import type { ChannelMessage } from '../api/channel'; + +export function useFreshMessageIds(messages: ChannelMessage[]): ReadonlySet { + const [freshMsgIds, setFreshMsgIds] = useState>(new Set()); + const msgTrackRef = useRef<{ ids: Set; latestTs: number } | null>(null); + const freshMsgTimerRef = useRef | null>(null); + useEffect(() => { + if (messages.length === 0) return; + const ts = (m: ChannelMessage) => new Date(m.createdAt).getTime(); + const track = msgTrackRef.current; + if (!track) { + // 首载:全部记为已见,不高亮 + msgTrackRef.current = { ids: new Set(messages.map(m => m.id)), latestTs: ts(messages[messages.length - 1]) }; + return; + } + const arrived = messages.filter(m => !track.ids.has(m.id) && ts(m) >= track.latestTs); + for (const m of messages) track.ids.add(m.id); + track.latestTs = Math.max(track.latestTs, ts(messages[messages.length - 1])); + if (arrived.length === 0) return; + setFreshMsgIds(prev => { + const next = new Set(prev); + for (const m of arrived) next.add(m.id); + return next; + }); + if (freshMsgTimerRef.current) clearTimeout(freshMsgTimerRef.current); + freshMsgTimerRef.current = setTimeout(() => setFreshMsgIds(new Set()), 2000); + }, [messages]); + useEffect(() => () => { if (freshMsgTimerRef.current) clearTimeout(freshMsgTimerRef.current); }, []); + return freshMsgIds; +} diff --git a/apps/web/src/pages/ChannelDetailPage.tsx b/apps/web/src/pages/ChannelDetailPage.tsx index 5432a1d3b..cfbf9efaf 100644 --- a/apps/web/src/pages/ChannelDetailPage.tsx +++ b/apps/web/src/pages/ChannelDetailPage.tsx @@ -35,6 +35,8 @@ import { useNotificationStore, needInputViewOf } from '../stores/notificationSto import { useUnreadStore } from '../stores/unreadStore'; import { useChannelDataStore, parseChannelMembers } from '../stores/channelDataStore'; import { useChannelWorkStore, parseRequirementPayload, wuIdleOf } from '../stores/channelWorkStore'; +import { agentAnsweredOf } from '../stores/channelMessageStore'; +import { useFreshMessageIds } from '../hooks/useFreshMessageIds'; import { useChannelWorkStoreSync } from '../hooks/useChannelWorkStoreSync'; import type { Requirement } from '../api/requirements'; import type { Channel, ChannelMessage, ChannelSuggestion, FileRef } from '../api/channel'; @@ -413,10 +415,8 @@ export function ChannelDetailPage() { // #493:「等待 agent」状态条——agent 已响应(该 WU 的 agent 新消息到达)即 render 派生隐藏, // 不做 effect 内同步 setState;state 本体由 30s 兜底定时器清理(agent 无响应时条不常住; // 30s 口径 > 唤醒+认领秒级路径,loop 异常时由工作条/建议片承接下来) - const agentAnswered = !!awaitingAgent && messages.some(m => - m.authorType === 'agent' && m.workUnitId === awaitingAgent.wuId && - new Date(m.createdAt).getTime() >= awaitingAgent.since - ); + // #548:判定本体迁出页面——agentAnsweredOf(channelMessageStore 旁挂纯函数),本页只消费派生结果 + const agentAnswered = agentAnsweredOf(messages, awaitingAgent); useEffect(() => { if (!awaitingAgent) return; const timer = setTimeout(() => setAwaitingAgent(null), 30_000); @@ -427,31 +427,8 @@ export function ChannelDetailPage() { // 口径 = 全部新到达消息(含自己发送的回显——消息模型只有 authorType 无 authorId,区分不到个人, // 与 useStreamFollow ownSendPending 窗口同一局限);首拉与翻页 prepend/水合归并的历史不标 // (createdAt 早于到达前最新一条即历史)。2s 后移类,经 .mc-msg 基类过渡渐隐 - const [freshMsgIds, setFreshMsgIds] = useState>(new Set()); - const msgTrackRef = useRef<{ ids: Set; latestTs: number } | null>(null); - const freshMsgTimerRef = useRef | null>(null); - useEffect(() => { - if (messages.length === 0) return; - const ts = (m: ChannelMessage) => new Date(m.createdAt).getTime(); - const track = msgTrackRef.current; - if (!track) { - // 首载:全部记为已见,不高亮 - msgTrackRef.current = { ids: new Set(messages.map(m => m.id)), latestTs: ts(messages[messages.length - 1]) }; - return; - } - const arrived = messages.filter(m => !track.ids.has(m.id) && ts(m) >= track.latestTs); - for (const m of messages) track.ids.add(m.id); - track.latestTs = Math.max(track.latestTs, ts(messages[messages.length - 1])); - if (arrived.length === 0) return; - setFreshMsgIds(prev => { - const next = new Set(prev); - for (const m of arrived) next.add(m.id); - return next; - }); - if (freshMsgTimerRef.current) clearTimeout(freshMsgTimerRef.current); - freshMsgTimerRef.current = setTimeout(() => setFreshMsgIds(new Set()), 2000); - }, [messages]); - useEffect(() => () => { if (freshMsgTimerRef.current) clearTimeout(freshMsgTimerRef.current); }, []); + // #548:判定本体迁出页面——useFreshMessageIds(hooks/),本页只消费派生集合 + const freshMsgIds = useFreshMessageIds(messages); // #547:频道消息环境——横切值单 Provider 下发,消息项 useContext 自取(公开 Props 收窄)。 // #322 契约不变量:成员全为稳定引用(useCallback/镜像 ref),useMemo 组装后 value identity diff --git a/apps/web/src/pages/__tests__/ChannelDetailPage-quote-locate.test.tsx b/apps/web/src/pages/__tests__/ChannelDetailPage-quote-locate.test.tsx index 2ecfdf82e..854d44c93 100644 --- a/apps/web/src/pages/__tests__/ChannelDetailPage-quote-locate.test.tsx +++ b/apps/web/src/pages/__tests__/ChannelDetailPage-quote-locate.test.tsx @@ -1,51 +1,24 @@ // ChannelDetailPage — channel 上下游优化 Phase 1(AC1/AC4 定位部分,docs/plans/2026-09-channel-upstream-downstream-ux.md): // 页面抽通用 locateMessage(mid)(?highlight effect 改调它);quote 引用块与 reply 预览条点击 → 定位高亮上游消息, -// 掉出已加载分页 → 翻页定位循环,翻到底 → toast 兜底。mock 搭建复用 ChannelDetailPage.test.tsx 的接缝 -// (useChannelMessages 自持 messages/hasMore 状态,loadMore(setMsgs, setMore) 驱动翻页)。 +// 掉出已加载分页 → 翻页定位循环,翻到底 → toast 兜底。 +// #548:手写有状态 mock(lastExternal 哨兵 + 手写翻页)已删——改播种 channelMessageStore 初态 + +// 真实 store action 取数(底层 channelApi → api 走本文件既有 mock,按 URL 分派)。 import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; -const { mockSendMessage, mockListWorkunits, mockListReqs, mockApiGet, mockApiPost, mockOnEvent, mockRefresh } = vi.hoisted(() => ({ - mockSendMessage: vi.fn(), +const { mockListWorkunits, mockListReqs, mockApiGet, mockApiPost, mockOnEvent } = vi.hoisted(() => ({ mockListWorkunits: vi.fn(), mockListReqs: vi.fn(), mockApiGet: vi.fn(), mockApiPost: vi.fn(), mockOnEvent: vi.fn(), - mockRefresh: vi.fn(), })); vi.mock('../../api', () => ({ api: { get: mockApiGet, post: mockApiPost }, })); -vi.mock('../../hooks/useChannelEvents', async () => { - const { useState, useRef } = await import('react'); - return { - useChannelMessages: () => { - // #439 同款接缝:mock 自持 messages/hasMore 状态——loadMore 翻页(prepend 历史页/到底) - // 能驱动页面重渲染,供「目标掉出已加载分页」用例走通翻页定位循环 - const [msgs, setMsgs] = useState(currentMessages); - const [more, setMore] = useState(currentHasMore); - const lastExternal = useRef(currentMessages); - if (lastExternal.current !== currentMessages) { - lastExternal.current = currentMessages; - setMsgs(currentMessages); - } - return { - messages: msgs, - loading: false, - error: null, - hasMore: more, - sendMessage: mockSendMessage, - loadMore: () => mockLoadMore(setMsgs, setMore), - refresh: mockRefresh, - }; - }, - }; -}); - vi.mock('../../api/workunit', () => ({ workunitApi: { list: mockListWorkunits } })); vi.mock('../../api/requirements', () => ({ requirementApi: { list: mockListReqs } })); vi.mock('../../api/websocketHooks', () => ({ @@ -78,6 +51,7 @@ vi.mock('../../components/channel/ConvertToTaskDialog', () => ({ ConvertToTaskDi import { ChannelDetailPage } from '../ChannelDetailPage'; import { useNotificationStore } from '../../stores/notificationStore'; +import { useChannelMessageStore } from '../../stores/channelMessageStore'; import { toast } from '../../utils/toast'; import type { ChannelMessage } from '../../api/channel'; @@ -97,10 +71,33 @@ const BASE_MESSAGES: ChannelMessage[] = [ }, ]; -// useChannelEvents mock 的当前消息集 / hasMore 初值 / loadMore spy(同 ChannelDetailPage.test.tsx 契约) -let currentMessages: ChannelMessage[] = BASE_MESSAGES; -let currentHasMore = false; -const mockLoadMore = vi.fn(); +// #548:播种 store 初态(loaded=true,等同首拉已完成;挂载首拉合并空页 = 无操作) +function seedMessages(messages: ChannelMessage[], opts: { hasMore?: boolean } = {}) { + useChannelMessageStore.setState(st => ({ + channels: { + ...st.channels, + 'ch-1': { messages, hasMore: opts.hasMore ?? false, loading: false, error: null, loaded: true }, + }, + })); +} + +/** api.get 按 URL 分派:messages(messagesImpl 可定制翻页;缺省空页)/ 其余 = 频道记录 */ +function installApiGet(messagesImpl?: (before?: string) => unknown) { + mockApiGet.mockImplementation((url: string, config?: { params?: { before?: string } }) => { + if (String(url).endsWith('/messages')) { + const result = messagesImpl + ? messagesImpl(config?.params?.before) + : { data: { data: [], hasMore: false, total: 0 } }; + return Promise.resolve(result); + } + return Promise.resolve({ data: { data: { id: 'ch-1', name: 'rnd-主研发', type: 'rnd', members: '[]' } } }); + }); +} + +/** 翻页调用 = messages GET 带 before 游标(首拉/refetch 不带) */ +const pagingCalls = () => + mockApiGet.mock.calls.filter(c => String(c[0]).endsWith('/messages') + && ((c[1] as { params?: { before?: string } } | undefined)?.params?.before)); // AC4:ChannelInput mock 点击预览条时上送的消息 id(用例设定) let previewTargetId = 'm-parent'; @@ -118,8 +115,8 @@ describe('ChannelDetailPage — quote/reply 预览点击定位上游消息(Pha beforeEach(async () => { vi.clearAllMocks(); window.localStorage.clear(); - currentMessages = BASE_MESSAGES; - currentHasMore = false; + useChannelMessageStore.getState().__resetForTests(); + seedMessages(BASE_MESSAGES); previewTargetId = 'm-parent'; // toast.dismiss() 是 200ms 动画后异步移除——有残留时等其落定,防跨用例 toast 文本污染断言 toast.dismiss(); @@ -128,11 +125,10 @@ describe('ChannelDetailPage — quote/reply 预览点击定位上游消息(Pha } useNotificationStore.setState({ stateItems: [], notifications: [], unreadCount: 0 }); mockApiPost.mockResolvedValue({ data: { success: true } }); - mockApiGet.mockResolvedValue({ data: { data: { id: 'ch-1', name: 'rnd-主研发', type: 'rnd', members: '[]' } } }); + installApiGet(); mockListWorkunits.mockResolvedValue({ data: { data: [] } }); mockListReqs.mockResolvedValue({ data: { data: [] } }); mockOnEvent.mockImplementation(() => () => {}); - mockSendMessage.mockResolvedValue({}); Element.prototype.scrollIntoView = vi.fn(); }); @@ -148,12 +144,12 @@ describe('ChannelDetailPage — quote/reply 预览点击定位上游消息(Pha expect(document.querySelector('[data-message-id="m-parent"]')?.className).toContain('mc-msg-highlight'); }); // 已加载直接定位,不翻页、无降级反馈 - expect(mockLoadMore).not.toHaveBeenCalled(); + expect(pagingCalls()).toHaveLength(0); expect(document.getElementById('toast-container')?.textContent ?? '').not.toContain('无法定位'); }); it('AC4:reply 预览条点击已加载目标 → 直接高亮,不翻页', async () => { - currentHasMore = true; + seedMessages(BASE_MESSAGES, { hasMore: true }); renderPage(); await waitFor(() => expect(screen.getByText('#rnd-主研发')).toBeTruthy()); @@ -162,7 +158,7 @@ describe('ChannelDetailPage — quote/reply 预览点击定位上游消息(Pha await waitFor(() => { expect(document.querySelector('[data-message-id="m-parent"]')?.className).toContain('mc-msg-highlight'); }); - expect(mockLoadMore).not.toHaveBeenCalled(); + expect(pagingCalls()).toHaveLength(0); }); it('AC4:目标掉出已加载分页 → 沿翻页游标加载所在页后定位高亮', async () => { @@ -172,12 +168,11 @@ describe('ChannelDetailPage — quote/reply 预览点击定位上游消息(Pha meta: '{}', createdAt: iso(-50), }; previewTargetId = 'm-target'; - currentHasMore = true; - mockLoadMore.mockImplementation(async (setMsgs: (fn: (prev: ChannelMessage[]) => ChannelMessage[]) => void, setMore: (v: boolean) => void) => { - setMsgs(prev => [oldMsg, ...prev]); - setMore(false); - return true; - }); + seedMessages(BASE_MESSAGES, { hasMore: true }); + // 翻一页:before=最老消息 m-parent → 返回含目标的历史页并到底 + installApiGet((before) => before === 'm-parent' + ? { data: { data: [oldMsg], hasMore: false, total: 1 } } + : { data: { data: [], hasMore: true, total: 0 } }); renderPage(); await waitFor(() => expect(screen.getByText('#rnd-主研发')).toBeTruthy()); @@ -186,18 +181,17 @@ describe('ChannelDetailPage — quote/reply 预览点击定位上游消息(Pha await waitFor(() => { expect(document.querySelector('[data-message-id="m-target"]')?.className).toContain('mc-msg-highlight'); }); - expect(mockLoadMore).toHaveBeenCalledTimes(1); + expect(pagingCalls()).toHaveLength(1); expect(document.getElementById('toast-container')?.textContent ?? '').not.toContain('无法定位'); }); it('AC4:翻页到底仍无目标 → toast 可见反馈,不静默', async () => { previewTargetId = 'm-ghost'; - currentHasMore = true; - // 翻一页后到底(hasMore → false),目标始终不存在 - mockLoadMore.mockImplementation(async (_setMsgs: unknown, setMore: (v: boolean) => void) => { - setMore(false); - return true; - }); + seedMessages(BASE_MESSAGES, { hasMore: true }); + // 翻一页后到底(空页 + hasMore=false),目标始终不存在 + installApiGet((before) => before + ? { data: { data: [], hasMore: false, total: 0 } } + : { data: { data: [], hasMore: true, total: 0 } }); renderPage(); await waitFor(() => expect(screen.getByText('#rnd-主研发')).toBeTruthy()); @@ -206,13 +200,13 @@ describe('ChannelDetailPage — quote/reply 预览点击定位上游消息(Pha await waitFor(() => { expect(document.getElementById('toast-container')?.textContent).toContain('无法定位'); }); - expect(mockLoadMore).toHaveBeenCalledTimes(1); + expect(pagingCalls()).toHaveLength(1); }); // Phase 2(AC2):多层线程 t-root → t-mid → t-leaf。t-root 无 WU——归组泛化后被回复即成 anchor; // 定位深层回复时 replyToId(t-mid)不是线程根,必须先解析根 anchor 再展开,否则线程仍收起、高亮不可见 it('Phase 2:定位埋在多层线程里的消息 → 展开根线程 + 高亮目标', async () => { - currentMessages = [ + seedMessages([ { id: 't-root', channelId: 'ch-1', authorType: 'agent' as const, agentName: 'pm-agent', content: '线程根:发布计划', workUnitId: null, replyToId: null, @@ -228,7 +222,7 @@ describe('ChannelDetailPage — quote/reply 预览点击定位上游消息(Pha content: '深层回复:定在周五', workUnitId: null, replyToId: 't-mid', meta: '{}', createdAt: iso(2), }, - ]; + ]); previewTargetId = 't-leaf'; renderPage(); @@ -245,6 +239,6 @@ describe('ChannelDetailPage — quote/reply 预览点击定位上游消息(Pha expect(el?.className).toContain('mc-msg-highlight'); }); expect(screen.getByText('深层回复:定在周五')).toBeTruthy(); // 线程已展开,目标可见 - expect(mockLoadMore).not.toHaveBeenCalled(); + expect(pagingCalls()).toHaveLength(0); }); }); diff --git a/apps/web/src/pages/__tests__/ChannelDetailPage.test.tsx b/apps/web/src/pages/__tests__/ChannelDetailPage.test.tsx index 0c05d9e56..a55be0b08 100644 --- a/apps/web/src/pages/__tests__/ChannelDetailPage.test.tsx +++ b/apps/web/src/pages/__tests__/ChannelDetailPage.test.tsx @@ -4,8 +4,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; -const { mockSendMessage, mockListWorkunits, mockListReqs, mockGetReq, mockApiGet, mockApiPost, mockDrawerSpy, mockOnEvent, mockOnReconnect, mockRefresh, mockActivityRailSpy, mockDispatchReview, mockClaim } = vi.hoisted(() => ({ - mockSendMessage: vi.fn(), +const { mockListWorkunits, mockListReqs, mockGetReq, mockApiGet, mockApiPost, mockDrawerSpy, mockOnEvent, mockOnReconnect, mockActivityRailSpy, mockDispatchReview, mockClaim } = vi.hoisted(() => ({ mockListWorkunits: vi.fn(), mockListReqs: vi.fn(), mockGetReq: vi.fn(), @@ -14,7 +13,6 @@ const { mockSendMessage, mockListWorkunits, mockListReqs, mockGetReq, mockApiGet mockDrawerSpy: vi.fn(), mockOnEvent: vi.fn(), mockOnReconnect: vi.fn(), - mockRefresh: vi.fn(), mockActivityRailSpy: vi.fn(), mockDispatchReview: vi.fn(), mockClaim: vi.fn(), @@ -24,34 +22,6 @@ vi.mock('../../api', () => ({ api: { get: mockApiGet, post: mockApiPost }, })); -vi.mock('../../hooks/useChannelEvents', async () => { - const { useState, useRef } = await import('react'); - return { - useChannelMessages: () => { - // #439:mock 自持 messages/hasMore 状态——loadMore 翻页(prepend 历史页/到底)能驱动 - // 页面重渲染,供「highlight 目标掉出首页分页」用例走通 capped 游标循环。 - // 同时保留原契约:用例在挂载后重赋值 currentMessages + rerender 时跟随外部快照 - // (lastExternal 哨兵区分外部重赋值与内部 prepend,只跟前者) - const [msgs, setMsgs] = useState(currentMessages); - const [more, setMore] = useState(currentHasMore); - const lastExternal = useRef(currentMessages); - if (lastExternal.current !== currentMessages) { - lastExternal.current = currentMessages; - setMsgs(currentMessages); - } - return { - messages: msgs, - loading: false, - error: currentError, - hasMore: more, - sendMessage: mockSendMessage, - loadMore: () => mockLoadMore(setMsgs, setMore), - refresh: mockRefresh, - }; - }, - }; -}); - vi.mock('../../api/workunit', () => ({ workunitApi: { list: mockListWorkunits, dispatchReview: mockDispatchReview, claim: mockClaim }, })); @@ -123,14 +93,17 @@ vi.mock('../../components/channel/ConvertToTaskDialog', () => ({ ConvertToTaskDi import { ChannelDetailPage } from '../ChannelDetailPage'; import { useNotificationStore } from '../../stores/notificationStore'; import { useChannelWorkStore } from '../../stores/channelWorkStore'; +import { useChannelMessageStore } from '../../stores/channelMessageStore'; import { toast } from '../../utils/toast'; import type { ChannelMessage } from '../../api/channel'; import type { DrawerState } from '../../components/channel/WorkUnitDrawer'; // #528:频道工作面收编 channelWorkStore(模块单例)——每用例清数据面+纪律簿记, // 防 TTL 锚点/已打底 slice 跨用例泄漏吞掉后续打底拉取 +// #548:消息面同口径收编 channelMessageStore,一并重置 beforeEach(() => { useChannelWorkStore.getState().__resetForTests(); + useChannelMessageStore.getState().__resetForTests(); }); const now = Date.now(); @@ -180,16 +153,62 @@ const REQS = [ { id: 'REQ-0042', seq: 42, title: '主界面视觉方向稿', status: 'in-progress', createdAt: iso(-100), createdBy: '张弛' }, ]; -// useChannelEvents mock 的当前消息集(默认 MESSAGES,单测可替换为 PROCESS_MESSAGES 等夹具) -let currentMessages: ChannelMessage[] = MESSAGES; - -// #439:mock 的 hasMore 初值 / loadMore spy。loadMore 实现签名 (setMsgs, setMore) => Promise, -// 由用例决定 prepend 哪些历史消息、翻页后是否到底(setMore(false))与返回值(是否真实前插) -let currentHasMore = false; -const mockLoadMore = vi.fn(); +// #548:messages 数据面收编 channelMessageStore——本文件的手写有状态 mock +// (lastExternal 哨兵 + 手写翻页)已删,改为播种 store 初态 + 真实 store action 取数 +// (底层 channelApi → api 走本文件既有 mock,按 URL 分派)。 +// 播种语义:loaded=true(等同首拉已完成);挂载首拉合并空页 = 无操作,播种态不受其影响。 +function seedMessages(messages: ChannelMessage[], opts: { hasMore?: boolean } = {}) { + useChannelMessageStore.setState(st => ({ + channels: { + ...st.channels, + 'ch-1': { messages, hasMore: opts.hasMore ?? false, loading: false, error: null, loaded: true }, + }, + })); +} + +/** 频道记录默认响应(各 describe 既有形状) */ +const CHANNEL_RESP = { data: { data: { id: 'ch-1', name: 'rnd-主研发', type: 'rnd', members: '[]' } } }; + +/** api.get 按 URL 分派:messages(messagesImpl 可定制翻页/失败;缺省空页)/ suggestions / 其余 = 频道记录 */ +function installApiGet(messagesImpl?: (before?: string) => unknown) { + mockApiGet.mockImplementation((url: string, config?: { params?: { before?: string } }) => { + const u = String(url); + if (u.endsWith('/messages')) { + const result = messagesImpl + ? messagesImpl(config?.params?.before) + : { data: { data: [], hasMore: false, total: 0 } }; + return Promise.resolve(result); + } + if (u.endsWith('/suggestions')) return Promise.resolve({ data: { data: { currentWuId: null, suggestions: [] } } }); + return Promise.resolve(CHANNEL_RESP); + }); +} + +/** api.post 按 URL 分派:messages = sendMessage 的服务端本体回包(乐观回显收敛);其余 = 通用成功 */ +let sendSeq = 0; +function installApiPost() { + mockApiPost.mockImplementation((url: string, body?: { content?: string; replyToId?: string | null }) => { + if (String(url).endsWith('/messages')) { + sendSeq += 1; + return Promise.resolve({ + data: { + data: { + id: `srv-${sendSeq}`, channelId: 'ch-1', authorType: 'human', + content: body?.content ?? '', replyToId: body?.replyToId ?? null, + createdAt: new Date().toISOString(), + }, + }, + }); + } + return Promise.resolve({ data: { success: true } }); + }); +} -// #482:mock 的首拉 error 态(null = 正常;非 null = 加载失败,页面应渲染错误态 + 重试入口) -let currentError: string | null = null; +/** messages 面 GET(首拉/refetch/翻页共用 listMessages);翻页 = 带 before 游标的调用 */ +const messagesGetCalls = () => mockApiGet.mock.calls.filter(c => String(c[0]).endsWith('/messages')); +const pagingCalls = () => messagesGetCalls().filter(c => ((c[1] as { params?: { before?: string } } | undefined)?.params?.before)); +/** messages 面 POST = sendMessage(其余 POST = 通知已读等) */ +const messagePostCalls = () => mockApiPost.mock.calls.filter(c => String(c[0]).endsWith('/messages')); // #242:onEvent 注册的 SSE 处理器(用例手工驱动事件); // 批 2(决策 5/6)后页面有多个订阅方(live 状态条 / waitingWus chip / REQ chips)→ 收集全部处理器统一派发 @@ -222,9 +241,7 @@ describe('ChannelDetailPage — Mission Control 三栏', () => { vi.clearAllMocks(); // 折叠状态按频道持久化(Step 3)——防跨用例 localStorage 泄漏 window.localStorage.clear(); - currentMessages = MESSAGES; - currentHasMore = false; - currentError = null; + seedMessages(MESSAGES); // toast.dismiss() 是 200ms 动画后异步移除——有残留时等其落定,防跨用例 toast 文本污染断言 toast.dismiss(); if (document.getElementById('toast-container')?.childElementCount) { @@ -239,8 +256,8 @@ describe('ChannelDetailPage — Mission Control 三栏', () => { notifications: [], unreadCount: 0, }); - mockApiPost.mockResolvedValue({ data: { success: true } }); - mockApiGet.mockResolvedValue({ data: { data: { id: 'ch-1', name: 'rnd-主研发', type: 'rnd', members: '[]' } } }); + installApiPost(); + installApiGet(); // 同一 list 接口服务两种查询:blocked(NEED_INPUT 挂起集合)/ active(#242 live 状态条,默认无执行中) mockListWorkunits.mockImplementation((params?: { status?: string }) => Promise.resolve( params?.status === 'active' @@ -251,7 +268,6 @@ describe('ChannelDetailPage — Mission Control 三栏', () => { mockOnReconnect.mockImplementation((cb: () => void) => { reconnectHandlers.push(cb); return () => {}; }); reconnectHandlers = []; mockListReqs.mockResolvedValue({ data: { data: REQS } }); - mockSendMessage.mockResolvedValue({}); }); it('打开频道即读:本频道未读通知标记已读(逐条 POST 同步 + unreadCount 递减),其他频道不动', async () => { @@ -293,12 +309,11 @@ describe('ChannelDetailPage — Mission Control 三栏', () => { content: '审计建议卡(老消息)', workUnitId: null, replyToId: null, meta: '{}', createdAt: iso(-50), }; - currentHasMore = true; - mockLoadMore.mockImplementation(async (setMsgs: (fn: (prev: ChannelMessage[]) => ChannelMessage[]) => void, setMore: (v: boolean) => void) => { - setMsgs(prev => [oldMsg, ...prev]); - setMore(false); - return true; - }); + seedMessages(MESSAGES, { hasMore: true }); + // 翻一页:before=最老消息 m-1 → 返回含目标的历史页并到底 + installApiGet((before) => before === 'm-1' + ? { data: { data: [oldMsg], hasMore: false, total: 1 } } + : { data: { data: [], hasMore: true, total: 0 } }); renderPage('/channels/ch-1?highlight=m-old'); @@ -306,35 +321,48 @@ describe('ChannelDetailPage — Mission Control 三栏', () => { const el = document.querySelector('[data-message-id="m-old"]'); expect(el?.className).toContain('mc-msg-highlight'); }); - expect(mockLoadMore).toHaveBeenCalledTimes(1); + expect(pagingCalls()).toHaveLength(1); // 目标已定位,无降级反馈 expect(document.getElementById('toast-container')?.textContent ?? '').not.toContain('无法定位'); }); it('#439:翻页到底仍无目标 → toast 可见反馈,不静默', async () => { - currentHasMore = true; - // 翻一页后到底(hasMore → false),目标始终不存在 - mockLoadMore.mockImplementation(async (_setMsgs: unknown, setMore: (v: boolean) => void) => { - setMore(false); - return true; - }); + seedMessages(MESSAGES, { hasMore: true }); + // 翻一页后到底(空页 + hasMore=false),目标始终不存在 + installApiGet((before) => before + ? { data: { data: [], hasMore: false, total: 0 } } + : { data: { data: [], hasMore: true, total: 0 } }); renderPage('/channels/ch-1?highlight=m-ghost'); await waitFor(() => { expect(document.getElementById('toast-container')?.textContent).toContain('无法定位'); }); - expect(mockLoadMore).toHaveBeenCalledTimes(1); + expect(pagingCalls()).toHaveLength(1); }); it('#439:翻页上限(10 页)后停止并 toast 反馈——不无限翻页', async () => { - currentHasMore = true; - mockLoadMore.mockImplementation(async () => true); // 历史永远翻不完,但目标不存在 + seedMessages(MESSAGES, { hasMore: true }); + // 历史永远翻不完(每页一条更老的消息,hasMore 恒 true),但目标不存在 + let pageSeq = 0; + installApiGet((before) => { + if (!before) return { data: { data: [], hasMore: true, total: 0 } }; + pageSeq += 1; + return { + data: { + data: [{ + id: `m-page-${pageSeq}`, channelId: 'ch-1', authorType: 'agent', + content: '历史页', replyToId: null, meta: '{}', createdAt: iso(-100 - pageSeq), + }], + hasMore: true, total: 1, + }, + }; + }); renderPage('/channels/ch-1?highlight=m-ghost'); // 先钉住页数上限(循环跑满 10 页才停),再查反馈——避免读到上一用例残留的 toast - await waitFor(() => expect(mockLoadMore).toHaveBeenCalledTimes(10)); + await waitFor(() => expect(pagingCalls()).toHaveLength(10)); await waitFor(() => { expect(document.getElementById('toast-container')?.textContent).toContain('无法定位'); }); @@ -343,12 +371,12 @@ describe('ChannelDetailPage — Mission Control 三栏', () => { it('决策9:SSE 断线重连 → 当前频道一次性 refetch(messages refresh + REQ chips 打底面对齐)', async () => { renderPage(); await waitFor(() => expect(screen.getByText('#rnd-主研发')).toBeTruthy()); - expect(mockRefresh).not.toHaveBeenCalled(); + expect(messagesGetCalls()).toHaveLength(1); // 仅挂载首拉,无额外 refetch expect(reconnectHandlers.length).toBeGreaterThan(0); const wuCallsBefore = mockListWorkunits.mock.calls.length; const reqCallsBefore = mockListReqs.mock.calls.length; act(() => emitReconnect()); - expect(mockRefresh).toHaveBeenCalledTimes(1); + expect(messagesGetCalls()).toHaveLength(2); // messages refresh +1 // chips 打底面强制对齐(reloadChannelReqs 挂进重连回调;#440 起 channelWus 面 → workunit list +1; // #468:reloadWaitingWus 已删——waitingWus 改行动中心投影,其重连重拉由 NotificationBell 承担) expect(mockListWorkunits.mock.calls.length).toBe(wuCallsBefore + 1); @@ -409,7 +437,7 @@ describe('ChannelDetailPage — Mission Control 三栏', () => { // #264:线上 meta 为 object——已完成折叠与里程碑判定必须同样生效 it('object meta:已完成消息仍正确折叠(默认留最近 2 条)', async () => { - currentMessages = MESSAGES.map(m => ({ ...m, meta: JSON.parse(m.meta as string) as Record })); + seedMessages(MESSAGES.map(m => ({ ...m, meta: JSON.parse(m.meta as string) as Record }))); renderPage(); await waitFor(() => expect(screen.getByText('完成的工作 5')).toBeTruthy()); expect(screen.queryByText('完成的工作 3')).toBeNull(); @@ -418,7 +446,7 @@ describe('ChannelDetailPage — Mission Control 三栏', () => { }); it('object meta:卡片回复识别为里程碑,不被折叠进过程消息组', async () => { - currentMessages = [ + seedMessages([ { id: 'c-1', channelId: 'ch-1', authorType: 'agent' as const, agentName: 'pm', content: '需求已收到,开始分析', workUnitId: 'WU-2000', replyToId: null, @@ -444,7 +472,7 @@ describe('ChannelDetailPage — Mission Control 三栏', () => { content: '分析结论:拆成 3 个任务', workUnitId: 'WU-2000', replyToId: 'c-1', meta: '{}', createdAt: iso(6), }, - ]; + ]); renderPage(); await waitFor(() => expect(screen.getByText('需求已收到,开始分析')).toBeTruthy()); @@ -461,7 +489,7 @@ describe('ChannelDetailPage — Mission Control 三栏', () => { fireEvent.change(input, { target: { value: '同意注入' } }); fireEvent.click(screen.getByText('回复')); await waitFor(() => { - expect(mockSendMessage).toHaveBeenCalledWith('同意注入', 'm-1'); + expect(mockApiPost).toHaveBeenCalledWith('/channels/ch-1/messages', { content: '同意注入', replyToId: 'm-1' }); }); }); @@ -485,7 +513,7 @@ describe('ChannelDetailPage — Mission Control 三栏', () => { }); it('collapses ≥3 consecutive process replies inside a thread; milestones stay visible', async () => { - currentMessages = PROCESS_MESSAGES; + seedMessages(PROCESS_MESSAGES); renderPage(); // 线程默认展开(无需再点「N 条回复」) await waitFor(() => expect(screen.getByText('需求已收到,开始分析')).toBeTruthy()); @@ -506,20 +534,19 @@ describe('ChannelDetailPage — Mission Control 三栏', () => { it('批次 E-3:SSE 新到达消息挂 mc-msg-new 渐隐高亮,2s 后自清;首拉历史不标', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); try { - const utils = renderPage(); + renderPage(); // 挂载后微任务(频道/通知等 mock promise 落地)在 act 内刷新,防 act 警告 await act(async () => {}); - // 首拉消息全部已渲染(mock 同步供给),且不带 mc-msg-new + // 首拉消息全部已渲染(播种 store 同步供给),且不带 mc-msg-new expect(document.querySelector('[data-message-id="m-1"]')).toBeTruthy(); expect(document.querySelector('[data-message-id="m-1"]')?.className).not.toContain('mc-msg-new'); - // 新消息到达(外部快照推进 + rerender,等价 SSE 落地后 messages 变化) + // 新消息到达(播种推进等价 SSE 落地后 messages 变化) const newMsg: ChannelMessage = { id: 'm-new', channelId: 'ch-1', authorType: 'agent' as const, agentName: 'coder-1', content: '新到的消息', workUnitId: null, replyToId: null, meta: '{}', createdAt: iso(10), }; - currentMessages = [...MESSAGES, newMsg]; - await act(async () => { utils.rerender(pageJsx('/channels/ch-1')); }); + await act(async () => { seedMessages([...MESSAGES, newMsg]); }); const el = document.querySelector('[data-message-id="m-new"]'); expect(el?.className).toContain('mc-msg-new'); @@ -537,7 +564,7 @@ describe('ChannelDetailPage — Mission Control 三栏', () => { it('批次 E-3:离底期间新消息计数进「回到底部」浮钮,点击回底后清零', async () => { Element.prototype.scrollIntoView = vi.fn(); // ?highlight 直达 = 离开底部的导航意图 → 解钉,浮钮出现(无新消息时原文案) - const utils = renderPage('/channels/ch-1?highlight=m-1'); + renderPage('/channels/ch-1?highlight=m-1'); await waitFor(() => expect(screen.getByText('↓ 回到底部')).toBeTruthy()); const mk = (mid: string, min: number): ChannelMessage => ({ @@ -545,12 +572,10 @@ describe('ChannelDetailPage — Mission Control 三栏', () => { content: mid, workUnitId: null, replyToId: null, meta: '{}', createdAt: iso(min), }); // 离底期间到达 2 条 → 浮钮带未读数 - currentMessages = [...MESSAGES, mk('m-6', 10), mk('m-7', 11)]; - act(() => { utils.rerender(pageJsx('/channels/ch-1?highlight=m-1')); }); + act(() => { seedMessages([...MESSAGES, mk('m-6', 10), mk('m-7', 11)]); }); expect(screen.getByText('↓ 2 条新消息')).toBeTruthy(); // 再到 1 条 → 累加 - currentMessages = [...currentMessages, mk('m-8', 12)]; - act(() => { utils.rerender(pageJsx('/channels/ch-1?highlight=m-1')); }); + act(() => { seedMessages([...MESSAGES, mk('m-6', 10), mk('m-7', 11), mk('m-8', 12)]); }); expect(screen.getByText('↓ 3 条新消息')).toBeTruthy(); // 点击回底 → 清零 + 浮钮随钉底隐藏 @@ -564,9 +589,9 @@ describe('ChannelDetailPage — #242 live 执行状态条', () => { // 与上层 describe 同套的干净基线(本 describe 独立于外层,beforeEach 不共享) beforeEach(() => { vi.clearAllMocks(); - currentMessages = MESSAGES; + seedMessages(MESSAGES); sseHandlers = []; - mockApiGet.mockResolvedValue({ data: { data: { id: 'ch-1', name: 'rnd-主研发', type: 'rnd', members: '[]' } } }); + installApiGet(); mockListWorkunits.mockImplementation((params?: { status?: string }) => Promise.resolve( params?.status === 'active' ? activeWuList([]) @@ -574,7 +599,6 @@ describe('ChannelDetailPage — #242 live 执行状态条', () => { )); mockOnEvent.mockImplementation((cb: SseHandler) => { sseHandlers.push(cb); return () => {}; }); mockListReqs.mockResolvedValue({ data: { data: REQS } }); - mockSendMessage.mockResolvedValue({}); }); it('本频道有执行中 WU → 状态条出现(WU 标识 + 步号来自 metadata.stepCount)', async () => { @@ -689,8 +713,7 @@ describe('ChannelDetailPage — #279 NEED_INPUT 待办 chip 与等待态清理', beforeEach(async () => { vi.clearAllMocks(); - currentMessages = FOLLOWUP_MESSAGES; - currentHasMore = false; + seedMessages(FOLLOWUP_MESSAGES); // toast.dismiss() 是 200ms 动画后异步移除——有残留时等其落定,防跨用例 toast 文本污染断言 toast.dismiss(); if (document.getElementById('toast-container')?.childElementCount) { @@ -702,11 +725,10 @@ describe('ChannelDetailPage — #279 NEED_INPUT 待办 chip 与等待态清理', notifications: [], unreadCount: 0, }); - mockApiGet.mockResolvedValue({ data: { data: { id: 'ch-1', name: 'rnd-主研发', type: 'rnd', members: '[]' } } }); + installApiGet(); mockListWorkunits.mockResolvedValue({ data: { data: [] } }); mockOnEvent.mockImplementation((cb: SseHandler) => { sseHandlers.push(cb); return () => {}; }); mockListReqs.mockResolvedValue({ data: { data: [] } }); - mockSendMessage.mockResolvedValue({}); Element.prototype.scrollIntoView = vi.fn(); }); @@ -759,7 +781,7 @@ describe('ChannelDetailPage — #279 NEED_INPUT 待办 chip 与等待态清理', renderPage(); await waitFor(() => expect(screen.getByText('OAuth')).toBeTruthy()); fireEvent.click(screen.getByText('OAuth')); - await waitFor(() => expect(mockSendMessage).toHaveBeenCalledWith('OAuth', 'q-2')); + await waitFor(() => expect(mockApiPost).toHaveBeenCalledWith('/channels/ch-1/messages', { content: 'OAuth', replyToId: 'q-2' })); // await sendMessage resolve 后:needSent=true -> 已回复显示;选项卡收起(互斥) await waitFor(() => expect(screen.getByText(/已回复/)).toBeTruthy()); expect(screen.queryByText('账号密码')).toBeNull(); @@ -773,7 +795,7 @@ describe('ChannelDetailPage — #279 NEED_INPUT 待办 chip 与等待态清理', useNotificationStore.setState({ stateItems: [replyItem('WU-3000', 'OAuth 的回调地址是?', 'ch-1', 'q-3')], }); - currentMessages = [ + seedMessages([ { id: 'a-1', channelId: 'ch-1', authorType: 'agent' as const, agentName: 'pm', content: '任务已派发', workUnitId: 'WU-3000', replyToId: null, @@ -796,7 +818,7 @@ describe('ChannelDetailPage — #279 NEED_INPUT 待办 chip 与等待态清理', meta: { options: [{ label: 'http://localhost' }] } as Record, createdAt: iso(3), }, - ]; + ]); renderPage(); // q-3 是最新提问,提升到主流 + 挂回复区 await waitFor(() => expect(screen.getByText(/回调地址/)).toBeTruthy()); @@ -819,17 +841,16 @@ describe('ChannelDetailPage — #279 NEED_INPUT 待办 chip 与等待态清理', }); it('#483:chip 定位——提问掉出已加载分页 → 自动翻页定位并高亮(与 highlight 路径同循环)', async () => { - // 提问 q-2 不在已加载页(空消息集),翻一页后载入并到底 - currentMessages = []; - currentHasMore = true; - mockLoadMore.mockImplementation(async ( - setMsgs: (fn: (prev: ChannelMessage[]) => ChannelMessage[]) => void, - setMore: (v: boolean) => void, - ) => { - setMsgs(prev => [...FOLLOWUP_MESSAGES, ...prev]); - setMore(false); - return true; - }); + // 提问 q-2 不在已加载页。种子 = 无关新消息锚点(真实翻页游标需要最老非 pending 消息作锚), + // 翻一页后载入 FOLLOWUP 全量并到底 + const anchor: ChannelMessage = { + id: 'm-anchor', channelId: 'ch-1', authorType: 'agent' as const, agentName: 'pm', + content: '无关新消息', workUnitId: null, replyToId: null, meta: '{}', createdAt: iso(20), + }; + seedMessages([anchor], { hasMore: true }); + installApiGet((before) => before === 'm-anchor' + ? { data: { data: FOLLOWUP_MESSAGES, hasMore: false, total: FOLLOWUP_MESSAGES.length } } + : { data: { data: [], hasMore: true, total: 0 } }); renderPage(); await waitFor(() => expect(screen.getByText('待回复 · 1')).toBeTruthy()); @@ -840,19 +861,21 @@ describe('ChannelDetailPage — #279 NEED_INPUT 待办 chip 与等待态清理', const el = document.querySelector('[data-message-id="q-2"]'); expect(el?.className).toContain('mc-msg-highlight'); }); - expect(mockLoadMore).toHaveBeenCalledTimes(1); + expect(pagingCalls()).toHaveLength(1); // 目标已定位,无降级反馈 expect(document.getElementById('toast-container')?.textContent ?? '').not.toContain('无法定位'); }); it('#483:chip 定位——翻页到底仍无该 WU 提问 → toast 可见反馈,不静默', async () => { - currentMessages = []; - currentHasMore = true; - // 翻一页后到底(hasMore → false),WU-3000 的提问始终不存在 - mockLoadMore.mockImplementation(async (_setMsgs: unknown, setMore: (v: boolean) => void) => { - setMore(false); - return true; - }); + // 同上用锚点种子起翻页;翻一页后到底(空页 + hasMore=false),WU-3000 的提问始终不存在 + const anchor: ChannelMessage = { + id: 'm-anchor', channelId: 'ch-1', authorType: 'agent' as const, agentName: 'pm', + content: '无关新消息', workUnitId: null, replyToId: null, meta: '{}', createdAt: iso(20), + }; + seedMessages([anchor], { hasMore: true }); + installApiGet((before) => before + ? { data: { data: [], hasMore: false, total: 0 } } + : { data: { data: [], hasMore: true, total: 0 } }); renderPage(); await waitFor(() => expect(screen.getByText('待回复 · 1')).toBeTruthy()); @@ -862,7 +885,7 @@ describe('ChannelDetailPage — #279 NEED_INPUT 待办 chip 与等待态清理', await waitFor(() => { expect(document.getElementById('toast-container')?.textContent).toContain('无法定位'); }); - expect(mockLoadMore).toHaveBeenCalledTimes(1); + expect(pagingCalls()).toHaveLength(1); toast.dismiss(); }); @@ -880,14 +903,14 @@ describe('ChannelDetailPage — #279 NEED_INPUT 待办 chip 与等待态清理', it('#533:口径单点——已加载集有更新非人类消息时,提升/回复区仍只认下发 messageId', async () => { // x-9 比 q-2 更新的同 WU 非人类消息;后端锚点仍指 q-2 → 前端不得按「最新」改挂 - currentMessages = [ + seedMessages([ ...FOLLOWUP_MESSAGES, { id: 'x-9', channelId: 'ch-1', authorType: 'agent' as const, agentName: 'pm', content: '补充一句进展', workUnitId: 'WU-3000', replyToId: 'a-1', meta: '{}', createdAt: iso(9), }, - ]; + ]); renderPage(); // q-2 仍被提升到主流 + 挂回复区(若按 recency 反推则会改挂 x-9、q-2 掉回折叠线程) await waitFor(() => expect(screen.getByText(/需要输入: 使用 OAuth 还是账号密码?/)).toBeTruthy()); @@ -905,7 +928,7 @@ describe('ChannelDetailPage — #279 NEED_INPUT 待办 chip 与等待态清理', await waitFor(() => { expect(document.getElementById('toast-container')?.textContent).toContain('无法定位'); }); - expect(mockLoadMore).not.toHaveBeenCalled(); + expect(pagingCalls()).toHaveLength(0); toast.dismiss(); }); }); @@ -919,35 +942,29 @@ describe('ChannelDetailPage — SSE 负载深化批 2:REQ chips 事件化 / wa beforeEach(() => { vi.clearAllMocks(); - currentMessages = MESSAGES; + seedMessages(MESSAGES); sseHandlers = []; useNotificationStore.setState({ stateItems: [], notifications: [], unreadCount: 0 }); - mockApiGet.mockResolvedValue({ data: { data: { id: 'ch-1', name: 'rnd-主研发', type: 'rnd', members: '[]' } } }); + installApiGet(); mockListWorkunits.mockImplementation((params?: { status?: string }) => Promise.resolve( params?.status === 'active' ? activeWuList([]) : { data: { data: [] } }, )); mockOnEvent.mockImplementation((cb: SseHandler) => { sseHandlers.push(cb); return () => {}; }); mockListReqs.mockResolvedValue({ data: { data: REQS } }); - mockSendMessage.mockResolvedValue({}); }); it('#468:waitingWus 无 blocked REST 面(行动中心投影);messages.length 变化不触发 REQ 重拉', async () => { - const { rerender } = renderPage(); + renderPage(); await waitFor(() => expect(screen.getByText(/REQ-0042/)).toBeTruthy()); expect(blockedCalls()).toBe(0); // blocked 列表查询已随 waitingWus 本地维护机制一并删除 expect(mockListReqs).toHaveBeenCalledTimes(1); // 模拟新消息到达(messages.length 增长)—— 旧实现 REQ effect 依赖 messages.length 会重拉 - currentMessages = [...MESSAGES, { - id: 'm-9', channelId: 'ch-1', authorType: 'agent' as const, agentName: 'pm', - content: '新消息', workUnitId: null, replyToId: null, meta: '{}', createdAt: iso(9), - }]; - rerender( - - - } /> - - , - ); + await act(async () => { + seedMessages([...MESSAGES, { + id: 'm-9', channelId: 'ch-1', authorType: 'agent' as const, agentName: 'pm', + content: '新消息', workUnitId: null, replyToId: null, meta: '{}', createdAt: iso(9), + }]); + }); await waitFor(() => expect(screen.getByText('新消息')).toBeTruthy()); expect(blockedCalls()).toBe(0); expect(mockListReqs).toHaveBeenCalledTimes(1); @@ -1054,12 +1071,14 @@ describe('ChannelDetailPage — #447 引导片唯一来源 = 建议端点', () = beforeEach(() => { vi.clearAllMocks(); - currentMessages = MESSAGES; + seedMessages(MESSAGES); sseHandlers = []; suggestionPayload = EMPTY; useNotificationStore.setState({ stateItems: [], notifications: [], unreadCount: 0 }); mockApiGet.mockImplementation((url: string) => Promise.resolve( - String(url).endsWith('/suggestions') ? suggestionPayload : CHANNEL, + String(url).endsWith('/messages') + ? { data: { data: [], hasMore: false, total: 0 } } + : String(url).endsWith('/suggestions') ? suggestionPayload : CHANNEL, )); mockListWorkunits.mockImplementation((params?: { status?: string }) => Promise.resolve( params?.status === 'active' ? activeWuList([]) : { data: { data: [] } }, @@ -1068,7 +1087,6 @@ describe('ChannelDetailPage — #447 引导片唯一来源 = 建议端点', () = mockOnReconnect.mockImplementation((cb: () => void) => { reconnectHandlers.push(cb); return () => {}; }); reconnectHandlers = []; mockListReqs.mockResolvedValue({ data: { data: [] } }); - mockSendMessage.mockResolvedValue({}); }); it('端点无建议 → 不出现引导片(前端不再按 WU 状态自行映射出片,无静态兜底)', async () => { @@ -1150,11 +1168,13 @@ describe('ChannelDetailPage — #489 建议端点重拉触发面(防抖合并 beforeEach(() => { vi.clearAllMocks(); - currentMessages = MESSAGES; + seedMessages(MESSAGES); sseHandlers = []; useNotificationStore.setState({ stateItems: [], notifications: [], unreadCount: 0 }); mockApiGet.mockImplementation((url: string) => Promise.resolve( - String(url).endsWith('/suggestions') ? EMPTY : CHANNEL, + String(url).endsWith('/messages') + ? { data: { data: [], hasMore: false, total: 0 } } + : String(url).endsWith('/suggestions') ? EMPTY : CHANNEL, )); mockListWorkunits.mockImplementation((params?: { status?: string }) => Promise.resolve( params?.status === 'active' ? activeWuList([]) : { data: { data: [] } }, @@ -1163,7 +1183,6 @@ describe('ChannelDetailPage — #489 建议端点重拉触发面(防抖合并 mockOnReconnect.mockImplementation((cb: () => void) => { reconnectHandlers.push(cb); return () => {}; }); reconnectHandlers = []; mockListReqs.mockResolvedValue({ data: { data: [] } }); - mockSendMessage.mockResolvedValue({}); }); it('channel.message_sent(本频道里程碑消息)→ 防抖窗口内重拉建议端点', async () => { @@ -1236,12 +1255,14 @@ describe('ChannelDetailPage — #440 阶段条(#447 起 currentWuId 由建议 beforeEach(() => { vi.clearAllMocks(); - currentMessages = MESSAGES; + seedMessages(MESSAGES); sseHandlers = []; suggestionPayload = { data: { data: { currentWuId: 'WU-5001', suggestions: [] } } }; useNotificationStore.setState({ stateItems: [], notifications: [], unreadCount: 0 }); mockApiGet.mockImplementation((url: string) => Promise.resolve( - String(url).endsWith('/suggestions') ? suggestionPayload : CHANNEL, + String(url).endsWith('/messages') + ? { data: { data: [], hasMore: false, total: 0 } } + : String(url).endsWith('/suggestions') ? suggestionPayload : CHANNEL, )); mockListWorkunits.mockImplementation((params?: { status?: string }) => Promise.resolve( params?.status === 'active' @@ -1254,7 +1275,6 @@ describe('ChannelDetailPage — #440 阶段条(#447 起 currentWuId 由建议 mockOnReconnect.mockImplementation((cb: () => void) => { reconnectHandlers.push(cb); return () => {}; }); reconnectHandlers = []; mockListReqs.mockResolvedValue({ data: { data: [] } }); - mockSendMessage.mockResolvedValue({}); }); it('端点 currentWuId 命中本频道 WU → 顶部渲染阶段条(当前站 = 进行中)', async () => { @@ -1284,7 +1304,9 @@ describe('ChannelDetailPage — #440 阶段条(#447 起 currentWuId 由建议 it('#488:建议端点请求失败 → 不误显示空闲,保持「状态同步中…」', async () => { mockApiGet.mockImplementation((url: string) => - String(url).endsWith('/suggestions') ? Promise.reject(new Error('boom')) : Promise.resolve(CHANNEL), + String(url).endsWith('/messages') + ? Promise.resolve({ data: { data: [], hasMore: false, total: 0 } }) + : String(url).endsWith('/suggestions') ? Promise.reject(new Error('boom')) : Promise.resolve(CHANNEL), ); renderPage(); await waitFor(() => expect(suggestionsCalls()).toBe(1)); @@ -1373,11 +1395,13 @@ describe('ChannelDetailPage — #443 端点驱动只读状态说明', () => { beforeEach(() => { vi.clearAllMocks(); - currentMessages = MESSAGES; + seedMessages(MESSAGES); sseHandlers = []; useNotificationStore.setState({ stateItems: [], notifications: [], unreadCount: 0 }); mockApiGet.mockImplementation((url: string) => Promise.resolve( - String(url).endsWith('/suggestions') ? STATUS_SUGGESTION : CHANNEL, + String(url).endsWith('/messages') + ? { data: { data: [], hasMore: false, total: 0 } } + : String(url).endsWith('/suggestions') ? STATUS_SUGGESTION : CHANNEL, )); mockListWorkunits.mockImplementation((params?: { status?: string }) => Promise.resolve( params?.status === 'active' ? activeWuList([]) : { data: { data: [] } }, @@ -1386,7 +1410,6 @@ describe('ChannelDetailPage — #443 端点驱动只读状态说明', () => { mockOnReconnect.mockImplementation((cb: () => void) => { reconnectHandlers.push(cb); return () => {}; }); reconnectHandlers = []; mockListReqs.mockResolvedValue({ data: { data: [] } }); - mockSendMessage.mockResolvedValue({}); }); it('端点回 status 建议 → 渲染只读状况说明:可见、非按钮、点击不进输入框', async () => { @@ -1424,7 +1447,9 @@ describe('ChannelDetailPage — #443 端点驱动只读状态说明', () => { it('端点请求失败 → 静默不出片(引导只是引导,不阻断频道使用)', async () => { mockApiGet.mockImplementation((url: string) => ( - String(url).endsWith('/suggestions') ? Promise.reject(new Error('boom')) : Promise.resolve(CHANNEL) + String(url).endsWith('/messages') + ? Promise.resolve({ data: { data: [], hasMore: false, total: 0 } }) + : String(url).endsWith('/suggestions') ? Promise.reject(new Error('boom')) : Promise.resolve(CHANNEL) )); renderPage(); await waitFor(() => expect(screen.getByText('#rnd-主研发')).toBeTruthy()); @@ -1453,11 +1478,13 @@ describe('ChannelDetailPage — #446 prompt 建议片(预填进输入框,不 beforeEach(() => { vi.clearAllMocks(); - currentMessages = MESSAGES; + seedMessages(MESSAGES); sseHandlers = []; useNotificationStore.setState({ stateItems: [], notifications: [], unreadCount: 0 }); mockApiGet.mockImplementation((url: string) => Promise.resolve( - String(url).endsWith('/suggestions') ? PROMPT_SUGGESTION : CHANNEL, + String(url).endsWith('/messages') + ? { data: { data: [], hasMore: false, total: 0 } } + : String(url).endsWith('/suggestions') ? PROMPT_SUGGESTION : CHANNEL, )); mockListWorkunits.mockImplementation((params?: { status?: string }) => Promise.resolve( params?.status === 'active' ? activeWuList([]) : { data: { data: [] } }, @@ -1466,7 +1493,6 @@ describe('ChannelDetailPage — #446 prompt 建议片(预填进输入框,不 mockOnReconnect.mockImplementation((cb: () => void) => { reconnectHandlers.push(cb); return () => {}; }); reconnectHandlers = []; mockListReqs.mockResolvedValue({ data: { data: [] } }); - mockSendMessage.mockResolvedValue({}); }); it('端点回 prompt 建议 → 渲染可点片:可选标注 + 工单上下文 + hint 说清点击后果', async () => { @@ -1482,7 +1508,7 @@ describe('ChannelDetailPage — #446 prompt 建议片(预填进输入框,不 expect(screen.getByTestId('channel-input').getAttribute('data-prefill')).toBe(PROMPT_TEXT); expect(Number(screen.getByTestId('channel-input').getAttribute('data-prefill-nonce'))).toBeGreaterThan(0); // 不自动发送:人过目编辑后按 Enter,发送走既有 @mention 消息路由(输入框既有路径) - expect(mockSendMessage).not.toHaveBeenCalled(); + expect(messagePostCalls()).toHaveLength(0); expect(mockApiPost).not.toHaveBeenCalled(); }); @@ -1524,15 +1550,17 @@ describe('ChannelDetailPage — #444 确定性动作片:补派评审', () => { beforeEach(() => { vi.clearAllMocks(); - currentMessages = MESSAGES; + seedMessages(MESSAGES); sseHandlers = []; suggestionFetchCount = 0; useNotificationStore.setState({ stateItems: [], notifications: [], unreadCount: 0 }); // 首次拉取回动作片;动作生效后的重拉回空(子单已建出,前置条件转假) mockApiGet.mockImplementation((url: string) => Promise.resolve( - String(url).endsWith('/suggestions') - ? (++suggestionFetchCount === 1 ? ACTION_SUGGESTION : EMPTY_SUGGESTION) - : CHANNEL, + String(url).endsWith('/messages') + ? { data: { data: [], hasMore: false, total: 0 } } + : String(url).endsWith('/suggestions') + ? (++suggestionFetchCount === 1 ? ACTION_SUGGESTION : EMPTY_SUGGESTION) + : CHANNEL, )); mockListWorkunits.mockImplementation((params?: { status?: string }) => Promise.resolve( params?.status === 'active' ? activeWuList([]) : { data: { data: [] } }, @@ -1541,7 +1569,6 @@ describe('ChannelDetailPage — #444 确定性动作片:补派评审', () => { mockOnReconnect.mockImplementation((cb: () => void) => { reconnectHandlers.push(cb); return () => {}; }); reconnectHandlers = []; mockListReqs.mockResolvedValue({ data: { data: [] } }); - mockSendMessage.mockResolvedValue({}); mockDispatchReview.mockResolvedValue({ data: { data: { reviewWorkUnitId: 'WU-4009' } } }); }); @@ -1562,7 +1589,7 @@ describe('ChannelDetailPage — #444 确定性动作片:补派评审', () => { // 直调 dispatch-review 端点(同原语);不经 @mention 消息路由、不产生频道噪音 await waitFor(() => expect(mockDispatchReview).toHaveBeenCalledWith('WU-4001')); expect(mockDispatchReview).toHaveBeenCalledTimes(1); - expect(mockSendMessage).not.toHaveBeenCalled(); + expect(messagePostCalls()).toHaveLength(0); // 动作生效、状态回扫(重拉端点回空)→ 片消失 await waitFor(() => expect(screen.queryByText('补派评审:《登录功能》')).toBeNull()); expect(suggestionsCalls()).toBe(2); @@ -1573,7 +1600,7 @@ describe('ChannelDetailPage — #444 确定性动作片:补派评审', () => { fireEvent.click(await screen.findByText('补派评审:《登录功能》')); fireEvent.click(await screen.findByRole('button', { name: '取消' })); expect(mockDispatchReview).not.toHaveBeenCalled(); - expect(mockSendMessage).not.toHaveBeenCalled(); + expect(messagePostCalls()).toHaveLength(0); expect(screen.getByText('补派评审:《登录功能》')).toBeTruthy(); }); @@ -1610,15 +1637,17 @@ describe('ChannelDetailPage — #445 认领动作片', () => { beforeEach(() => { vi.clearAllMocks(); - currentMessages = MESSAGES; + seedMessages(MESSAGES); sseHandlers = []; suggestionFetchCount = 0; useNotificationStore.setState({ stateItems: [], notifications: [], unreadCount: 0 }); // 首次拉取回认领动作片;认领生效后的重拉回空(已 active,前置条件转假) mockApiGet.mockImplementation((url: string) => Promise.resolve( - String(url).endsWith('/suggestions') - ? (++suggestionFetchCount === 1 ? ACTION_SUGGESTION : EMPTY_SUGGESTION) - : CHANNEL, + String(url).endsWith('/messages') + ? { data: { data: [], hasMore: false, total: 0 } } + : String(url).endsWith('/suggestions') + ? (++suggestionFetchCount === 1 ? ACTION_SUGGESTION : EMPTY_SUGGESTION) + : CHANNEL, )); mockListWorkunits.mockImplementation((params?: { status?: string }) => Promise.resolve( params?.status === 'active' ? activeWuList([]) : { data: { data: [] } }, @@ -1627,7 +1656,6 @@ describe('ChannelDetailPage — #445 认领动作片', () => { mockOnReconnect.mockImplementation((cb: () => void) => { reconnectHandlers.push(cb); return () => {}; }); reconnectHandlers = []; mockListReqs.mockResolvedValue({ data: { data: [] } }); - mockSendMessage.mockResolvedValue({}); mockClaim.mockResolvedValue({ data: { id: 'WU-4001', status: 'active' } }); }); @@ -1648,7 +1676,7 @@ describe('ChannelDetailPage — #445 认领动作片', () => { // 直调 claim 端点(认领人由服务端按会话用户解析);不经 @mention 消息路由 await waitFor(() => expect(mockClaim).toHaveBeenCalledWith('WU-4001')); expect(mockClaim).toHaveBeenCalledTimes(1); - expect(mockSendMessage).not.toHaveBeenCalled(); + expect(messagePostCalls()).toHaveLength(0); // 认领生效、状态回扫(重拉端点回空)→ 片消失 await waitFor(() => expect(screen.queryByText('认领工单:《登录功能》')).toBeNull()); expect(suggestionsCalls()).toBe(2); @@ -1659,7 +1687,7 @@ describe('ChannelDetailPage — #445 认领动作片', () => { fireEvent.click(await screen.findByText('认领工单:《登录功能》')); fireEvent.click(await screen.findByRole('button', { name: '取消' })); expect(mockClaim).not.toHaveBeenCalled(); - expect(mockSendMessage).not.toHaveBeenCalled(); + expect(messagePostCalls()).toHaveLength(0); expect(screen.getByText('认领工单:《登录功能》')).toBeTruthy(); }); @@ -1682,13 +1710,13 @@ describe('ChannelDetailPage — 空频道态示例提示 chip(视觉批次 2 beforeEach(() => { vi.clearAllMocks(); window.localStorage.clear(); - currentMessages = []; // 空频道 - currentHasMore = false; - currentError = null; + // 空频道:不播种——挂载首拉经下方 messages 分支回空页,走真实首拉路径 sseHandlers = []; useNotificationStore.setState({ stateItems: [], notifications: [], unreadCount: 0 }); mockApiGet.mockImplementation((url: string) => Promise.resolve( - String(url).endsWith('/suggestions') ? { data: { data: { currentWuId: null, suggestions: [] } } } : CHANNEL, + String(url).endsWith('/messages') + ? { data: { data: [], hasMore: false, total: 0 } } + : String(url).endsWith('/suggestions') ? { data: { data: { currentWuId: null, suggestions: [] } } } : CHANNEL, )); mockListWorkunits.mockImplementation((params?: { status?: string }) => Promise.resolve( params?.status === 'active' ? activeWuList([]) : { data: { data: [] } }, @@ -1697,7 +1725,6 @@ describe('ChannelDetailPage — 空频道态示例提示 chip(视觉批次 2 mockOnReconnect.mockImplementation((cb: () => void) => { reconnectHandlers.push(cb); return () => {}; }); reconnectHandlers = []; mockListReqs.mockResolvedValue({ data: { data: [] } }); - mockSendMessage.mockResolvedValue({}); }); it('空频道 → 两行引导文案 + 2-3 个可点示例 chip', async () => { @@ -1716,7 +1743,7 @@ describe('ChannelDetailPage — 空频道态示例提示 chip(视觉批次 2 const input = screen.getByTestId('channel-input'); expect(input.getAttribute('data-prefill')).toBe(chip.textContent); expect(Number(input.getAttribute('data-prefill-nonce'))).toBeGreaterThan(0); - expect(mockSendMessage).not.toHaveBeenCalled(); + expect(messagePostCalls()).toHaveLength(0); // 再点一次另一 chip → nonce 继续递增(同 nonce 不覆盖用户编辑的契约靠 nonce 保证) const chips = document.querySelectorAll('.mc-empty-chip'); const before = Number(input.getAttribute('data-prefill-nonce')); @@ -1732,13 +1759,15 @@ describe('ChannelDetailPage — 消息加载失败错误态(#482)', () => { beforeEach(() => { vi.clearAllMocks(); window.localStorage.clear(); - currentMessages = []; - currentHasMore = false; - currentError = 'network down'; + // #548:error 态改走真实失败路径——messages GET 拒绝,store fetchMessages 置 error(不再播种 error) sseHandlers = []; useNotificationStore.setState({ stateItems: [], notifications: [], unreadCount: 0 }); - mockApiGet.mockImplementation((url: string) => Promise.resolve( - String(url).endsWith('/suggestions') ? { data: { data: { currentWuId: null, suggestions: [] } } } : CHANNEL, + mockApiGet.mockImplementation((url: string) => ( + String(url).endsWith('/messages') + ? Promise.reject(new Error('network down')) + : Promise.resolve( + String(url).endsWith('/suggestions') ? { data: { data: { currentWuId: null, suggestions: [] } } } : CHANNEL, + ) )); mockListWorkunits.mockImplementation((params?: { status?: string }) => Promise.resolve( params?.status === 'active' ? activeWuList([]) : { data: { data: [] } }, @@ -1747,7 +1776,6 @@ describe('ChannelDetailPage — 消息加载失败错误态(#482)', () => { mockOnReconnect.mockImplementation((cb: () => void) => { reconnectHandlers.push(cb); return () => {}; }); reconnectHandlers = []; mockListReqs.mockResolvedValue({ data: { data: [] } }); - mockSendMessage.mockResolvedValue({}); }); it('加载失败 → 错误态 + 重试按钮,不渲染空态文案/示例 chip', async () => { @@ -1759,14 +1787,17 @@ describe('ChannelDetailPage — 消息加载失败错误态(#482)', () => { it('点击重试 → 走 hook refresh 重新拉取', async () => { renderPage(); + // 挂载首拉(失败)落定后出现重试钮 fireEvent.click(await screen.findByRole('button', { name: '重试' })); - expect(mockRefresh).toHaveBeenCalledTimes(1); + await waitFor(() => expect(messagesGetCalls()).toHaveLength(2)); // 首拉 1 + 重试 refresh 1 }); it('已有消息时轮询失败:消息流保留,不整屏替换为错误态', async () => { - currentMessages = MESSAGES; + seedMessages(MESSAGES); renderPage(); - await waitFor(() => expect(screen.queryByText('消息加载失败')).toBeNull()); + // 挂载首拉失败落定(error 置位但有消息不整屏替换) + await waitFor(() => expect(messagesGetCalls()).toHaveLength(1)); + expect(screen.queryByText('消息加载失败')).toBeNull(); expect(screen.getByText((MESSAGES[0] as { content: string }).content)).toBeTruthy(); }); }); @@ -1793,14 +1824,14 @@ describe('ChannelDetailPage — Phase 4(AC6)composer-stack 输入区归组', beforeEach(() => { vi.clearAllMocks(); window.localStorage.clear(); - currentMessages = MESSAGES; - currentHasMore = false; - currentError = null; + seedMessages(MESSAGES); sseHandlers = []; suggestionPayload = EMPTY; useNotificationStore.setState({ stateItems: [], notifications: [], unreadCount: 0 }); mockApiGet.mockImplementation((url: string) => Promise.resolve( - String(url).endsWith('/suggestions') ? suggestionPayload : CHANNEL, + String(url).endsWith('/messages') + ? { data: { data: [], hasMore: false, total: 0 } } + : String(url).endsWith('/suggestions') ? suggestionPayload : CHANNEL, )); mockListWorkunits.mockImplementation((params?: { status?: string }) => Promise.resolve( params?.status === 'active' ? activeWuList([]) : { data: { data: [] } }, @@ -1809,7 +1840,6 @@ describe('ChannelDetailPage — Phase 4(AC6)composer-stack 输入区归组', mockOnReconnect.mockImplementation((cb: () => void) => { reconnectHandlers.push(cb); return () => {}; }); reconnectHandlers = []; mockListReqs.mockResolvedValue({ data: { data: [] } }); - mockSendMessage.mockResolvedValue({}); }); it('composer-stack 容器恒在且包裹输入条(无引导片/无 ack 时也在)', async () => { diff --git a/apps/web/src/stores/__tests__/channelMessageStore.test.ts b/apps/web/src/stores/__tests__/channelMessageStore.test.ts new file mode 100644 index 000000000..f1ff6a564 --- /dev/null +++ b/apps/web/src/stores/__tests__/channelMessageStore.test.ts @@ -0,0 +1,348 @@ +// channelMessageStore(#548,ADR 2026-08-31 数据面模式延伸): +// messages 面收编 per-channelId store 后的独立单测——首拉替换/refetch 合并/hasMore 头部方向规则、 +// id 游标翻页、SSE 插入与更新、乐观回显、降级/水合、agentAnsweredOf 派生。 +// 行为口径 = 收编前 useChannelMessages 本地 state 语义(hook 侧旧测试一并兜底)。 +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { ChannelMessage } from '../../api/channel'; + +const { mockListMessages, mockSendMessage, mockSink } = vi.hoisted(() => ({ + mockListMessages: vi.fn(), + mockSendMessage: vi.fn(), + mockSink: vi.fn(), +})); + +vi.mock('../../api/channel', () => ({ + channelApi: { listMessages: mockListMessages, sendMessage: mockSendMessage }, +})); + +import { useChannelMessageStore, agentAnsweredOf } from '../channelMessageStore'; +import { setClientPerfSink, resetClientPerfSink } from '../../utils/clientPerf'; + +const iso = (s: number) => new Date(s * 1000).toISOString(); + +function msg(id: string, seq: number, over: Partial = {}): ChannelMessage { + return { + id, + channelId: 'ch-1', + authorType: 'agent', + content: `内容-${id}`, + replyToId: null, + workUnitId: null, + meta: '{}', + createdAt: iso(seq), + ...over, + }; +} + +function sliceOf(channelId = 'ch-1') { + return useChannelMessageStore.getState().channels[channelId]; +} + +describe('channelMessageStore', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetClientPerfSink(); + useChannelMessageStore.getState().__resetForTests(); + mockListMessages.mockResolvedValue({ data: { data: [], hasMore: false } }); + }); + + describe('fetchMessages(首拉替换 / refetch 合并)', () => { + it('首拉:替换语义落库,loading 落位、loaded 置位', async () => { + const m1 = msg('m1', 0); + mockListMessages.mockResolvedValue({ data: { data: [m1], hasMore: true } }); + await useChannelMessageStore.getState().fetchMessages('ch-1'); + const s = sliceOf()!; + expect(s.messages.map(m => m.id)).toEqual(['m1']); + expect(s.hasMore).toBe(true); + expect(s.loading).toBe(false); + expect(s.loaded).toBe(true); + expect(s.error).toBeNull(); + }); + + it('同频道 refetch:合并语义——prepend 历史保留、已存在按服务端刷新、新消息有序插入', async () => { + const m3 = msg('m3', 2); + mockListMessages.mockResolvedValue({ data: { data: [m3], hasMore: true } }); + const store = useChannelMessageStore.getState(); + await store.fetchMessages('ch-1'); + // prepend 一页历史 + const m1 = msg('m1', 0); + mockListMessages.mockResolvedValue({ data: { data: [m1], hasMore: false } }); + await store.loadMore('ch-1'); + // refetch:m3 服务端更新版 + 新到 m4 + const m3u = { ...m3, content: '服务端更新' }; + const m4 = msg('m4', 3); + mockListMessages.mockResolvedValue({ data: { data: [m3u, m4], hasMore: true } }); + await store.fetchMessages('ch-1'); + + const s = sliceOf()!; + expect(s.messages.map(m => m.id)).toEqual(['m1', 'm3', 'm4']); + expect(s.messages.find(m => m.id === 'm3')!.content).toBe('服务端更新'); + }); + + it('prepend 方向上 hasMore 不被最新一页的 hasMore 错误重置', async () => { + const m3 = msg('m3', 2); + mockListMessages.mockResolvedValue({ data: { data: [m3], hasMore: true } }); + const store = useChannelMessageStore.getState(); + await store.fetchMessages('ch-1'); + const m1 = msg('m1', 0); + mockListMessages.mockResolvedValue({ data: { data: [m1], hasMore: false } }); + await store.loadMore('ch-1'); + expect(sliceOf()!.hasMore).toBe(false); + // 最新一页 hasMore=true 描述头部方向,不得覆盖 prepend 方向状态 + mockListMessages.mockResolvedValue({ data: { data: [m3], hasMore: true } }); + await store.fetchMessages('ch-1'); + expect(sliceOf()!.hasMore).toBe(false); + }); + + it('失败:error 置位、loading 落位、已加载消息保留;成功重试清 error', async () => { + const store = useChannelMessageStore.getState(); + mockListMessages.mockRejectedValueOnce(new Error('network down')); + await store.fetchMessages('ch-1'); + expect(sliceOf()!.error).toBe('network down'); + expect(sliceOf()!.loading).toBe(false); + expect(sliceOf()!.messages).toHaveLength(0); + + const m1 = msg('m1', 0); + mockListMessages.mockResolvedValue({ data: { data: [m1], hasMore: false } }); + await store.fetchMessages('ch-1'); + expect(sliceOf()!.error).toBeNull(); + expect(sliceOf()!.messages.map(m => m.id)).toEqual(['m1']); + }); + }); + + describe('beginLoad(频道切换复位)', () => { + it('置 loading、清 error、保留已有消息(旧频道缓存不闪空)', async () => { + mockListMessages.mockRejectedValueOnce(new Error('down')); + const store = useChannelMessageStore.getState(); + await store.fetchMessages('ch-1'); + expect(sliceOf()!.error).toBeTruthy(); + + store.beginLoad('ch-1'); + expect(sliceOf()!.loading).toBe(true); + expect(sliceOf()!.error).toBeNull(); + }); + }); + + describe('loadMore(#319 id 游标 prepend)', () => { + it('以最老非 pending 消息 id 为游标前插,返回是否真实前插', async () => { + const m3 = msg('m3', 2); + mockListMessages.mockResolvedValue({ data: { data: [m3], hasMore: true } }); + const store = useChannelMessageStore.getState(); + await store.fetchMessages('ch-1'); + + const m1 = msg('m1', 0); + const m2 = msg('m2', 1); + mockListMessages.mockResolvedValue({ data: { data: [m1, m2], hasMore: false } }); + const inserted = await store.loadMore('ch-1'); + + expect(mockListMessages).toHaveBeenLastCalledWith('ch-1', { before: 'm3' }); + expect(sliceOf()!.messages.map(m => m.id)).toEqual(['m1', 'm2', 'm3']); + expect(sliceOf()!.hasMore).toBe(false); + expect(inserted).toBe(true); + }); + + it('hasMore=false 时不发请求', async () => { + const store = useChannelMessageStore.getState(); + await store.fetchMessages('ch-1'); + mockListMessages.mockClear(); + expect(await store.loadMore('ch-1')).toBe(false); + expect(mockListMessages).not.toHaveBeenCalled(); + }); + + it('pending 不作分页游标:列表仅 pending 时不发请求(#486)', async () => { + mockListMessages.mockResolvedValue({ data: { data: [], hasMore: true } }); + const store = useChannelMessageStore.getState(); + await store.fetchMessages('ch-1'); + let resolveSend!: (v: unknown) => void; + mockSendMessage.mockImplementation(() => new Promise(r => { resolveSend = r; })); + const sendP = store.sendMessage('ch-1', '你好'); + expect(sliceOf()!.messages.some(m => m.pending)).toBe(true); + + mockListMessages.mockClear(); + expect(await store.loadMore('ch-1')).toBe(false); + expect(mockListMessages).not.toHaveBeenCalled(); + + resolveSend({ data: { data: msg('s1', 10, { authorType: 'human' }) } }); + await sendP; + }); + }); + + describe('applyMessageSent(SSE 有序插入)', () => { + it('按 createdAt 归位插入 + id 去重;缺切片时建切片', async () => { + const store = useChannelMessageStore.getState(); + store.applyMessageSent('ch-1', msg('m1', 0)); + store.applyMessageSent('ch-1', msg('m3', 2)); + store.applyMessageSent('ch-1', msg('m2', 1)); // 乱序到达 + store.applyMessageSent('ch-1', msg('m2', 1)); // 回声去重 + expect(sliceOf()!.messages.map(m => m.id)).toEqual(['m1', 'm2', 'm3']); + }); + + it('#520:仅 agent 新消息记回执起点(回声/人类消息不记)', () => { + setClientPerfSink(mockSink); + const store = useChannelMessageStore.getState(); + const m1 = msg('m1', 0); + store.applyMessageSent('ch-1', m1); + store.applyMessageSent('ch-1', m1); // 回声 + store.applyMessageSent('ch-1', msg('h1', 1, { authorType: 'human' })); + expect(mockSink).not.toHaveBeenCalled(); // mark 只记起点,sink 在渲染完成才发 + // markReceiptArrived 的配对语义由 useChannelMessages-perf.test.ts 端到端兜底 + }); + }); + + describe('applyMessageUpdated(#315 全量本体优先 / legacy patch)', () => { + it('全量 message 本体原位替换', async () => { + const m1 = msg('m1', 0, { content: '旧内容' }); + mockListMessages.mockResolvedValue({ data: { data: [m1], hasMore: false } }); + const store = useChannelMessageStore.getState(); + await store.fetchMessages('ch-1'); + + const full = { ...m1, content: '新内容', meta: { k: 'v' } }; + store.applyMessageUpdated('ch-1', { messageId: 'm1', message: full }); + expect(sliceOf()!.messages[0]).toEqual(full); + }); + + it('legacy patch:仅 meta 整体替换、骨架不假复活;带 content 复活清 degraded', async () => { + const m1 = msg('m1', 0, { content: '', meta: '{}', degraded: true }); + mockListMessages.mockResolvedValue({ data: { data: [m1], hasMore: false } }); + const store = useChannelMessageStore.getState(); + await store.fetchMessages('ch-1'); + + store.applyMessageUpdated('ch-1', { messageId: 'm1', meta: { status: 'done' } }); + expect(sliceOf()!.messages[0].degraded).toBe(true); + expect(sliceOf()!.messages[0].content).toBe(''); + expect(sliceOf()!.messages[0].meta).toEqual({ status: 'done' }); + + store.applyMessageUpdated('ch-1', { messageId: 'm1', content: '更新后', meta: { status: 'done' } }); + expect(sliceOf()!.messages[0].degraded).toBe(false); + expect(sliceOf()!.messages[0].content).toBe('更新后'); + }); + + it('无匹配 id / 缺切片 → 不动其他消息', () => { + const store = useChannelMessageStore.getState(); + store.applyMessageSent('ch-1', msg('m1', 0)); + store.applyMessageUpdated('ch-1', { messageId: 'ghost', message: msg('ghost', 9) }); + store.applyMessageUpdated('ch-2', { messageId: 'm1', content: '他频道' }); + expect(sliceOf()!.messages.map(m => m.id)).toEqual(['m1']); + expect(sliceOf('ch-2')).toBeUndefined(); + }); + }); + + describe('sendMessage(#486 乐观回显)', () => { + it('发送即插 pending;成功后服务端本体原位替换', async () => { + const store = useChannelMessageStore.getState(); + let resolveSend!: (v: unknown) => void; + mockSendMessage.mockImplementation(() => new Promise(r => { resolveSend = r; })); + + const sendP = store.sendMessage('ch-1', '你好', 'r1'); + const pending = sliceOf()!.messages.find(m => m.pending)!; + expect(pending.id).toMatch(/^pending-/); + expect(pending.content).toBe('你好'); + expect(pending.replyToId).toBe('r1'); + expect(mockSendMessage).toHaveBeenCalledWith('ch-1', '你好', 'r1', undefined); + + const sent = msg('s1', 10, { authorType: 'human' }); + resolveSend({ data: { data: sent } }); + const ret = await sendP; + expect(ret).toEqual(sent); + expect(sliceOf()!.messages.some(m => m.pending)).toBe(false); + expect(sliceOf()!.messages.filter(m => m.id === 's1')).toHaveLength(1); + }); + + it('失败回滚 pending + 上抛', async () => { + const store = useChannelMessageStore.getState(); + mockSendMessage.mockRejectedValue(new Error('network down')); + await expect(store.sendMessage('ch-1', '你好')).rejects.toThrow('network down'); + expect(sliceOf()!.messages.some(m => m.pending)).toBe(false); + expect(sliceOf()!.messages).toHaveLength(0); + }); + + it('空内容 / 全空白 → 不发请求返回 null', async () => { + const store = useChannelMessageStore.getState(); + expect(await store.sendMessage('ch-1', ' ')).toBeNull(); + expect(mockSendMessage).not.toHaveBeenCalled(); + }); + }); + + describe('syncPruning(#326 降级/水合)', () => { + const OPTS = { keepRecent: 3, degradeDistance: 2, hydrateDistance: 1 }; + const batch = (n: number) => Array.from({ length: n }, (_, i) => msg(`m${i + 1}`, i + 1)); + + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('按 planPrune 降级视口上方历史为骨架', async () => { + mockListMessages.mockResolvedValue({ data: { data: batch(10), hasMore: false } }); + const store = useChannelMessageStore.getState(); + await store.fetchMessages('ch-1'); + store.syncPruning('ch-1', 'm8', OPTS); // 边界 7-2=5 → [m1..m5] + const msgs = sliceOf()!.messages; + expect(msgs.slice(0, 5).every(m => m.degraded && m.content === '')).toBe(true); + expect(msgs.slice(5).every(m => !m.degraded)).toBe(true); + }); + + it('视口进入降级区 → 防抖后以首个非骨架 id 为游标整页水合,骨架原位复活、hasMore 不动', async () => { + const list = batch(10); + mockListMessages.mockResolvedValue({ data: { data: list, hasMore: false } }); + const store = useChannelMessageStore.getState(); + await store.fetchMessages('ch-1'); + store.syncPruning('ch-1', 'm8', OPTS); + + mockListMessages.mockResolvedValue({ data: { data: list.slice(0, 5), hasMore: true } }); + store.syncPruning('ch-1', 'm6', OPTS); // idx 5 < 5+1 → 触发水合 + expect(mockListMessages).toHaveBeenCalledTimes(1); // 首拉;防抖未点火 + await vi.advanceTimersByTimeAsync(250); + expect(mockListMessages).toHaveBeenCalledWith('ch-1', { before: 'm6', limit: 100 }); + expect(sliceOf()!.messages.every(m => !m.degraded)).toBe(true); + expect(sliceOf()!.messages.map(m => m.id)).toEqual(list.map(m => m.id)); + expect(sliceOf()!.hasMore).toBe(false); // 水合不触碰 hasMore + }); + + it('防抖窗口内连续触发只发一次水合请求', async () => { + mockListMessages.mockResolvedValue({ data: { data: batch(10), hasMore: false } }); + const store = useChannelMessageStore.getState(); + await store.fetchMessages('ch-1'); + store.syncPruning('ch-1', 'm8', OPTS); + mockListMessages.mockResolvedValue({ data: { data: [], hasMore: false } }); + store.syncPruning('ch-1', 'm6', OPTS); + store.syncPruning('ch-1', 'm6', OPTS); + await vi.advanceTimersByTimeAsync(250); + expect(mockListMessages.mock.calls.filter(c => c[1]?.before === 'm6')).toHaveLength(1); + }); + + it('__resetForTests 清数据面 + 纪律簿记 + 水合计时器', async () => { + mockListMessages.mockResolvedValue({ data: { data: batch(10), hasMore: false } }); + const store = useChannelMessageStore.getState(); + await store.fetchMessages('ch-1'); + store.syncPruning('ch-1', 'm8', OPTS); + store.syncPruning('ch-1', 'm6', OPTS); // 排上一个水合计时器 + store.__resetForTests(); + expect(sliceOf()).toBeUndefined(); + mockListMessages.mockClear(); + await vi.advanceTimersByTimeAsync(250); + expect(mockListMessages).not.toHaveBeenCalled(); // 计时器已清 + }); + }); + + describe('agentAnsweredOf(#493 派生判定,自页面迁出)', () => { + const awaiting = { wuId: 'WU-1', since: new Date(iso(5)).getTime() }; + + it('该 WU 的 agent 新消息(createdAt ≥ since)到达 → true', () => { + const msgs = [msg('m1', 6, { authorType: 'agent', workUnitId: 'WU-1' })]; + expect(agentAnsweredOf(msgs, awaiting)).toBe(true); + }); + + it('无等待态 → false', () => { + expect(agentAnsweredOf([msg('m1', 6, { workUnitId: 'WU-1' })], null)).toBe(false); + }); + + it('人类消息 / 他 WU 的 agent 消息 / since 之前的消息 → false', () => { + expect(agentAnsweredOf([msg('m1', 6, { authorType: 'human', workUnitId: 'WU-1' })], awaiting)).toBe(false); + expect(agentAnsweredOf([msg('m1', 6, { authorType: 'agent', workUnitId: 'WU-2' })], awaiting)).toBe(false); + expect(agentAnsweredOf([msg('m1', 4, { authorType: 'agent', workUnitId: 'WU-1' })], awaiting)).toBe(false); + }); + }); +}); diff --git a/apps/web/src/stores/channelMessageStore.ts b/apps/web/src/stores/channelMessageStore.ts new file mode 100644 index 000000000..5b4fb58a6 --- /dev/null +++ b/apps/web/src/stores/channelMessageStore.ts @@ -0,0 +1,332 @@ +// Channel Message Store — #548 per-channelId 消息面数据面 store +// (ADR 2026-08-31-channel-data-plane-store 模式延伸;2026-09-15 架构评审候选 B4)。 +// 收编前 messages 是 ChannelDetailPage 生命周期 state(useChannelMessages 本地 useState)—— +// 五个数据面 store(roster/notification/unread/channelData/channelWork)就位后的最后一处例外。 +// 本 store 是搬运不是发明:列表纯函数在 utils/messageList(insertMessage/mergePage), +// 降级判定在 utils/messagePruning(planPrune/degradeMessage),此处只做状态持有与接线。 +// 行为口径 = 收编前 hook 本地 state 语义: +// - 首拉替换 / 同频道 refetch 合并(#328),hasMore 仅当本地最老消息落在最新一页内才以响应为准 +// (页的 hasMore 描述头部方向,直接覆盖会错误重置 prepend 方向状态) +// - SSE 逐条直写(写批处理已裁决不做,ADR 2026-09-12 实测帧成本 <1ms): +// message_sent 有序插入(#287)/ message_updated 全量本体优先、legacy 增量 patch(#315) +// - loadMore 以最老非 pending 消息 id 为游标 prepend(#319/#486) +// - sendMessage 乐观 pending:成功原位替换、失败回滚上抛(#486) +// - syncPruning 数据层降级(#326):planPrune 判定 + 200ms 防抖整页水合(不触碰 hasMore) +import { create } from 'zustand'; +import { channelApi, type ChannelMessage, type FileRef } from '../api/channel'; +import { insertMessage, mergePage } from '../utils/messageList'; +import { + degradeMessage, planPrune, + PRUNE_KEEP_RECENT, PRUNE_DEGRADE_DISTANCE, PRUNE_HYDRATE_DISTANCE, PRUNE_HYDRATE_PAGE_LIMIT, + type PruneOptions, +} from '../utils/messagePruning'; +import { markReceiptArrived } from '../utils/clientPerf'; +import { createFetchGate, disciplinedFetch, type FetchGate, type FetchGateState } from './fetchDiscipline'; + +/** per-channelId 消息切片(缺键 = 从未加载;消费方按「首拉进行中」渲染) */ +export interface ChannelMessagesSlice { + /** 恒按 createdAt 升序(下游 groupIntoThreads 单遍归组不变式) */ + messages: ChannelMessage[]; + /** prepend 方向是否还有更早历史 */ + hasMore: boolean; + loading: boolean; + /** #482:首拉/兜底轮询失败暴露 error 态(页面据此区分加载失败/真空频道) */ + error: string | null; + /** 首拉已完成(旧 loadedChannelRef 的 store 形态)——同频道 refetch 走合并,否则替换 */ + loaded: boolean; +} + +/** message_updated 负载(全量本体优先;旧形状回退增量 patch,见 #315 ADR 2026-08-24 D1/D2) */ +export interface MessageUpdatedPayload { + messageId?: string; + meta?: string | Record; + content?: string; + message?: ChannelMessage; +} + +interface ChannelMessageState { + channels: Record; + + /** 频道切换复位:置 loading、清 error、保留已有消息(旧渲染期语义的 store 形态) */ + beginLoad: (channelId: string) => void; + /** 首拉 / refetch(兜底轮询、重连、手动 refresh 共用):loaded → 合并,否则替换 */ + fetchMessages: (channelId: string) => Promise; + /** #290:返回是否真实前插(供调用方在失败/无更多时清理行锚点,防视口乱跳) */ + loadMore: (channelId: string) => Promise; + /** SSE channel.message_sent:有序插入 + id 去重(含 #520 回执计时起点) */ + applyMessageSent: (channelId: string, message: ChannelMessage) => void; + /** SSE channel.message_updated:全量本体原位替换 / legacy 增量 patch */ + applyMessageUpdated: (channelId: string, data: MessageUpdatedPayload) => void; + /** #486 乐观回显:成功返回服务端本体;失败回滚 pending + 上抛;空内容返回 null */ + sendMessage: (channelId: string, content: string, replyToId?: string, files?: FileRef[]) => Promise; + /** #326:渲染侧在首个可见消息变化时调用(anchorMid = 首个可见消息 id,null = 无可锚行不动作) */ + syncPruning: (channelId: string, anchorMid: string | null, opts?: Partial) => void; + /** 测试隔离:清数据面 + 纪律簿记 + 水合计时器(模块级 gate/timer 不在 zustand 内) */ + __resetForTests: () => void; +} + +/** 缺省切片:loading=true(首拉进行中语义——SSE 先于首拉到达建切片时不同步闪空态) */ +function freshSlice(): ChannelMessagesSlice { + return { messages: [], hasMore: false, loading: true, error: null, loaded: false }; +} + +// 纪律簿记按 channelId 模块级持有:数据在 zustand(消费方订阅),门禁状态不进响应式树 +const book = new Map(); + +function gateOf(channelId: string): { gate: FetchGate; state: FetchGateState } { + let s = book.get(channelId); + if (!s) { + s = { gate: createFetchGate(), state: { loadedAt: null, inflight: null } }; + book.set(channelId, s); + } + return s; +} + +// 水合簿记按 channelId 模块级持有:防抖计时器 + in-flight 标记(旧 hook ref 的 store 形态; +// per-channel 后天然无跨频道污染,无需切频道清理) +const hydration = new Map | null; inFlight: boolean }>(); + +function hydrationOf(channelId: string): { timer: ReturnType | null; inFlight: boolean } { + let h = hydration.get(channelId); + if (!h) { + h = { timer: null, inFlight: false }; + hydration.set(channelId, h); + } + return h; +} + +function scheduleHydration(channelId: string, before: string): void { + const h = hydrationOf(channelId); + if (h.timer) clearTimeout(h.timer); + h.timer = setTimeout(() => { + h.timer = null; + // in-flight 中不丢触发:重排等其落地后再取(防同区并发重复请求) + if (h.inFlight) { + scheduleHydration(channelId, before); + return; + } + h.inFlight = true; + void (async () => { + try { + const res = await channelApi.listMessages(channelId, { before, limit: PRUNE_HYDRATE_PAGE_LIMIT }); + // 骨架原位复活(按 id 归并);hasMore 不动——本路径与 prepend 方向无关 + useChannelMessageStore.setState(st => { + const cur = st.channels[channelId]; + if (!cur) return st; + return { channels: { ...st.channels, [channelId]: { ...cur, messages: mergePage(cur.messages, res.data.data) } } }; + }); + } catch (err) { + console.error('[Channel] Failed to hydrate messages', err); + } finally { + h.inFlight = false; + } + })(); + }, 200); +} + +export const useChannelMessageStore = create((set, get) => ({ + channels: {}, + + beginLoad: (channelId) => { + set(st => { + const cur = st.channels[channelId] ?? freshSlice(); + return { channels: { ...st.channels, [channelId]: { ...cur, loading: true, error: null } } }; + }); + }, + + fetchMessages: (channelId) => { + const s = gateOf(channelId); + // maxAgeMs: 0 = 恒拉(消息面无 TTL——取数时机归 useGatedPoll/重连/手动 refresh); + // 纪律底座提供 single-flight 簿记 + seq 守卫(晚到旧结果不落库) + return disciplinedFetch( + s.gate, + { read: () => s.state, setInflight: (p) => { s.state.inflight = p; } }, + { maxAgeMs: 0 }, + async (seq) => { + try { + const res = await channelApi.listMessages(channelId); + if (!s.gate.isLatest(seq)) return; + set(st => { + const cur = st.channels[channelId]; + if (cur?.loaded) { + // 合并路径:prepend 的历史页不丢;hasMore 仅当本地最老消息落在最新一页内 + // (未 prepend 出页外)才以响应为准 + const oldest = cur.messages[0]; + const hasMore = !oldest || res.data.data.some(m => m.id === oldest.id) + ? res.data.hasMore + : cur.hasMore; + return { + channels: { + ...st.channels, + [channelId]: { ...cur, messages: mergePage(cur.messages, res.data.data), hasMore, loading: false, error: null }, + }, + }; + } + return { + channels: { + ...st.channels, + [channelId]: { messages: res.data.data, hasMore: res.data.hasMore, loading: false, error: null, loaded: true }, + }, + }; + }); + } catch (err) { + if (!s.gate.isLatest(seq)) return; + console.error('[Channel] Failed to fetch messages', err); + // #482:error 态供页面区分「加载失败(可重试)/ 真空频道」;已加载消息不清空 + set(st => { + const cur = st.channels[channelId] ?? freshSlice(); + return { + channels: { + ...st.channels, + [channelId]: { ...cur, loading: false, error: err instanceof Error ? err.message : String(err) }, + }, + }; + }); + } + }, + ); + }, + + loadMore: async (channelId) => { + const slice = get().channels[channelId]; + if (!slice?.hasMore) return false; + // #486:游标取最老非 pending 消息——pending 是本地乐观 id,服务端不存在,作锚点会翻出空页 + const oldest = slice.messages.find(m => !m.pending); + if (!oldest) return false; + try { + // #319:游标 = 锚点消息 id(原 createdAt 时间戳同毫秒撞车会漏/重) + const res = await channelApi.listMessages(channelId, { before: oldest.id }); + const older = res.data.data; + set(st => { + const cur = st.channels[channelId]; + if (!cur) return st; + return { channels: { ...st.channels, [channelId]: { ...cur, messages: [...older, ...cur.messages], hasMore: res.data.hasMore } } }; + }); + return older.length > 0; + } catch (err) { + console.error('[Channel] Failed to load more', err); + return false; + } + }, + + applyMessageSent: (channelId, message) => { + // #520 测量②:回执渲染计时起点——仅 agent 新消息记(人类消息不是回执); + // 已在列表的 SSE 回声不记(其渲染由 REST 替换完成,非本次到达) + const cur = get().channels[channelId]; + if (message.authorType === 'agent' && !cur?.messages.some(m => m.id === message.id)) { + markReceiptArrived(message.id); + } + set(st => { + const slice = st.channels[channelId] ?? freshSlice(); + return { channels: { ...st.channels, [channelId]: { ...slice, messages: insertMessage(slice.messages, message) } } }; + }); + }, + + applyMessageUpdated: (channelId, data) => { + set(st => { + const cur = st.channels[channelId]; + if (!cur) return st; + let messages = cur.messages; + if (data.message) { + // #315:全量 message 本体的 meta 为后端合并后真值,消除增量 meta 整体替换丢旧 key 的分叉 + const full = data.message; + messages = cur.messages.map(m => (m.id === full.id ? full : m)); + } else if (data.messageId) { + messages = cur.messages.map(m => + m.id === data.messageId + // #326:patch 带 content = 拿到本体即复活清标记(「拿到全量本体即复活」唯一规则); + // 仅 meta 的 patch 骨架不假复活(空正文不应渲染为全量行) + ? { + ...m, + meta: data.meta ?? m.meta, + ...(data.content != null ? { content: data.content, degraded: false } : {}), + } + : m + ); + } + return { channels: { ...st.channels, [channelId]: { ...cur, messages } } }; + }); + }, + + sendMessage: async (channelId, content, replyToId, files) => { + if (!content.trim()) return null; + const pendingId = `pending-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const pendingMsg: ChannelMessage = { + id: pendingId, + channelId, + authorType: 'human', + content, + replyToId: replyToId ?? null, + createdAt: new Date().toISOString(), + pending: true, + }; + set(st => { + const cur = st.channels[channelId] ?? freshSlice(); + return { channels: { ...st.channels, [channelId]: { ...cur, messages: insertMessage(cur.messages, pendingMsg) } } }; + }); + try { + const res = await channelApi.sendMessage(channelId, content, replyToId, files); + const msg = res.data.data; + // 服务端本体原位替换(SSE 回声先到则已按 id 插入,替换同样收敛不重复) + set(st => { + const cur = st.channels[channelId]; + if (!cur) return st; + return { + channels: { + ...st.channels, + [channelId]: { ...cur, messages: insertMessage(cur.messages.filter(m => m.id !== pendingId), msg) }, + }, + }; + }); + return msg; + } catch (err) { + set(st => { + const cur = st.channels[channelId]; + if (!cur) return st; + return { channels: { ...st.channels, [channelId]: { ...cur, messages: cur.messages.filter(m => m.id !== pendingId) } } }; + }); + throw err; + } + }, + + syncPruning: (channelId, anchorMid, opts) => { + const slice = get().channels[channelId]; + if (!slice) return; + const plan = planPrune(slice.messages, anchorMid, { + keepRecent: PRUNE_KEEP_RECENT, + degradeDistance: PRUNE_DEGRADE_DISTANCE, + hydrateDistance: PRUNE_HYDRATE_DISTANCE, + ...opts, + }); + if (plan.degradeIds.length > 0) { + const ids = new Set(plan.degradeIds); + set(st => { + const cur = st.channels[channelId]; + if (!cur) return st; + return { channels: { ...st.channels, [channelId]: { ...cur, messages: cur.messages.map(m => (ids.has(m.id) ? degradeMessage(m) : m)) } } }; + }); + } + if (plan.hydrateBefore) scheduleHydration(channelId, plan.hydrateBefore); + }, + + __resetForTests: () => { + book.clear(); + for (const h of hydration.values()) { + if (h.timer) clearTimeout(h.timer); + } + hydration.clear(); + set({ channels: {} }); + }, +})); + +/** #493:「等待 agent」状态条的派生判定(自 ChannelDetailPage 迁出;输入/输出不变, + * 数据来自 store 切片——照 needInputViewOf / wuIdleOf 旁挂纯函数先例) */ +export function agentAnsweredOf( + messages: ChannelMessage[], + awaitingAgent: { wuId: string; since: number } | null, +): boolean { + return !!awaitingAgent && messages.some(m => + m.authorType === 'agent' && m.workUnitId === awaitingAgent.wuId && + new Date(m.createdAt).getTime() >= awaitingAgent.since + ); +} diff --git a/apps/web/src/utils/__tests__/messageList.test.ts b/apps/web/src/utils/__tests__/messageList.test.ts new file mode 100644 index 000000000..b98734902 --- /dev/null +++ b/apps/web/src/utils/__tests__/messageList.test.ts @@ -0,0 +1,76 @@ +// messageList 纯函数(#548 自 useChannelEvents 抽取): +// insertMessage — createdAt 升序归位 + id 去重(#287:下游 groupIntoThreads 单遍归组 +// 要求 anchor 先于 reply);mergePage — refetch 合并(#328:已存在按服务端版本刷新、 +// 新消息有序插入、prepend 的历史页原样保留)。 +import { describe, it, expect } from 'vitest'; +import type { ChannelMessage } from '../../api/channel'; +import { insertMessage, mergePage } from '../messageList'; + +const iso = (s: number) => new Date(s * 1000).toISOString(); + +function msg(id: string, seq: number, over: Partial = {}): ChannelMessage { + return { + id, + channelId: 'ch-1', + authorType: 'agent', + content: `内容-${id}`, + replyToId: null, + createdAt: iso(seq), + ...over, + }; +} + +describe('insertMessage', () => { + it('尾部追加:createdAt 最新时插到末尾', () => { + const prev = [msg('m1', 0), msg('m2', 1)]; + expect(insertMessage(prev, msg('m3', 2)).map(m => m.id)).toEqual(['m1', 'm2', 'm3']); + }); + + it('乱序到达按 createdAt 归位,不盲推尾部', () => { + const prev = [msg('m1', 0), msg('m3', 2)]; + expect(insertMessage(prev, msg('m2', 1)).map(m => m.id)).toEqual(['m1', 'm2', 'm3']); + }); + + it('插到最前:createdAt 早于全部现有消息', () => { + const prev = [msg('m2', 1), msg('m3', 2)]; + expect(insertMessage(prev, msg('m1', 0)).map(m => m.id)).toEqual(['m1', 'm2', 'm3']); + }); + + it('id 已存在 → 原样返回(引用不变,SSE 回声不重复)', () => { + const prev = [msg('m1', 0)]; + const next = insertMessage(prev, msg('m1', 0, { content: '回声' })); + expect(next).toBe(prev); + }); + + it('空列表插入单条', () => { + expect(insertMessage([], msg('m1', 0)).map(m => m.id)).toEqual(['m1']); + }); +}); + +describe('mergePage', () => { + it('已存在的消息以服务端版本刷新(原位替换)', () => { + const prev = [msg('m1', 0), msg('m2', 1)]; + const updated = msg('m2', 1, { content: '服务端更新' }); + const next = mergePage(prev, [updated]); + expect(next.map(m => m.id)).toEqual(['m1', 'm2']); + expect(next[1].content).toBe('服务端更新'); + }); + + it('新消息按 createdAt 有序插入(归并进现有列表)', () => { + const prev = [msg('m1', 0), msg('m3', 2)]; + const next = mergePage(prev, [msg('m2', 1), msg('m4', 3)]); + expect(next.map(m => m.id)).toEqual(['m1', 'm2', 'm3', 'm4']); + }); + + it('prepend 的历史页原样保留(页内不含的旧消息不动)', () => { + const prev = [msg('m0', -1), msg('m1', 0)]; + const next = mergePage(prev, [msg('m1', 0, { content: '刷新' }), msg('m2', 1)]); + expect(next.map(m => m.id)).toEqual(['m0', 'm1', 'm2']); + expect(next[0].content).toBe('内容-m0'); + }); + + it('空页 = 无操作(内容与 id 序列不变)', () => { + const prev = [msg('m1', 0), msg('m2', 1)]; + expect(mergePage(prev, []).map(m => m.id)).toEqual(['m1', 'm2']); + }); +}); diff --git a/apps/web/src/utils/messageList.ts b/apps/web/src/utils/messageList.ts new file mode 100644 index 000000000..e8e5bed42 --- /dev/null +++ b/apps/web/src/utils/messageList.ts @@ -0,0 +1,27 @@ +// 频道消息列表纯函数(#548 自 useChannelEvents 抽取,与 messagePruning 同层): +// store(channelMessageStore)与测试共用;语义与抽取前逐字节一致。 +import type { ChannelMessage } from '../api/channel'; + +/** #287(清单 P2 #19):增量到达按 createdAt 升序归位 + id 去重。 + * 下游 groupIntoThreads 单遍归组要求 anchor 先于 reply 出现;一律 push 尾部会让 + * 乱序/孤儿到达的线程回复滞留主流(走查 F17:同一消息刷新前后两种位置)。 + * 有序插入后,任意到达路径下的顺序与刷新全量列表一致,孤儿回复在 anchor 到达时归并。 */ +export function insertMessage(prev: ChannelMessage[], msg: ChannelMessage): ChannelMessage[] { + if (prev.some(m => m.id === msg.id)) return prev; + const t = new Date(msg.createdAt).getTime(); + let idx = prev.length; + while (idx > 0 && new Date(prev[idx - 1].createdAt).getTime() > t) idx--; + return [...prev.slice(0, idx), msg, ...prev.slice(idx)]; +} + +/** #328:refetch 合并——最新一页按 id 归并进现有列表:已存在的以服务端版本刷新, + * 新消息按 createdAt 有序插入,已 prepend 的历史页原样保留。 */ +export function mergePage(prev: ChannelMessage[], page: ChannelMessage[]): ChannelMessage[] { + const fresh = new Map(page.map(m => [m.id, m])); + const prevIds = new Set(prev.map(m => m.id)); + let next = prev.map(m => fresh.get(m.id) ?? m); + for (const m of page) { + if (!prevIds.has(m.id)) next = insertMessage(next, m); + } + return next; +} From 5584ffb3f932fcab793311b792a07521ab1bfda7 Mon Sep 17 00:00:00 2001 From: dommaker Date: Wed, 16 Sep 2026 02:38:11 +0800 Subject: [PATCH 025/116] =?UTF-8?q?refactor(web):=20WU=20=E5=9F=9F=20SSE?= =?UTF-8?q?=20=E8=A2=AB=E5=8A=A8=E5=86=99=E6=94=B6=E5=8F=A3=20+=20fresh=20?= =?UTF-8?q?=E6=9C=BA=E5=88=B6=E5=90=88=E4=B8=80=20+=20AuditLogsPage=20?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=20useAsyncData=20(#549)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 useWorkUnitStoreSync(App 级单点,对标 useChannelWorkStoreSync): status_changed/created/workunit:removed 三路路由唯一一份, 与 B1 主动写(gateWriter)同一组 store action - workunitStore 增 detail slice(detailById + loadWorkUnitDetail), drawer 退回订阅者;fresh 集合驻 store(created 路由驱动) - fresh 高亮机制合一:utils/freshIds 共享纯件(per-id 2s 自清), WU 行与频道消息两侧共用,消息侧语义由单 timer 全清统一为 per-id - WorkUnitListPage 删内联 onEvent/重连扇出/fresh 手写实现,测试拆 emitSse 管道 - AuditLogsPage 取数状态机退位:logs/stats/options 三份 useAsyncData, filterKey prev-state hack 删除 - useGatedPoll 支持 intervalMs<=0 完全停用(本域维持无轮询现状) --- apps/web/src/App.tsx | 10 ++ apps/web/src/CONTEXT.md | 10 +- apps/web/src/__tests__/App-basename.test.tsx | 4 + .../src/components/channel/WorkUnitDrawer.tsx | 90 +++++------ .../channel/__tests__/WorkUnitDrawer.test.tsx | 27 +++- .../__tests__/useFreshMessageIds.test.ts | 7 +- .../src/hooks/__tests__/useGatedPoll.test.ts | 16 ++ .../__tests__/useWorkUnitStoreSync.test.ts | 142 ++++++++++++++++++ apps/web/src/hooks/useDataPlaneSync.ts | 2 +- apps/web/src/hooks/useFreshMessageIds.ts | 26 ++-- apps/web/src/hooks/useGatedPoll.ts | 13 +- apps/web/src/hooks/useWorkUnitStoreSync.ts | 52 +++++++ apps/web/src/pages/AuditLogsPage.tsx | 141 ++++++++--------- apps/web/src/pages/WorkUnitListPage.tsx | 39 +---- .../pages/__tests__/AuditLogsPage.test.tsx | 6 +- .../pages/__tests__/WorkUnitListPage.test.tsx | 67 ++------- .../stores/__tests__/workunitStore.test.ts | 98 +++++++++++- apps/web/src/stores/workunitStore.ts | 63 +++++++- apps/web/src/utils/__tests__/freshIds.test.ts | 65 ++++++++ apps/web/src/utils/freshIds.ts | 38 +++++ 20 files changed, 658 insertions(+), 258 deletions(-) create mode 100644 apps/web/src/hooks/__tests__/useWorkUnitStoreSync.test.ts create mode 100644 apps/web/src/hooks/useWorkUnitStoreSync.ts create mode 100644 apps/web/src/utils/__tests__/freshIds.test.ts create mode 100644 apps/web/src/utils/freshIds.ts diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index b3958c4b7..d6bc6b480 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -40,6 +40,7 @@ import { WebSocketProvider } from './api/websocket'; import { channelApi } from './api/channel'; import { useRosterStore } from './stores/rosterStore'; import { useRequirementChainStoreSync } from './hooks/useRequirementChainStoreSync'; +import { useWorkUnitStoreSync } from './hooks/useWorkUnitStoreSync'; import { usePmoDataStoreSync } from './hooks/usePmoDataStoreSync'; import { useGlobalShortcuts } from './hooks/useGlobalShortcuts'; import { StudioRoleSetupModal } from './components/setup/StudioRoleSetupModal'; @@ -60,6 +61,13 @@ function PmoDataSync() { return null; } +// #549:WU 数据面 SSE 被动写收口(App 级单点、零渲染;消费方分布在 ListPage/Drawer, +// 事件路由唯一一份在 useWorkUnitStoreSync,页面退回 store 订阅者) +function WorkUnitStoreSync() { + useWorkUnitStoreSync(); + return null; +} + // #474:/project/:id 旧路由收编——双路由渲染同一页面已合并为 /pmo/project/:id 唯一入口, // 存量链接(书签/外部引用)在此 301 式重定向(replace 不留历史栈) function LegacyProjectRedirect() { @@ -176,6 +184,8 @@ export default function App() { {/* #456:PMO 数据面接线(App 级单点) */} + {/* #549:WU 数据面 SSE 接线(App 级单点) */} + {/* AC-2.2: studio 角色 provider=null 弹框 */} ({ vi.mock('../hooks/usePmoDataStoreSync', () => ({ usePmoDataStoreSync: () => {}, })); +// #549:WU 数据面接线同理(App 级 WorkUnitStoreSync 经 useDataPlaneSync 依赖真实 SSE context) +vi.mock('../hooks/useWorkUnitStoreSync', () => ({ + useWorkUnitStoreSync: () => {}, +})); vi.mock('../contexts/ThemeContext', () => ({ ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}, })); diff --git a/apps/web/src/components/channel/WorkUnitDrawer.tsx b/apps/web/src/components/channel/WorkUnitDrawer.tsx index b8d7a40e3..c357150ab 100644 --- a/apps/web/src/components/channel/WorkUnitDrawer.tsx +++ b/apps/web/src/components/channel/WorkUnitDrawer.tsx @@ -5,13 +5,12 @@ // ExecutionSteps 后补「会话原文」折叠节(TranscriptViewer,原详情页独有)。 import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import axios from 'axios'; import { workunitApi, parseWorkunitTokenEvents, - type WorkUnit, type WorkunitTokenEvent, } from '../../api/workunit'; +import { useWorkUnitStore } from '../../stores/workunitStore'; import { useRequirementChainStore } from '../../stores/requirementChainStore'; import { monitoringApi, type OverheadStats } from '../../api/monitoring'; import { channelApi, type AgentProfile } from '../../api/channel'; @@ -29,7 +28,6 @@ import { deriveDisplayState, parseAttestations, WU_STATUS_LABELS, formatChannelN import { AssigneeLabel } from '../workunit/AssigneeLabel'; import { formatShortTime } from '../../utils/datetime'; import { parseWuMeta } from '../../utils/wuMeta'; -import { errorMessage } from '../../utils/errorMessage'; export type DrawerState = // #284(决策 #250 D6):autoApprove = analysis_confirm 接力卡「去确认」的「打开即弹」入参 @@ -142,64 +140,50 @@ export function WorkUnitDrawer({ drawer, onClose, onOpenWu, onOpenReq, todoNav } function WuDetail({ id, autoApprove = false, autoRuling = false, onOpenReq }: { id: string; autoApprove?: boolean; autoRuling?: boolean; onOpenReq: (reqId: string) => void }) { const navigate = useNavigate(); - const [wu, setWu] = useState(null); + // #549(B5 收口):WU 详情读 workunitStore detail slice——REST 打底(loadWorkUnitDetail) + // 与 SSE status_changed 就地 upsert 的落点合一(路由在 App 级 useWorkUnitStoreSync); + // 本地 wu 直替已删,drawer 退回 store 订阅者。workunit.tokens 订阅保留本地(唯一消费方) + const detail = useWorkUnitStore(s => s.detailById[id]); const [tokens, setTokens] = useState(null); const [overhead, setOverhead] = useState(null); // #275(#251 断点2):「#频道名」回频道入口的频道名(best-effort,失败退回 id 截短显示) const [channelName, setChannelName] = useState(null); - const [error, setError] = useState(''); - // #241: 悬空 WU 引用(历史清理后消息 footer 指向已不存在的 WU)——404 单列友好态 - const [notFound, setNotFound] = useState(false); const [showTreeTokens, setShowTreeTokens] = useState(false); // E2-1:ReviewHint 自列表展开区挪入——频道成员(in_review 时判断是否有人可认领评审); // null = 未拉到(含 best-effort 失败)→ 不渲染提醒(空数组会误报「无人可认领」) const [channelMembers, setChannelMembers] = useState(null); - // 决策 8(2026-08 SSE 负载加深):SSE 事件订阅——status_changed 负载 = 全量 WorkUnit - // (同 workunitApi.get 形状)按 id 匹配直接替换本地 wu;workunit.tokens 复用 - // parseWorkunitTokenEvents 防御解析(他 WU / 缺字段负载跳过,聚合保持现有值)。 - // 替代原 eventTick(400ms 防抖)驱动的整组重拉。 const { onEvent } = useWebSocketContext(); - // 渲染期按 id 重置(替代原 effect 内同步重置):SSE 事件触发的更新不重置, - // 消除每次事件都闪"加载中…"的骨架闪烁——事件刷新静默进行,旧数据留到新数据到达 + // 渲染期按 id 重置本地切片(替代原 effect 内同步重置):SSE 事件触发的更新不重置, + // 消除每次事件都闪"加载中…"的骨架闪烁——事件刷新静默进行,旧数据留到新数据到达; + // detail slice 由 store 按 id 分区,无需重置 const [prevId, setPrevId] = useState(id); if (prevId !== id) { setPrevId(id); - setWu(null); setTokens(null); setChannelName(null); setChannelMembers(null); - setError(''); - setNotFound(false); } - // 开抽屉一次性打底:WU 详情(+ 频道名/频道成员 best-effort) + // 开抽屉一次性打底:WU 详情进 store detail slice(打开/换 id 即拉,不做 TTL 门禁) useEffect(() => { + void useWorkUnitStore.getState().loadWorkUnitDetail(id); + }, [id]); + + // WU 落库后补频道面(best-effort,随 channelId 派生,不随 SSE 快照重拉): + // #275 频道名(失败退回 id 截短)+ E2-1 ReviewHint 频道成员(失败留 null 不渲染提醒,防误报) + const channelId = detail?.wu?.channelId ?? null; + useEffect(() => { + if (!channelId) return; let alive = true; - workunitApi.get(id) - .then(r => { - if (!alive) return; - setWu(r.data); - setError(''); - if (r.data.channelId) { - // #275(#251 断点2):频道名 best-effort(频道已删/无权限时保留 null,链接退回 id 截短) - channelApi.get(r.data.channelId) - .then(res => { if (alive) setChannelName(res.data.data.name); }) - .catch(() => { /* best-effort */ }); - // E2-1:ReviewHint 频道成员判断(best-effort;失败留 null 不渲染提醒,防误报) - channelApi.listAgents(r.data.channelId) - .then(res => { if (alive) setChannelMembers(res.data.data); }) - .catch(() => { /* best-effort */ }); - } - }) - .catch(e => { - if (!alive) return; - if (axios.isAxiosError(e) && e.response?.status === 404) setNotFound(true); - // 批次A 项6:错误文案走 errorMessage 正本(服务端 error.message 优先,不再直拼 axios 裸 message) - else setError(errorMessage(e)); - }); + channelApi.get(channelId) + .then(res => { if (alive) setChannelName(res.data.data.name); }) + .catch(() => { /* best-effort */ }); + channelApi.listAgents(channelId) + .then(res => { if (alive) setChannelMembers(res.data.data); }) + .catch(() => { /* best-effort */ }); return () => { alive = false; }; - }, [id]); + }, [channelId]); // 开抽屉一次性打底:token 度量历史(此后增量走 workunit.tokens SSE) useEffect(() => { @@ -219,20 +203,17 @@ function WuDetail({ id, autoApprove = false, autoRuling = false, onOpenReq }: { return () => { alive = false; }; }, [id]); - // SSE 增量订阅(决策 8) + // SSE 增量订阅:workunit.tokens 聚合累加(决策 8;status_changed 直替已收口进 store,#549) useEffect(() => onEvent((msg) => { - if (msg.event_type === 'workunit.status_changed') { - const data = msg.data as { workunit?: WorkUnit } | null; - if (data?.workunit && data.workunit.id === id) setWu(data.workunit); - } else if (msg.event_type === 'workunit.tokens') { - const [ev] = parseWorkunitTokenEvents([{ payload: msg.data }], id); - if (ev) setTokens(prev => [...(prev ?? []), ev]); - } + if (msg.event_type !== 'workunit.tokens') return; + const [ev] = parseWorkunitTokenEvents([{ payload: msg.data }], id); + if (ev) setTokens(prev => [...(prev ?? []), ev]); }), [onEvent, id]); - if (notFound) return
该任务不存在或已被清理(id:{id})
; - if (error) return
加载失败: {error}
; - if (!wu) return ; + if (detail?.notFound) return
该任务不存在或已被清理(id:{id})
; + if (detail?.error) return
加载失败: {detail.error}
; + if (!detail?.wu) return ; + const wu = detail.wu; const meta = parseWuMeta(wu.metadata); const title = meta.title || wu.scope; @@ -243,10 +224,11 @@ function WuDetail({ id, autoApprove = false, autoRuling = false, onOpenReq }: { const totalSum = (tokens ?? []).reduce((s, t) => s + t.totalTokens, 0); const maxBar = Math.max(totalSum, 1); - /** E2-4:闸门动作写路径 #545 起内建于共享 WuGateActions(gateWriter 双写落点 + onUpdated 直替本地 wu; + /** E2-4:闸门动作写路径 #545 起内建于共享 WuGateActions(gateWriter 双写落点—— + * store applyWorkunitEvent 覆盖 detail slice 就地更新(#549),drawer 无需 onUpdated sink; * 决策 8:状态变化另有 status_changed SSE 兜底);分支/锁存/错误内联/弹窗全部组件自带 */ const gateActions = ( - + ); return ( @@ -325,7 +307,7 @@ function WuDetail({ id, autoApprove = false, autoRuling = false, onOpenReq }: { #467:plan-ruling 挂起时另出「去裁决」(PlanRulingDialog,autoRuling = 接力卡打开即弹)。 动作成功后重拉一次详情兜底(状态变化另有 status_changed SSE 负载直更) */} { - workunitApi.get(id).then(r => setWu(r.data)).catch(() => {}); + void useWorkUnitStore.getState().loadWorkUnitDetail(id); }} /> {/* WU 过程可视化:执行步事件流(思考/工具调用/skill 注入/用量),SSE 步级刷新。 diff --git a/apps/web/src/components/channel/__tests__/WorkUnitDrawer.test.tsx b/apps/web/src/components/channel/__tests__/WorkUnitDrawer.test.tsx index 2ecb85bb2..9520afcc5 100644 --- a/apps/web/src/components/channel/__tests__/WorkUnitDrawer.test.tsx +++ b/apps/web/src/components/channel/__tests__/WorkUnitDrawer.test.tsx @@ -44,14 +44,13 @@ vi.mock('../../../api/requirements', () => ({ requirementApi: { getChain: mockGetChain }, })); -// #545:gateWriter 的 store 双写落点在模块级测试单点覆盖;本文件 stub 掉两个 store, -// 防真实 markSuggestionsDirty 防抖定时器跨测悬挂(API 透传 + onUpdated 直替仍走真实 gateWriter) +// #545:gateWriter 的 channelWorkStore 双写落点在模块级测试单点覆盖;本文件 stub 掉该 store, +// 防真实 markSuggestionsDirty 防抖定时器跨测悬挂(API 透传仍走真实 gateWriter)。 +// #549:workunitStore 用真实实现——drawer 已是 detail slice 订阅者,SSE 事件经挂载的 +// useWorkUnitStoreSync(真实 hook)路由进 store,契约即集成断言 vi.mock('../../../stores/channelWorkStore', () => ({ useChannelWorkStore: { getState: () => ({ applyWorkunitSnapshot: vi.fn(), markSuggestionsDirty: vi.fn() }) }, })); -vi.mock('../../../stores/workunitStore', () => ({ - useWorkUnitStore: { getState: () => ({ applyWorkunitEvent: vi.fn() }) }, -})); vi.mock('../../../api/monitoring', () => ({ monitoringApi: { @@ -100,6 +99,8 @@ import { WorkUnitDrawer } from '../WorkUnitDrawer'; import type { DrawerState, DrawerTodoNav } from '../WorkUnitDrawer'; import { useRosterStore } from '../../../stores/rosterStore'; import { useRequirementChainStore } from '../../../stores/requirementChainStore'; +import { useWorkUnitStore } from '../../../stores/workunitStore'; +import { useWorkUnitStoreSync } from '../../../hooks/useWorkUnitStoreSync'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; // 决策 8:SSE 事件捕获(mockOnEvent 注册的回调,用例手工驱动)。 @@ -162,9 +163,17 @@ const CHAIN = { ], }; +// #549:真实 sync hook 挂载(App 级形状镜像)——SSE status_changed/created/removed 路由进 +// workunitStore,drawer 测试保持「发事件 → 断言 UI」的集成口径 +function WorkUnitSyncBridge() { + useWorkUnitStoreSync(); + return null; +} + const renderDrawer = (drawer: DrawerState, extra: { onClose?: () => void; onOpenWu?: (id: string) => void; onOpenReq?: (id: string) => void; todoNav?: DrawerTodoNav } = {}) => render( + { loadedAt: null, channelsLoadedOnce: false, agentsLoadedOnce: false, inflight: null, lastToken: null, }); + // #549:drawer 读真实 workunitStore detail slice(模块级单例),每测重置防快照跨测串味 + useWorkUnitStore.getState().__resetForTests(); + useWorkUnitStore.setState({ + workunits: [], total: 0, page: 1, limit: 20, + statusFilter: null, typeFilter: null, + unattributedOnly: false, unattributedTotal: null, allTotal: null, + searchQuery: null, loading: false, error: null, + }); sseHandlers = []; mockOnEvent.mockImplementation((h: (msg: { event_type: string; data?: unknown }) => void) => { sseHandlers.push(h); diff --git a/apps/web/src/hooks/__tests__/useFreshMessageIds.test.ts b/apps/web/src/hooks/__tests__/useFreshMessageIds.test.ts index 60c2b55f6..fd75f99c2 100644 --- a/apps/web/src/hooks/__tests__/useFreshMessageIds.test.ts +++ b/apps/web/src/hooks/__tests__/useFreshMessageIds.test.ts @@ -63,7 +63,8 @@ describe('useFreshMessageIds', () => { expect(result.current.size).toBe(0); }); - it('清除窗口内又有新到 → 重新计时(新旧同集,2s 后一并清除)', () => { + // #549:per-id 语义(机制 = utils/freshIds 共享件)——各自到达起 2s 渐隐,互不重计时 + it('清除窗口内又有新到 → per-id 各自计时:先到的先清,后到的留', () => { const { result, rerender } = renderHook(({ msgs }) => useFreshMessageIds(msgs), { initialProps: { msgs: [msg('m1', 0)] }, }); @@ -73,9 +74,9 @@ describe('useFreshMessageIds', () => { }); rerender({ msgs: [msg('m1', 0), msg('m2', 1), msg('m3', 2)] }); act(() => { - vi.advanceTimersByTime(1000); // 距首次 2s,但计时已重置 + vi.advanceTimersByTime(1000); // m2 到自己的 2s }); - expect(result.current.has('m2')).toBe(true); + expect(result.current.has('m2')).toBe(false); // 先到先清(不受 m3 到达影响) expect(result.current.has('m3')).toBe(true); act(() => { vi.advanceTimersByTime(1000); diff --git a/apps/web/src/hooks/__tests__/useGatedPoll.test.ts b/apps/web/src/hooks/__tests__/useGatedPoll.test.ts index 55a93891e..dde5f10da 100644 --- a/apps/web/src/hooks/__tests__/useGatedPoll.test.ts +++ b/apps/web/src/hooks/__tests__/useGatedPoll.test.ts @@ -124,4 +124,20 @@ describe('useGatedPoll', () => { }); expect(fetch).toHaveBeenCalledTimes(1); }); + + // #549:intervalMs <= 0 = 完全停用(只接 SSE + 重连的 store 不加轮询兜底) + it('intervalMs=0 → 完全停用:无首拉、无轮询、回 visible 不补拉', async () => { + const fetch = vi.fn(); + renderHook(() => useGatedPoll(fetch, 0)); + await flushFirstFetch(); + act(() => { + vi.advanceTimersByTime(120000); + }); + setHidden(true); + setHidden(false); + act(() => { + vi.advanceTimersByTime(120000); + }); + expect(fetch).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/hooks/__tests__/useWorkUnitStoreSync.test.ts b/apps/web/src/hooks/__tests__/useWorkUnitStoreSync.test.ts new file mode 100644 index 000000000..9f3c1eaaa --- /dev/null +++ b/apps/web/src/hooks/__tests__/useWorkUnitStoreSync.test.ts @@ -0,0 +1,142 @@ +// useWorkUnitStoreSync 单测 — #549(B5 收口)的契约钉子: +// 同一事件(workunit.status_changed / created / workunit:removed)的 SSE 路由唯一一份在此—— +// status_changed → applyWorkunitEvent(insertIfMissing:false)(存量行直替/过滤移除 + detail 就地 upsert); +// created → applyWorkunitEvent(insertIfMissing:true) + markWuFresh(fresh 高亮集合); +// workunit:removed → removeWorkunit;detail 未打开过的 WU 事件 no-op。 +// 重连兜底仅在 store 已有负载时刷(页面不在屏不做全量拉);不加轮询(pollIntervalMs=0 停用)。 +// 引用计数单例契约由 useDataPlaneSync 套件锁定,此处不重复。 +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; + +const { mockOnEvent, mockOnReconnect, mockWuList } = vi.hoisted(() => ({ + mockOnEvent: vi.fn(), + mockOnReconnect: vi.fn(), + mockWuList: vi.fn(), +})); + +vi.mock('../../api/websocketHooks', () => ({ + useWebSocketContext: () => ({ onEvent: mockOnEvent, onReconnect: mockOnReconnect, status: 'connected' }), +})); +vi.mock('../../api/workunit', () => ({ + workunitApi: { list: mockWuList }, +})); + +import { useWorkUnitStoreSync } from '../useWorkUnitStoreSync'; +import { useWorkUnitStore } from '../../stores/workunitStore'; +import type { WorkUnit } from '../../api/workunit'; + +let handler: ((msg: { event_type: string; data?: unknown }) => void) | null = null; + +function wuFixture(id: string, over: Partial = {}): WorkUnit { + return { + id, parentId: null, dependsOn: '', type: 'task', scope: `任务${id}`, + assigneeId: null, status: 'active', failureType: null, retryCount: 0, + timeoutAt: null, channelId: 'ch-1', metadata: null, + createdAt: '2026-09-01T00:00:00Z', updatedAt: '2026-09-01T00:00:00Z', + claimedAt: null, completedAt: null, + ...over, + }; +} + +function mount() { + return renderHook(() => useWorkUnitStoreSync()); +} + +beforeEach(() => { + vi.clearAllMocks(); + handler = null; + useWorkUnitStore.getState().__resetForTests(); + useWorkUnitStore.setState({ + workunits: [], total: 0, page: 1, limit: 20, + statusFilter: null, typeFilter: null, + unattributedOnly: false, unattributedTotal: null, allTotal: null, + searchQuery: null, loading: false, error: null, + }); + mockOnEvent.mockImplementation((h: typeof handler) => { handler = h; return () => {}; }); + mockOnReconnect.mockReturnValue(() => {}); + mockWuList.mockResolvedValue({ data: { data: [], pagination: { total: 0, page: 1, limit: 20, totalPages: 0 } } }); +}); + +describe('useWorkUnitStoreSync — SSE 路由(三路径唯一一份)', () => { + it('status_changed → 存量行直替(insertIfMissing: false);未知行不插入', () => { + useWorkUnitStore.setState({ workunits: [wuFixture('wu-1')], total: 1 }); + mount(); + + act(() => handler!({ event_type: 'workunit.status_changed', data: { workunit: wuFixture('wu-1', { status: 'done' }) } })); + expect(useWorkUnitStore.getState().workunits[0].status).toBe('done'); + expect(useWorkUnitStore.getState().total).toBe(1); + + act(() => handler!({ event_type: 'workunit.status_changed', data: { workunit: wuFixture('wu-9') } })); + expect(useWorkUnitStore.getState().workunits).toHaveLength(1); + }); + + it('status_changed 行变更后不符过滤 → 就地移除(过滤匹配语义在 store 单份)', () => { + useWorkUnitStore.setState({ statusFilter: 'active', workunits: [wuFixture('wu-1')], total: 1 }); + mount(); + + act(() => handler!({ event_type: 'workunit.status_changed', data: { workunit: wuFixture('wu-1', { status: 'done' }) } })); + expect(useWorkUnitStore.getState().workunits).toHaveLength(0); + expect(useWorkUnitStore.getState().total).toBe(0); + }); + + it('status_changed → 已打开 detail 就地 upsert;未打开 no-op(ADR 决策 2)', () => { + useWorkUnitStore.setState({ detailById: { 'wu-1': { wu: wuFixture('wu-1'), notFound: false, error: null } } }); + mount(); + + act(() => handler!({ event_type: 'workunit.status_changed', data: { workunit: wuFixture('wu-1', { status: 'done' }) } })); + expect(useWorkUnitStore.getState().detailById['wu-1'].wu?.status).toBe('done'); + + act(() => handler!({ event_type: 'workunit.status_changed', data: { workunit: wuFixture('wu-9') } })); + expect(useWorkUnitStore.getState().detailById['wu-9']).toBeUndefined(); + }); + + it('created → 插头部(insertIfMissing: true)+ fresh 标记;status_changed 不标 fresh', () => { + mount(); + + act(() => handler!({ event_type: 'workunit.status_changed', data: { workunit: wuFixture('wu-0') } })); + expect(useWorkUnitStore.getState().freshWuIds.size).toBe(0); + + act(() => handler!({ event_type: 'workunit.created', data: { workunit: wuFixture('wu-1') } })); + expect(useWorkUnitStore.getState().workunits.map(w => w.id)).toEqual(['wu-1']); + expect(useWorkUnitStore.getState().freshWuIds.has('wu-1')).toBe(true); + }); + + it('workunit:removed → 删行;缺 id 坏负载 no-op(#538 路由同迁)', () => { + useWorkUnitStore.setState({ workunits: [wuFixture('wu-1')], total: 1 }); + mount(); + + act(() => handler!({ event_type: 'workunit:removed', data: { channelId: 'ch-1' } })); + expect(useWorkUnitStore.getState().workunits).toHaveLength(1); + + act(() => handler!({ event_type: 'workunit:removed', data: { id: 'wu-1', channelId: null } })); + expect(useWorkUnitStore.getState().workunits).toHaveLength(0); + }); + + it('坏负载 no-op 不炸', () => { + mount(); + expect(() => { + act(() => handler!({ event_type: 'workunit.status_changed', data: null })); + act(() => handler!({ event_type: 'workunit.created', data: {} })); + act(() => handler!({ event_type: 'channel.message_sent', data: { channelId: 'ch-1' } })); + }).not.toThrow(); + expect(useWorkUnitStore.getState().workunits).toHaveLength(0); + }); +}); + +describe('useWorkUnitStoreSync — 重连兜底', () => { + it('store 已有负载 → 重连强刷(loadWorkUnits + 两徽标);无负载 → 不刷(页面不在屏不做全量拉)', async () => { + let reconnect: (() => void) | null = null; + mockOnReconnect.mockImplementation((h: () => void) => { reconnect = h; return () => {}; }); + + // 无负载:不刷 + mount(); + act(() => reconnect!()); + await act(async () => {}); + expect(mockWuList).not.toHaveBeenCalled(); + + // 有负载:三接口扇出强刷 + useWorkUnitStore.setState({ workunits: [wuFixture('wu-1')], total: 1 }); + act(() => reconnect!()); + await vi.waitFor(() => expect(mockWuList).toHaveBeenCalledTimes(3)); + }); +}); diff --git a/apps/web/src/hooks/useDataPlaneSync.ts b/apps/web/src/hooks/useDataPlaneSync.ts index 936a5eb41..46cc6825b 100644 --- a/apps/web/src/hooks/useDataPlaneSync.ts +++ b/apps/web/src/hooks/useDataPlaneSync.ts @@ -14,7 +14,7 @@ export interface DataPlaneSyncOptions { handleEvent: (msg: WebSocketMessage) => void; /** 重连强刷与兜底轮询的目标(ensureFresh 自带 TTL 门禁;引用稳定) */ ensureFresh: (opts?: { maxAgeMs?: number }) => Promise; - /** 兜底轮询周期 */ + /** 兜底轮询周期;<= 0 = 停用轮询兜底(#549:只接 SSE + 重连的 store 维持无轮询现状) */ pollIntervalMs: number; } diff --git a/apps/web/src/hooks/useFreshMessageIds.ts b/apps/web/src/hooks/useFreshMessageIds.ts index 1fc645e83..2def5a955 100644 --- a/apps/web/src/hooks/useFreshMessageIds.ts +++ b/apps/web/src/hooks/useFreshMessageIds.ts @@ -1,15 +1,21 @@ -// useFreshMessageIds — #548 自 ChannelDetailPage 迁出(逻辑逐行不变): -// 批次 E-3 SSE 新到达消息渐隐高亮的 id 集(白名单③状态色切换:accent-dim 底色 → 常态)。 +// useFreshMessageIds — 批次 E-3 SSE 新到达消息渐隐高亮的 id 集 +// (白名单③状态色切换:accent-dim 底色 → 常态;#548 自 ChannelDetailPage 迁出)。 // 口径 = 全部新到达消息(含自己发送的回显——消息模型只有 authorType 无 authorId,区分不到个人, // 与 useStreamFollow ownSendPending 窗口同一局限);首拉与翻页 prepend/水合归并的历史不标 -// (createdAt 早于到达前最新一条即历史)。2s 后移类,经 .mc-msg 基类过渡渐隐。 +// (createdAt 早于到达前最新一条即历史)。per-id 2s 后移类(各自到达起计,互不重计时), +// 经 .mc-msg 基类过渡渐隐。 +// #549:核心机制(id 集 + per-id timer 自清)= utils/freshIds 共享件——与 WU 行侧同一机制, +// 替代本 hook 原「单 timer 全清」实现(fresh 两实现合一)。 import { useEffect, useRef, useState } from 'react'; import type { ChannelMessage } from '../api/channel'; +import { createFreshIdTracker } from '../utils/freshIds'; export function useFreshMessageIds(messages: ChannelMessage[]): ReadonlySet { const [freshMsgIds, setFreshMsgIds] = useState>(new Set()); const msgTrackRef = useRef<{ ids: Set; latestTs: number } | null>(null); - const freshMsgTimerRef = useRef | null>(null); + // tracker 实例随组件生命周期:useState 懒建一次(onChange 直写 state),卸载 dispose 清计时器 + const [tracker] = useState(() => createFreshIdTracker(setFreshMsgIds)); + useEffect(() => () => tracker.dispose(), [tracker]); useEffect(() => { if (messages.length === 0) return; const ts = (m: ChannelMessage) => new Date(m.createdAt).getTime(); @@ -22,15 +28,7 @@ export function useFreshMessageIds(messages: ChannelMessage[]): ReadonlySet !track.ids.has(m.id) && ts(m) >= track.latestTs); for (const m of messages) track.ids.add(m.id); track.latestTs = Math.max(track.latestTs, ts(messages[messages.length - 1])); - if (arrived.length === 0) return; - setFreshMsgIds(prev => { - const next = new Set(prev); - for (const m of arrived) next.add(m.id); - return next; - }); - if (freshMsgTimerRef.current) clearTimeout(freshMsgTimerRef.current); - freshMsgTimerRef.current = setTimeout(() => setFreshMsgIds(new Set()), 2000); - }, [messages]); - useEffect(() => () => { if (freshMsgTimerRef.current) clearTimeout(freshMsgTimerRef.current); }, []); + for (const m of arrived) tracker.mark(m.id); + }, [messages, tracker]); return freshMsgIds; } diff --git a/apps/web/src/hooks/useGatedPoll.ts b/apps/web/src/hooks/useGatedPoll.ts index 61f35d739..b6d8bc434 100644 --- a/apps/web/src/hooks/useGatedPoll.ts +++ b/apps/web/src/hooks/useGatedPoll.ts @@ -2,6 +2,8 @@ // 语义:挂载首拉一次;仅当(页面 visible ∧ SSE status ≠ 'connected')按 intervalMs 轮询; // 页面回 visible 立即补拉一次并恢复计时。消费方各自的 403 终态/错误处理留在 fetch 闭包内。 // 对齐频道消息既有模式(useChannelEvents:SSE 断开才 10s 轮询兜底),并补 visibility 门禁。 +// intervalMs <= 0 = 完全停用(#549:只接 SSE + 重连对齐的 store 不加轮询兜底, +// 挂载首拉/回 visible 补拉一并跳过——取数时机全由 SSE 路由与重连强刷承担)。 import { useEffect, useRef, useState } from 'react'; import { useWebSocketContext } from '../api/websocketHooks'; @@ -12,15 +14,18 @@ export function useGatedPoll(fetch: () => void | Promise, intervalMs: numb fetchRef.current = fetch; }); + const enabled = intervalMs > 0; const [visible, setVisible] = useState(() => !document.hidden); // 挂载首拉(微任务:对齐各消费方既有首拉纪律——编译器对 effect 内同步调用多语句 async 保守告警) useEffect(() => { + if (!enabled) return; void Promise.resolve().then(() => fetchRef.current()); - }, []); + }, [enabled]); // visibility 门禁:回 visible 立即补拉一次(无论 SSE 状态——切回标签页应看到新鲜数据) useEffect(() => { + if (!enabled) return; const onVisibility = () => { const v = !document.hidden; setVisible(v); @@ -28,12 +33,12 @@ export function useGatedPoll(fetch: () => void | Promise, intervalMs: numb }; document.addEventListener('visibilitychange', onVisibility); return () => document.removeEventListener('visibilitychange', onVisibility); - }, []); + }, [enabled]); // 轮询门禁:SSE 断开才兜底,页面隐藏停表 useEffect(() => { - if (status === 'connected' || !visible) return; + if (!enabled || status === 'connected' || !visible) return; const timer = setInterval(() => void fetchRef.current(), intervalMs); return () => clearInterval(timer); - }, [status, visible, intervalMs]); + }, [enabled, status, visible, intervalMs]); } diff --git a/apps/web/src/hooks/useWorkUnitStoreSync.ts b/apps/web/src/hooks/useWorkUnitStoreSync.ts new file mode 100644 index 000000000..a5cd8be02 --- /dev/null +++ b/apps/web/src/hooks/useWorkUnitStoreSync.ts @@ -0,0 +1,52 @@ +// useWorkUnitStoreSync — workunitStore 的 SSE 被动写收口(#549,架构评审候选 B5) +// 形状对标 useChannelWorkStoreSync(#528):模块级 handleEvent 路由 + useDataPlaneSync 承接重连。 +// 此前同一事件三套独立接线(ListPage 内联 onEvent + 手写 upsert/fresh 高亮、WuDetail 内联直替、 +// channel 域 sync hook 除外)收敛为唯一一份路由: +// status_changed → applyWorkunitEvent(insertIfMissing:false)(存量行直替/过滤移除 + detail slice 就地 upsert) +// created → applyWorkunitEvent(insertIfMissing:true) + markWuFresh(fresh 渐隐高亮集合) +// workunit:removed(#538)→ removeWorkunit +// 与 B1 主动写(gateWriter 响应体双写)同口径——同一组 store action 是「单份 upsert」的完整含义 +// (ADR 2026-09-15-web-gate-write-module 决策 2)。 +// 挂载点:App 级单点(App.tsx WebSocketProvider 内,同 useRequirementChainStoreSync 位置)。 +// 重连兜底仅在 store 已有负载时刷(页面不在屏不做全量拉);不加轮询(pollIntervalMs=0 停用,维持现状)。 +import { type WebSocketMessage } from '../api/websocketHooks'; +import { useWorkUnitStore } from '../stores/workunitStore'; +import { useDataPlaneSync } from './useDataPlaneSync'; +import type { WorkUnit } from '../api/workunit'; + +function handleWorkUnitEvent(msg: WebSocketMessage) { + const store = useWorkUnitStore.getState(); + + if (msg.event_type === 'workunit:removed') { + // #538:负载 { id, channelId },缺 id 属畸形 fail-closed 跳过 + const data = msg.data as { id?: string } | null | undefined; + if (typeof data?.id === 'string' && data.id) store.removeWorkunit(data.id); + return; + } + + if (msg.event_type !== 'workunit.status_changed' && msg.event_type !== 'workunit.created') return; + // 负载 = { workunit } 信封全量快照(同 workunitApi.get 形状);缺 workunit 属畸形跳过 + const wu = (msg.data as { workunit?: WorkUnit | null } | null | undefined)?.workunit; + if (!wu) return; + store.applyWorkunitEvent(wu, { insertIfMissing: msg.event_type === 'workunit.created' }); + if (msg.event_type === 'workunit.created') store.markWuFresh(wu.id); +} + +/** 引用稳定(useDataPlaneSync 的 effect 依赖要求):重连兜底仅在 store 已有负载时刷 */ +function workUnitEnsureFresh(): Promise { + const store = useWorkUnitStore.getState(); + if (store.workunits.length === 0) return Promise.resolve(); + return Promise.all([ + store.loadWorkUnits(), + store.loadUnattributedCount(), + store.loadAllCount(), + ]).then(() => undefined); +} + +export function useWorkUnitStoreSync(): void { + useDataPlaneSync({ + handleEvent: handleWorkUnitEvent, + ensureFresh: workUnitEnsureFresh, + pollIntervalMs: 0, // 不加轮询(维持现状)——取数时机 = SSE 路由 + 重连强刷 + 页面操作 + }); +} diff --git a/apps/web/src/pages/AuditLogsPage.tsx b/apps/web/src/pages/AuditLogsPage.tsx index ebeff0518..13e002f6e 100644 --- a/apps/web/src/pages/AuditLogsPage.tsx +++ b/apps/web/src/pages/AuditLogsPage.tsx @@ -4,8 +4,9 @@ * 提供审计日志查询、筛选、导出功能 */ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { auditLogApi, type AuditLog, type AuditLogStats } from '../api/auditLogs'; +import React, { useEffect, useRef, useState } from 'react'; +import { auditLogApi, type AuditLog } from '../api/auditLogs'; +import { useAsyncData } from '../hooks/useAsyncData'; import { Select, SkeletonText, SkeletonCard } from '../components/ui'; import { IconSearch } from '../components/ui/icons'; import { toast } from '../utils/toast'; @@ -15,11 +16,9 @@ import { formatFullTime } from '../utils/datetime'; const toStartIso = (d: string) => (d ? new Date(`${d}T00:00:00`).toISOString() : undefined); const toEndIso = (d: string) => (d ? new Date(`${d}T23:59:59.999`).toISOString() : undefined); +const PAGE_LIMIT = 50; + export const AuditLogsPage: React.FC = () => { - const [logs, setLogs] = useState([]); - const [stats, setStats] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); // 行内展开详情:点击行在下方展开/收起,不弹 Modal 遮罩列表 const [expandedId, setExpandedId] = useState(null); const toggleExpanded = (id: string) => setExpandedId(cur => (cur === id ? null : id)); @@ -36,20 +35,55 @@ export const AuditLogsPage: React.FC = () => { // userId 输入框原值(300ms 防抖后才进 filters,批次 B-5,模式参照 LibraryPage) const [userIdInput, setUserIdInput] = useState(''); const [page, setPage] = useState(1); - const [total, setTotal] = useState(0); - const limit = 50; - - // Available options - const [actions, setActions] = useState([]); - const [resources, setResources] = useState([]); - - // 筛选/翻页变化时在渲染期同步置回加载态(替代原 loadLogs 内、由 effect 触发的同步 setLoading) - const filterKey = JSON.stringify([filters, page]); - const [prevFilterKey, setPrevFilterKey] = useState(filterKey); - if (prevFilterKey !== filterKey) { - setPrevFilterKey(filterKey); - setLoading(true); - } + + // #549(B5 收口):取数状态机退位——三份 useAsyncData(logs 随筛选/翻页重拉、 + // stats 与下拉 options 挂载一次 best-effort),自管理 loading 派生(filterKey prev-state hack)已删; + // 筛选/翻页/行展开/userId 防抖留页面本地 + const logsData = useAsyncData(async () => { + const response = await auditLogApi.list({ + action: filters.action || undefined, + resource: filters.resource || undefined, + status: filters.status || undefined, + userId: filters.userId || undefined, + startTime: toStartIso(filters.startDate), + endTime: toEndIso(filters.endDate), + page, + limit: PAGE_LIMIT, + }); + return { + logs: (response.data.data || []) as AuditLog[], + total: response.data.pagination?.total || 0, + }; + }, [filters.action, filters.resource, filters.status, filters.userId, filters.startDate, filters.endDate, page]); + + const statsData = useAsyncData(async () => { + try { + return (await auditLogApi.getStats()).data; + } catch (err) { + console.error('Failed to load stats:', err); + return null; + } + }, []); + + const optionsData = useAsyncData(async () => { + try { + const [actionsRes, resourcesRes] = await Promise.all([ + auditLogApi.listActions(), + auditLogApi.listResources(), + ]); + return { actions: (actionsRes.data.data || []) as string[], resources: (resourcesRes.data.data || []) as string[] }; + } catch (err) { + console.error('Failed to load options:', err); + return null; + } + }, []); + + const logs = logsData.data?.logs ?? []; + const total = logsData.data?.total ?? 0; + const error = logsData.error; + const stats = statsData.data; + const actions = optionsData.data?.actions ?? []; + const resources = optionsData.data?.resources ?? []; // 筛选变化统一入口:改筛选即回第 1 页(修复翻页后改筛选停留旧页、结果错位) const applyFilter = (patch: Partial) => { @@ -67,7 +101,7 @@ export const AuditLogsPage: React.FC = () => { setPage(1); }; - // userId 防抖:跳过首次运行(初始加载由主 effect 触发,输入框初值与 filters 一致无需提交) + // userId 防抖:跳过首次运行(初始加载由 useAsyncData 首拉触发,输入框初值与 filters 一致无需提交) const debounceRef = useRef | null>(null); const firstDebounceRef = useRef(true); useEffect(() => { @@ -84,61 +118,6 @@ export const AuditLogsPage: React.FC = () => { }; }, [userIdInput]); - const loadOptions = useCallback(async () => { - try { - const [actionsRes, resourcesRes] = await Promise.all([ - auditLogApi.listActions(), - auditLogApi.listResources(), - ]); - setActions(actionsRes.data.data || []); - setResources(resourcesRes.data.data || []); - } catch (err) { - console.error('Failed to load options:', err); - } - }, []); - - const loadLogs = useCallback(async () => { - try { - const response = await auditLogApi.list({ - action: filters.action || undefined, - resource: filters.resource || undefined, - status: filters.status || undefined, - userId: filters.userId || undefined, - startTime: toStartIso(filters.startDate), - endTime: toEndIso(filters.endDate), - page, - limit, - }); - const data = response.data; - - setLogs(data.data || []); - setTotal(data.pagination?.total || 0); - setError(null); - } catch (err) { - setError(err.message || '加载失败'); - } finally { - setLoading(false); - } - }, [filters, page]); - - const loadStats = useCallback(async () => { - try { - const response = await auditLogApi.getStats(); - setStats(response.data); - } catch (err) { - console.error('Failed to load stats:', err); - } - }, []); - - useEffect(() => { - // 微任务里触发加载:loader 为多 await async 函数,编译器对 effect 内同步调用保守告警 - void Promise.resolve().then(() => { - loadOptions(); - loadLogs(); - loadStats(); - }); - }, [loadOptions, loadLogs, loadStats]); - const handleExport = () => { // 文件下载:浏览器跳转打开导出 URL(鉴权说明见 api/auditLogs.ts getExportUrl) // 口径与列表一致:status/时间范围随筛选带上 @@ -193,7 +172,7 @@ export const AuditLogsPage: React.FC = () => { ); }; - if (loading && !logs.length) { + if (logsData.loading && !logs.length) { // 批次 F-3:加载态骨架(批次 E-2 ui/Skeleton 正本)——统计卡 4 格 + 表格行形态 return (
@@ -316,7 +295,7 @@ export const AuditLogsPage: React.FC = () => { {error && (
{error} - +
)} @@ -500,10 +479,10 @@ export const AuditLogsPage: React.FC = () => {
{/* Pagination */} - {total > limit && ( + {total > PAGE_LIMIT && (
- {`第 ${page} 页 / 共 ${Math.ceil(total / limit)} 页`} + {`第 ${page} 页 / 共 ${Math.ceil(total / PAGE_LIMIT)} 页`}