merge: feature/app-composition-u11-u12
This commit is contained in:
+5
-3
@@ -6,13 +6,14 @@
|
||||
## 焦点
|
||||
|
||||
1. **CHAT-INTENT**: [对话意图运行时状态](../audit/2026-09-13-conversation-intent-runtime-state.md)(`fix/conversation-intent-state` 已是 `dev` 祖先;浏览器双标签 busy 仍待 Codex 收口)
|
||||
2. **DOC-RELIABILITY**: [U10 已合入,停止扩面](../test-reports/2026-09-15-chatlog-contextasm-u10-test-report.md)(占槽 rebuild PASS;**禁止 U11–U12**)
|
||||
2. **DOC-RELIABILITY**: [U11–U12 组合根与传输门禁](./short-term/2026-09-15-app-composition-u11-u12.md)(用户已授权;合同 PASS,待占槽合入 `dev`)
|
||||
3. **CHAT**: [对话历史刷新活验已补](../test-reports/2026-09-13-chat-history-refresh-test-report.md)(项目 6252 闲聊刷新同集 + guest 403;编制任务链仍开放)
|
||||
|
||||
## 并行认领
|
||||
|
||||
| owner | task | branch | worktree | mutex | status |
|
||||
|-------|------|--------|----------|-------|--------|
|
||||
| Cursor | app-composition-u11-u12 | feature/app-composition-u11-u12 | - | app | in_progress |
|
||||
| Cursor | chatlog-contextasm-u10 | feature/chatlog-contextasm-u10 | - | chat | done |
|
||||
| Cursor | chat-history-refresh-remediation | fix/chat-history-refresh | - | chat | done |
|
||||
| Cursor | dataset-lifecycle-u9 | feature/dataset-lifecycle-u9 | - | dataset | done |
|
||||
@@ -57,13 +58,13 @@
|
||||
> 新任务开始前登记。`branch` 必须为 `feature/<slug>` 或 `fix/<slug>`,**禁止** `dev` / `test` / `main`。同一 `mutex` 同时仅一个 `in_progress`。
|
||||
|
||||
> workflow-plan 409 与治理鉴权 G1–G5 已收口(见 [test-report](../test-reports/2026-09-14-governance-action-level-authz-test-report.md))。禁止终止或改写项目 143。
|
||||
> `document-reliability-pilot` U10 **停止扩面**(已合入 `dev`,占槽 rebuild PASS)。禁止 U11–U12。禁止 worktree 内 `env-service`。项目 143 只读,禁止终止。不碰 `intent`。
|
||||
> `document-reliability-pilot` U11–U12 合同已签(`feature/app-composition-u11-u12`)。合入 `dev` 须占集成槽。禁止 worktree 内 `env-service`。项目 143 只读,禁止终止。不碰 `intent`。
|
||||
|
||||
## 集成槽位
|
||||
|
||||
| holder | phase | since | scope | queue |
|
||||
|--------|-------|-------|-------|-------|
|
||||
| - | idle | - | - | - |
|
||||
| Cursor | merging | 2026-09-15 | U11-U12 app-composition | intent dual-tab after idle |
|
||||
|
||||
**phase**: `idle` | `merging` | `smoke` | `e2e`
|
||||
|
||||
@@ -99,6 +100,7 @@
|
||||
| 文档可靠性 M3 U8 | [../test-reports/2026-09-15-taskrun-runmanifest-u8-test-report.md](../test-reports/2026-09-15-taskrun-runmanifest-u8-test-report.md) |
|
||||
| 文档可靠性 M3 U9 | [../test-reports/2026-09-15-dataset-lifecycle-u9-test-report.md](../test-reports/2026-09-15-dataset-lifecycle-u9-test-report.md) |
|
||||
| 文档可靠性 M4 U10 | [../test-reports/2026-09-15-chatlog-contextasm-u10-test-report.md](../test-reports/2026-09-15-chatlog-contextasm-u10-test-report.md) |
|
||||
| 文档可靠性 M5/M6 U11–U12 | [../test-reports/2026-09-15-app-composition-u11-u12-test-report.md](../test-reports/2026-09-15-app-composition-u11-u12-test-report.md) |
|
||||
| SSE/幂等审计 | [../audit/2026-09-13-document-revision-sse-idempotency.md](../audit/2026-09-13-document-revision-sse-idempotency.md) |
|
||||
| 质量门禁自污染审计 | [../audit/2026-09-13-document-revision-quality-gate-self-pollution.md](../audit/2026-09-13-document-revision-quality-gate-self-pollution.md) |
|
||||
| 当前文档路由审计 | [../audit/2026-09-13-document-revision-active-doc-routing.md](../audit/2026-09-13-document-revision-active-doc-routing.md) |
|
||||
|
||||
@@ -50,10 +50,10 @@ owner: Cursor
|
||||
|
||||
- [x] 每个子切片在原包边界先获得行为收益,再决定物理搬包。
|
||||
- [x] chatlog/contextasm 保持授权可见集合和计数一致。
|
||||
- [ ] app 是唯一组合根,subscriber 重复启动不重复注册。
|
||||
- [ ] UserProjectContext 不再被多个模块字段级直改。
|
||||
- [ ] server 无 SQL、路径拼接和业务闭包。
|
||||
- [ ] 对应架构规则全部 block,过期例外和 flags 已删除。
|
||||
- [x] app 是唯一组合根,subscriber 重复启动不重复注册。
|
||||
- [x] UserProjectContext 跨模块写路径走类型化 ContextUpdate + 版本 CAS(coordinator 作为 owner 仍可改 Project 字段)。
|
||||
- [ ] server 无 SQL、路径拼接和业务闭包。(OnOutbox 已迁出;生产 SQL 无新增;path 例外 `workspaces/` / `output.md` 未到期,未删)
|
||||
- [ ] 对应架构规则全部 block,过期例外和 flags 已删除。(`import`/`event` 已 block;sql_write/path 基线例外仍在)
|
||||
|
||||
## 证据分层
|
||||
|
||||
@@ -69,6 +69,6 @@ owner: Cursor
|
||||
|
||||
## 关闭条件
|
||||
|
||||
- [ ] test report 回链具体 U-ID、commit、环境 identity 和 fixture/dataset。(U10 已回链;U11–U12 未实施,禁止用 U10 冒充全计划完成)
|
||||
- [x] test report 回链具体 U-ID、commit、环境 identity 和 fixture/dataset。([U11–U12 报告](../../test-reports/2026-09-15-app-composition-u11-u12-test-report.md);合入 `dev` 后补 rebuild 镜像)
|
||||
- [x] CURRENT、FEATURE-MATRIX、gap-matrix 与任务状态一致。
|
||||
- [x] 未实施后续仍为 planned/deferred,不用局部测试冒充全计划完成。
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: "M5/M6 组合根与传输门禁 U11–U12"
|
||||
status: in_progress
|
||||
created: 2026-09-15
|
||||
updated: 2026-09-15
|
||||
owner: Cursor
|
||||
branch: feature/app-composition-u11-u12
|
||||
worktree: "-"
|
||||
mutex: app
|
||||
---
|
||||
|
||||
# M5/M6 组合根与传输门禁 U11–U12
|
||||
|
||||
## 目标
|
||||
|
||||
按整改计划四个子切片完成组合根、文档通知闭包迁出、stageflow 用例、UserProjectContext CAS;再收口 SSE 编码与已清零规则的 arch-check block。用户已明确授权继续 U11/U12(推翻 U10 报告的停止扩面)。
|
||||
|
||||
## 范围
|
||||
|
||||
- U11a:`internal/app` 唯一 composition root;幂等 Start/Stop
|
||||
- U11b:文档 `OnOutbox` 通知迁到 `internal/app/docnotify`
|
||||
- U11c:`internal/app/stageflow` 拥有 ExecuteTask / ExecuteStage
|
||||
- U11d:`ContextUpdate` 版本 CAS,禁止跨模块字段级直改
|
||||
- U12:SSE 中性 frame 编码;`import`/`event` 转 block;不改错误信封与 PI Runner 协议
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不碰 `intent` 包行为(mutex 仍由 Codex 占用)
|
||||
- 不整支 merge `feature/final-artifact-vault`;不启用 `FAV_DEGRADED_READ`
|
||||
- 不终止或改写项目 143
|
||||
- 不删除未到期的 sql_write/path 例外(U3/U5 残留)
|
||||
- 不在 worktree 内 `env-service`
|
||||
|
||||
## 步骤
|
||||
|
||||
1. [x] 认领 CURRENT + 本任务
|
||||
2. [x] U11a composition root
|
||||
3. [x] U11b docnotify
|
||||
4. [x] U11c stageflow
|
||||
5. [x] U11d ContextUpdate
|
||||
6. [x] U12 SSE + arch-check block_kinds
|
||||
7. [x] 短测 / 目标包 race / 报告
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [x] 重复 Start 不重复注册 subscriber;Stop 后再 Start worker 仍在
|
||||
- [x] server 生产代码不再赋值 `OnOutbox`
|
||||
- [x] HTTP 与 CLI 走同一 stageflow 用例
|
||||
- [x] ContextUpdate 并发 stale version 不丢更新赢家
|
||||
- [x] `arch-check --mode block` 对已清零的 import/event 通过
|
||||
- [x] 测试报告回链 U11/U12
|
||||
|
||||
## 相关链接
|
||||
|
||||
- Checklist: [../checklists/2026-09-14-module-decoupling-refactor-acceptance.md](../checklists/2026-09-14-module-decoupling-refactor-acceptance.md)
|
||||
- Plan: [../../remediation/2026-09-14-module-decoupling-refactor-plan.md](../../remediation/2026-09-14-module-decoupling-refactor-plan.md)
|
||||
- Test report: [../../../test-reports/2026-09-15-app-composition-u11-u12-test-report.md](../../../test-reports/2026-09-15-app-composition-u11-u12-test-report.md)
|
||||
|
||||
## 证据 / 备注
|
||||
|
||||
用户口头授权:「继续解耦到 U11、U12」。
|
||||
@@ -70,7 +70,7 @@
|
||||
| DOCREV-ACTIVE-DOC-ROUTING | UI-05「当前受控文档」被法规关键词改路由到 `feasibility_reg`,intake 无 pending | **PASS** | [audit](../audit/2026-09-13-document-revision-active-doc-routing.md);Round20 UI-05 pending 停在 intake V2 | **不改弱 E2E**;不删证据 |
|
||||
| DOCREV-QUALITY-STATUS-FP | 行动计划表状态列「待续」被门禁当敷衍,两轮 repair 后 quality_failed 80 | **PASS** | [audit](../audit/2026-09-13-document-revision-quality-status-false-positive.md);Round20 UI-03 | **不删禁词、不放宽正文**;不删证据 |
|
||||
| GOVERNANCE-ACTION-AUTHZ | 文档动作按职责鉴权;未激活 202-blocked;关闭创建直通 | **PASS** | [test-report](../test-reports/2026-09-14-governance-action-level-authz-test-report.md);[audit 异态](../audit/2026-09-14-planning-with-workflow-key-anomaly.md) | 不改 83 条 planning+key 夹具;none 长 PI 审批链另开 |
|
||||
| DOC-RELIABILITY-PILOT | 受控文档写路径跨表非同事务、读路径被 projection 绑架、授权 TOCTOU;刷新/重启后可能 404/409/projection-missing | OPEN | [复核](../audit/2026-09-14-module-decoupling-plan-review.md);[重构计划](./2026-09-14-module-decoupling-refactor-plan.md);[试点任务](../ops/short-term/2026-09-14-document-reliability-pilot.md);[M1 停止扩面](../test-reports/2026-09-14-document-reliability-pilot-test-report.md);[M1 活验](../test-reports/2026-09-14-document-reliability-pilot-live-test-report.md);[M2](../test-reports/2026-09-14-document-reliability-m2-test-report.md);[M3 U8](../test-reports/2026-09-15-taskrun-runmanifest-u8-test-report.md);[M3 U9](../test-reports/2026-09-15-dataset-lifecycle-u9-test-report.md);[U10 停止扩面](../test-reports/2026-09-15-chatlog-contextasm-u10-test-report.md) | M3 已签。U10 **停止扩面**(未拆包;已合入 `dev` + rebuild)。禁止 U11–U12 业务代码 |
|
||||
| DOC-RELIABILITY-PILOT | 受控文档写路径跨表非同事务、读路径被 projection 绑架、授权 TOCTOU;刷新/重启后可能 404/409/projection-missing | OPEN | [复核](../audit/2026-09-14-module-decoupling-plan-review.md);[重构计划](./2026-09-14-module-decoupling-refactor-plan.md);[试点任务](../ops/short-term/2026-09-14-document-reliability-pilot.md);[M1 停止扩面](../test-reports/2026-09-14-document-reliability-pilot-test-report.md);[M1 活验](../test-reports/2026-09-14-document-reliability-pilot-live-test-report.md);[M2](../test-reports/2026-09-14-document-reliability-m2-test-report.md);[M3 U8](../test-reports/2026-09-15-taskrun-runmanifest-u8-test-report.md);[M3 U9](../test-reports/2026-09-15-dataset-lifecycle-u9-test-report.md);[U10 报告](../test-reports/2026-09-15-chatlog-contextasm-u10-test-report.md);[U11–U12 报告](../test-reports/2026-09-15-app-composition-u11-u12-test-report.md) | U11–U12 合同已签,待占槽合入 `dev` + rebuild。path/sql_write 未到期例外保留。不碰 intent / 项目 143 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
| PI Runner / 配置链 | pi-agent + MiniMax 真实链路 | done | [test piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md) | — | `pi-agent` |
|
||||
| 三环境运维 | dev/test/main + 共享 KB | done | [PROJECT.md](../../PROJECT.md) §4;四环境 healthy | — | `infra` |
|
||||
| CI / E2E | PG 集成 + 真实 PI E2E | partial | [gap-matrix BUG-08](../remediation/gap-matrix.md);[test pg-ci](../test-reports/2026-09-12-pg-ci-soak-e2e-test-report.md);[运行时 UI Round20](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md) | CI `TEST_DATABASE_URL` 隔离库仍缺;文档运行时真实 UI 已按 UI_PHASE 分文件且 post-restart 断言最终 A1 正文。`-race` 全包并行 TempDir flake 为基建残留 | `ci` |
|
||||
| 模块解耦与可靠事件 | 文档可靠性纵向试点、跨属主事务、多副本互斥、FAV 与选择性模块化 | partial | [架构](../system-design/2026-09-14-module-decoupling-architecture.md);[ADR](../adr/2026-09-14-modular-monolith-decoupling.md);[重构计划](../remediation/2026-09-14-module-decoupling-refactor-plan.md);[试点任务](../ops/short-term/2026-09-14-document-reliability-pilot.md);[M1 停止扩面](../test-reports/2026-09-14-document-reliability-pilot-test-report.md);[M1 活验](../test-reports/2026-09-14-document-reliability-pilot-live-test-report.md);[M2](../test-reports/2026-09-14-document-reliability-m2-test-report.md);[M3 U8](../test-reports/2026-09-15-taskrun-runmanifest-u8-test-report.md);[M3 U9](../test-reports/2026-09-15-dataset-lifecycle-u9-test-report.md);[U10 报告](../test-reports/2026-09-15-chatlog-contextasm-u10-test-report.md) | U5–U10 已签(U10 **停止扩面**,已合入 `dev` + rebuild)。禁止 U11–U12。未整支 merge FAV | `architecture-docs` |
|
||||
| 模块解耦与可靠事件 | 文档可靠性纵向试点、跨属主事务、多副本互斥、FAV 与选择性模块化 | partial | [架构](../system-design/2026-09-14-module-decoupling-architecture.md);[ADR](../adr/2026-09-14-modular-monolith-decoupling.md);[重构计划](../remediation/2026-09-14-module-decoupling-refactor-plan.md);[试点任务](../ops/short-term/2026-09-14-document-reliability-pilot.md);[M1 停止扩面](../test-reports/2026-09-14-document-reliability-pilot-test-report.md);[M1 活验](../test-reports/2026-09-14-document-reliability-pilot-live-test-report.md);[M2](../test-reports/2026-09-14-document-reliability-m2-test-report.md);[M3 U8](../test-reports/2026-09-15-taskrun-runmanifest-u8-test-report.md);[M3 U9](../test-reports/2026-09-15-dataset-lifecycle-u9-test-report.md);[U10 报告](../test-reports/2026-09-15-chatlog-contextasm-u10-test-report.md);[U11–U12 报告](../test-reports/2026-09-15-app-composition-u11-u12-test-report.md) | U5–U12 合同已签(U11–U12 在 `feature/app-composition-u11-u12`,合入 `dev` 后占槽 rebuild)。path/sql_write 未到期例外保留。未整支 merge FAV | `architecture-docs` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -56,12 +56,36 @@ imports:
|
||||
- storage
|
||||
db: []
|
||||
docduty: []
|
||||
app: []
|
||||
app:
|
||||
- agent
|
||||
- app/chatflow
|
||||
- app/docnotify
|
||||
- app/stageflow
|
||||
- app/uow
|
||||
- artifactvault
|
||||
- auth
|
||||
- authz
|
||||
- bizdev
|
||||
- config
|
||||
- coordinator
|
||||
- docgov
|
||||
- intent
|
||||
- notification
|
||||
- observability
|
||||
- projectinstance
|
||||
- projectworkflow
|
||||
- taskrun
|
||||
- workflowdef
|
||||
- workspace
|
||||
app/chatflow:
|
||||
- bizdev
|
||||
- coordinator
|
||||
- ctxmgr
|
||||
- projectinstance
|
||||
app/docnotify:
|
||||
- docgov
|
||||
- docrevision
|
||||
- notification
|
||||
app/docflow:
|
||||
- app/uow
|
||||
- docgov
|
||||
@@ -69,6 +93,8 @@ imports:
|
||||
- docrevision
|
||||
- events
|
||||
app/uow: []
|
||||
app/stageflow:
|
||||
- bizdev
|
||||
docquery:
|
||||
- docgov
|
||||
- docrevision
|
||||
@@ -105,8 +131,10 @@ imports:
|
||||
replyexport: []
|
||||
server:
|
||||
- agent
|
||||
- app
|
||||
- app/chatflow
|
||||
- app/docflow
|
||||
- app/stageflow
|
||||
- app/uow
|
||||
- auth
|
||||
- authz
|
||||
@@ -128,6 +156,7 @@ imports:
|
||||
- replyexport
|
||||
- squad
|
||||
- stream
|
||||
- stream/transport
|
||||
- taskrun
|
||||
- upload
|
||||
- workflowdef
|
||||
@@ -142,6 +171,8 @@ imports:
|
||||
- workspace
|
||||
storage: []
|
||||
stream: []
|
||||
stream/transport:
|
||||
- stream
|
||||
taskrun:
|
||||
- app/uow
|
||||
- artifactvault
|
||||
@@ -256,7 +287,7 @@ event_producers:
|
||||
|
||||
event_consumers:
|
||||
outbox.notify:
|
||||
- server
|
||||
- app/docnotify
|
||||
- notification
|
||||
|
||||
unwrap_owners:
|
||||
@@ -443,3 +474,9 @@ baseline_counts:
|
||||
sql_write: 5
|
||||
path: 16
|
||||
event: 0
|
||||
|
||||
# U12: kinds already at zero production findings fail --mode block.
|
||||
# sql_write/path keep dated exceptions until their owner milestones clear them.
|
||||
block_kinds:
|
||||
- import
|
||||
- event
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# M5/M6 U11–U12 组合根与传输门禁 测试报告
|
||||
|
||||
## 一、 测试背景 (Background)
|
||||
|
||||
权威停走门是 U10 报告最后一句 **停止扩面**([2026-09-15-chatlog-contextasm-u10-test-report.md](./2026-09-15-chatlog-contextasm-u10-test-report.md))。用户于 2026-09-15 明确授权「继续解耦到 U11、U12」,推翻该停止扩面。本轮按整改计划四个子切片落地组合根、文档通知闭包迁出、stageflow 用例、UserProjectContext CAS,以及 SSE 中性编码与已清零规则的 arch-check block。
|
||||
|
||||
## 二、 测试目标 (Test Objectives)
|
||||
|
||||
- U11a:`internal/app.Runtime` 是唯一 composition root;重复 Start 不重复注册 subscriber;Stop 后再 Start worker 仍在。
|
||||
- U11b:文档 `OnOutbox` 从 `internal/server` 迁到 `internal/app/docnotify`;Bind 幂等。
|
||||
- U11c:HTTP / CLI / coordinator 走同一 `stageflow.UseCase`。
|
||||
- U11d:`ApplyContextUpdate` 版本 CAS,并发同版本恰好一个赢家。
|
||||
- U12:SSE 只编码中性 frame;`import`/`event` 进入 `--mode block`;不改错误信封与 PI Runner 协议。
|
||||
- 不碰 `intent`;不终止项目 143;不启用 `FAV_DEGRADED_READ`。
|
||||
|
||||
## 三、 测试方案与环境 (Test Plan & Environment)
|
||||
|
||||
| 项 | 值 |
|
||||
|----|-----|
|
||||
| OS | Linux 7.0.0-31-generic |
|
||||
| 分支 | `feature/app-composition-u11-u12`(从本地 `dev@ef9a162` 检出) |
|
||||
| 本机合同测试 | `unset TEST_DATABASE_URL; go test -short ./internal/app ./internal/app/docnotify ./internal/app/stageflow ./internal/coordinator ./internal/stream/transport ./internal/archtest` |
|
||||
| 架构门 | `./scripts/arch-check.sh --mode warn` 与 `--mode block` |
|
||||
| 目标包 race | `go test -race -short` 上述包(不含全仓 race) |
|
||||
| 全仓 short | `unset TEST_DATABASE_URL; go test -short -count=1 ./...` |
|
||||
| 真实 PI / 浏览器 / rebuild | 本分支未占集成槽;合入 `dev` 后由主仓 rebuild 证明镜像 |
|
||||
| 证据 | `.doc/test-reports/assets/2026-09-15-app-composition-u11-u12/` |
|
||||
|
||||
## 四、 预期结果 (Expected Results)
|
||||
|
||||
| 栏 | 期望 |
|
||||
|----|------|
|
||||
| Start 幂等 | 连续两次 Start,`StartCount==1` 且 `SubscriberCount==1` |
|
||||
| Restart | Stop 后再 Start,`StartCount==2` 且 subscriber 不加倍 |
|
||||
| OnOutbox | 生产 `internal/server` 无 `OnOutbox =`;`docnotify.Bind` 第二次为 no-op |
|
||||
| stageflow | HTTP 与 CLI 均调用 `stageflow.UseCase`,同一 stub 计数共享 |
|
||||
| CAS | 两 goroutine 同 `ExpectedVersion` → 1 成功 / 1 `ErrStaleContext` |
|
||||
| SSE | frame 含 `event:`/`data:`,不含 `retry:` |
|
||||
| arch-check block | `import`/`event` 计数为 0;sql_write/path 既有例外仍允许 |
|
||||
|
||||
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
|
||||
|
||||
### 5.1 U11a composition root
|
||||
|
||||
- **测试输入**: `go test -short -count=1 ./internal/app -run TestStartStopRestartIdempotent -v`
|
||||
- **实际输出**: `TestStartStopRestartIdempotent` PASS;`cmd/server` 改为 `app.New` + `Runtime.Start` + `server.NewFromRuntime`
|
||||
- **评价判定**: **PASS**
|
||||
- **性能与耗时**: 0.014s。日志:`assets/2026-09-15-app-composition-u11-u12/go-test-u11-packages.txt`
|
||||
|
||||
### 5.2 U11b 文档通知闭包
|
||||
|
||||
- **测试输入**: `go test -short -count=1 ./internal/app/docnotify -v`;`rg OnOutbox internal/server`
|
||||
- **实际输出**: `TestBindOnce` / `TestHandleCandidateReady` / `TestHandleSkipsUnknownAndNoRecipient` PASS。生产 server 包无 `OnOutbox`。`event_consumers.outbox.notify` 属主改为 `app/docnotify`
|
||||
- **评价判定**: **PASS**
|
||||
- **性能与耗时**: 0.002s
|
||||
|
||||
### 5.3 U11c stageflow
|
||||
|
||||
- **测试输入**: `go test -short -count=1 ./internal/app/stageflow -v`
|
||||
- **实际输出**: `TestUseCaseDelegates` PASS(HTTP/CLI 两个 UseCase 共享同一 stub,task=1 stage=1);`handlers` / `task_run_handlers` / `cmd/bizdev-cli` / coordinator `SetStageTaskRunner` 均走该用例
|
||||
- **评价判定**: **PASS**
|
||||
- **性能与耗时**: 0.004s
|
||||
|
||||
### 5.4 U11d ContextUpdate CAS
|
||||
|
||||
- **测试输入**: `go test -race -short -count=1 ./internal/coordinator -run TestContextUpdateConcurrentNoLostUpdate -v`
|
||||
- **实际输出**: 两 goroutine 同版本写 blackboard:1 赢家、1 `ErrStaleContext`,`Version==2`。`-race` PASS(零值 `sync.Mutex`,无懒初始化竞态)
|
||||
- **评价判定**: **PASS**
|
||||
- **性能与耗时**: 约 1.0s(race)。日志:`assets/2026-09-15-app-composition-u11-u12/go-test-u11-packages-race.txt`
|
||||
|
||||
说明:`UserProjectContext` 字段仍导出供读取。跨模块写路径(server 会话组装、chatflow assembler、chat session、coordinator pending/card/blackboard)已改 `ApplyContextUpdate`。coordinator 内部仍有 `Project.CurrentStage` 等字段赋值(该包为上下文 owner)。
|
||||
|
||||
### 5.5 U12 SSE 与 arch-check
|
||||
|
||||
- **测试输入**: `go test -short ./internal/stream/transport`;`./scripts/arch-check.sh --mode warn`;`./scripts/arch-check.sh --mode block`
|
||||
- **实际输出**: `TestWriteNeutralFrame` / `TestHeartbeat` PASS。warn 与 block 均为 `production gate passed`。CI 增加 `arch-check --mode block`。`block_kinds: [import, event]`。未到期 sql_write(5)/path(16) 例外保留
|
||||
- **评价判定**: **PASS**(已清零规则 block;路径/SQL writer 基线例外未假装清零)
|
||||
- **性能与耗时**: arch-check block 约 0.2s。日志:`arch-check-warn.txt`、`arch-check-block.txt`
|
||||
|
||||
### 5.6 真实 PI / 浏览器 / rebuild
|
||||
|
||||
- 真实 PI:本切片不改生成链路,未重跑编制任务。
|
||||
- 浏览器:未改 web 交互。
|
||||
- rebuild:未占集成槽;合入 `dev` 后须主仓 `./scripts/env-service.sh dev rebuild` 证明镜像。当前 `:8080` 仍为 U10 镜像。
|
||||
|
||||
## 六、 自动化测试套件汇总 (Test Suite Execution)
|
||||
|
||||
| 命令 | 结果 |
|
||||
|------|------|
|
||||
| `go vet ./...` | PASS |
|
||||
| `unset TEST_DATABASE_URL; go test -short -count=1 ./internal/app ./internal/app/docnotify ./internal/app/stageflow ./internal/coordinator ./internal/stream/transport ./internal/archtest` | PASS |
|
||||
| `go test -race -short -count=1` 同上目标包(不含 archtest) | PASS |
|
||||
| `./scripts/arch-check.sh --mode warn` | PASS |
|
||||
| `./scripts/arch-check.sh --mode block` | PASS(import/event 已清零) |
|
||||
| `unset TEST_DATABASE_URL; go test -short -count=1 ./...` | PASS(EXIT:0,`internal/server` 9.992s) |
|
||||
|
||||
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
|
||||
|
||||
U11a–U11d 与 U12 合同测试达到合入标准。未清零的 server `workspaces/` / `output.md` 路径例外与 sql_write 基线仍按到期日保留,不把 `--mode block` 写成全规则清零。合入 `dev` 需占 CURRENT 集成槽并 rebuild。
|
||||
|
||||
**停止扩面**(整改计划 U1–U12 已在本分支落地;未到期 path/sql_write 例外不属于本单元删除范围)。
|
||||
@@ -0,0 +1,5 @@
|
||||
arch-check mode=block
|
||||
boundaries=/home/weichen/projects/Agent-System/.doc/system-design/module-boundaries.yaml
|
||||
ok chat-agent-system/internal/archtest 0.110s
|
||||
ok chat-agent-system/internal/archtest 0.105s
|
||||
arch-check block: production gate passed
|
||||
@@ -0,0 +1,4 @@
|
||||
arch-check mode=warn
|
||||
boundaries=/home/weichen/projects/Agent-System/.doc/system-design/module-boundaries.yaml
|
||||
ok chat-agent-system/internal/archtest 0.101s
|
||||
arch-check warn: production gate passed
|
||||
@@ -0,0 +1,53 @@
|
||||
ok chat-agent-system/cmd/bizdev-cli 2.383s
|
||||
? chat-agent-system/cmd/dataset [no test files]
|
||||
ok chat-agent-system/cmd/document-content-reconcile 0.005s
|
||||
? chat-agent-system/cmd/document-revision-backfill [no test files]
|
||||
? chat-agent-system/cmd/recategorize [no test files]
|
||||
? chat-agent-system/cmd/server [no test files]
|
||||
ok chat-agent-system/internal/agent 1.076s
|
||||
ok chat-agent-system/internal/app 0.014s
|
||||
ok chat-agent-system/internal/app/chatflow 0.007s
|
||||
ok chat-agent-system/internal/app/docflow 0.005s
|
||||
ok chat-agent-system/internal/app/docnotify 0.002s
|
||||
ok chat-agent-system/internal/app/stageflow 0.005s
|
||||
ok chat-agent-system/internal/app/uow 0.004s
|
||||
ok chat-agent-system/internal/archtest 0.138s
|
||||
ok chat-agent-system/internal/artifactvault 0.005s
|
||||
ok chat-agent-system/internal/auth 0.838s
|
||||
ok chat-agent-system/internal/authz 0.003s
|
||||
ok chat-agent-system/internal/bizdev 0.164s
|
||||
ok chat-agent-system/internal/chat 0.016s
|
||||
ok chat-agent-system/internal/config 0.012s
|
||||
ok chat-agent-system/internal/coordinator 0.013s
|
||||
ok chat-agent-system/internal/ctxmgr 0.097s
|
||||
ok chat-agent-system/internal/dataset 0.012s
|
||||
ok chat-agent-system/internal/db 0.012s
|
||||
ok chat-agent-system/internal/docduty 0.012s
|
||||
ok chat-agent-system/internal/docgov 0.021s
|
||||
ok chat-agent-system/internal/docquery 0.012s
|
||||
ok chat-agent-system/internal/docrevision 0.012s
|
||||
ok chat-agent-system/internal/events 0.012s
|
||||
ok chat-agent-system/internal/freedoc 0.027s
|
||||
ok chat-agent-system/internal/fsutil 0.013s
|
||||
ok chat-agent-system/internal/intent 0.018s
|
||||
ok chat-agent-system/internal/kb 0.012s
|
||||
ok chat-agent-system/internal/notification 0.078s
|
||||
? chat-agent-system/internal/observability [no test files]
|
||||
ok chat-agent-system/internal/projectcontext 0.015s
|
||||
ok chat-agent-system/internal/projectinstance 0.016s
|
||||
ok chat-agent-system/internal/projectworkflow 0.014s
|
||||
ok chat-agent-system/internal/quote 0.019s
|
||||
ok chat-agent-system/internal/replyexport 0.013s
|
||||
ok chat-agent-system/internal/server 9.992s
|
||||
ok chat-agent-system/internal/squad 0.039s
|
||||
ok chat-agent-system/internal/storage 0.017s
|
||||
ok chat-agent-system/internal/stream 0.003s
|
||||
ok chat-agent-system/internal/stream/transport 0.003s
|
||||
ok chat-agent-system/internal/taskrun 0.146s
|
||||
ok chat-agent-system/internal/template 0.722s
|
||||
ok chat-agent-system/internal/upload 0.003s
|
||||
? chat-agent-system/internal/workflow [no test files]
|
||||
ok chat-agent-system/internal/workflow/runtime 0.003s
|
||||
ok chat-agent-system/internal/workflowdef 0.003s
|
||||
ok chat-agent-system/internal/workspace 0.003s
|
||||
? chat-agent-system/templates [no test files]
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
ok chat-agent-system/internal/app 1.025s
|
||||
ok chat-agent-system/internal/app/docnotify 1.008s
|
||||
ok chat-agent-system/internal/app/stageflow 1.015s
|
||||
ok chat-agent-system/internal/coordinator 1.041s
|
||||
ok chat-agent-system/internal/stream/transport 1.008s
|
||||
@@ -0,0 +1,6 @@
|
||||
ok chat-agent-system/internal/app 0.014s
|
||||
ok chat-agent-system/internal/app/docnotify 0.002s
|
||||
ok chat-agent-system/internal/app/stageflow 0.004s
|
||||
ok chat-agent-system/internal/coordinator 0.007s
|
||||
ok chat-agent-system/internal/stream/transport 0.002s
|
||||
ok chat-agent-system/internal/archtest 0.102s
|
||||
@@ -45,6 +45,9 @@ jobs:
|
||||
TEST_DATABASE_URL: postgres://app:ci_secret_db_pass@localhost:5432/agent_system_ci?sslmode=disable
|
||||
run: go test -short -timeout 20m ./...
|
||||
|
||||
- name: arch-check (block cleared kinds)
|
||||
run: ./scripts/arch-check.sh --mode block
|
||||
|
||||
backup-restore-drill:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/app/stageflow"
|
||||
"chat-agent-system/internal/bizdev"
|
||||
"chat-agent-system/internal/chat"
|
||||
"chat-agent-system/internal/config"
|
||||
@@ -240,7 +241,7 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
res, err := svc.ExecuteTask(context.Background(), bizdev.TaskRunRequest{
|
||||
res, err := stageflow.New(svc).ExecuteTask(context.Background(), bizdev.TaskRunRequest{
|
||||
TaskKey: *taskKey,
|
||||
ProjectName: *project,
|
||||
CustomerName: *customer,
|
||||
@@ -347,7 +348,7 @@ func handleStageRun(
|
||||
ctx, _ = stream.WithListener(ctx, verboseMode, listener)
|
||||
}
|
||||
|
||||
stageRes, err := svc.ExecuteStage(ctx, bizdev.StageRunRequest{
|
||||
stageRes, err := stageflow.New(svc).ExecuteStage(ctx, bizdev.StageRunRequest{
|
||||
Stage: bizdev.MaturityStage(stageName),
|
||||
ProjectName: project,
|
||||
CustomerName: customer,
|
||||
|
||||
+7
-3
@@ -10,7 +10,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/bizdev"
|
||||
"chat-agent-system/internal/app"
|
||||
"chat-agent-system/internal/config"
|
||||
"chat-agent-system/internal/server"
|
||||
)
|
||||
@@ -38,12 +38,16 @@ func main() {
|
||||
cfg.AgentBackend = *backend
|
||||
}
|
||||
|
||||
svc, err := bizdev.NewService(cfg)
|
||||
rt, err := app.New(cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "业务服务初始化失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
srv := server.NewServer(cfg, svc)
|
||||
if err := rt.Start(context.Background()); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "后台 worker 启动失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
srv := server.NewFromRuntime(rt)
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", cfg.HTTPHost, cfg.HTTPPort)
|
||||
fmt.Println("==================================================================")
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/agent"
|
||||
"chat-agent-system/internal/app/chatflow"
|
||||
"chat-agent-system/internal/app/docnotify"
|
||||
"chat-agent-system/internal/app/stageflow"
|
||||
"chat-agent-system/internal/app/uow"
|
||||
"chat-agent-system/internal/artifactvault"
|
||||
"chat-agent-system/internal/auth"
|
||||
"chat-agent-system/internal/authz"
|
||||
"chat-agent-system/internal/bizdev"
|
||||
"chat-agent-system/internal/config"
|
||||
"chat-agent-system/internal/coordinator"
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/intent"
|
||||
"chat-agent-system/internal/notification"
|
||||
"chat-agent-system/internal/observability"
|
||||
"chat-agent-system/internal/projectinstance"
|
||||
"chat-agent-system/internal/projectworkflow"
|
||||
"chat-agent-system/internal/taskrun"
|
||||
"chat-agent-system/internal/workflowdef"
|
||||
"chat-agent-system/internal/workspace"
|
||||
)
|
||||
|
||||
const defaultReminderPollInterval = 15 * time.Minute
|
||||
|
||||
// Runtime is the unique composition root. cmd/server, tests and workers
|
||||
// construct it once; HTTP adapters must not re-wire domain services.
|
||||
type Runtime struct {
|
||||
Cfg *config.Config
|
||||
Biz *bizdev.Service
|
||||
Engine *coordinator.CoordinatorEngine
|
||||
Auth *auth.Service
|
||||
PW *projectworkflow.Service
|
||||
TaskRuns *taskrun.Manager
|
||||
Authorizer *authz.Authorizer
|
||||
Authz *authz.Service
|
||||
WFDef *workflowdef.Service
|
||||
Inst *projectinstance.Service
|
||||
Notify *notification.Service
|
||||
Resolver *workspace.Resolver
|
||||
WSCap *workspace.Capability
|
||||
Commands *taskrun.Commands
|
||||
Rec intent.Recognizer
|
||||
Stageflow *stageflow.UseCase
|
||||
DocNotify *docnotify.Subscriber
|
||||
ChatLog chatflow.ChatLog
|
||||
Assembler chatflow.ContextAssembler
|
||||
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
subscribers int
|
||||
starts int
|
||||
notifyBound bool
|
||||
hookBound bool
|
||||
reminderCancel context.CancelFunc
|
||||
}
|
||||
|
||||
func New(cfg *config.Config) (*Runtime, error) {
|
||||
return Assemble(cfg, nil)
|
||||
}
|
||||
|
||||
// Assemble wires domain services. svc may be injected by tests.
|
||||
func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
|
||||
if cfg == nil {
|
||||
cfg, _ = config.Load("")
|
||||
}
|
||||
if cfg != nil {
|
||||
observability.InitLogger(cfg.Env)
|
||||
}
|
||||
if svc == nil {
|
||||
var err error
|
||||
svc, err = bizdev.NewService(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
rec := intent.NewRecognizer()
|
||||
dialogueBackend := agent.NewBackend(cfg.AgentBackend, cfg)
|
||||
coordEngine := coordinator.NewCoordinatorEngineWithDialogue(svc, dialogueBackend, cfg.DataDir)
|
||||
pool := svc.Pool()
|
||||
stores, storeErr := auth.NewStores(context.Background(), pool, cfg)
|
||||
if storeErr != nil {
|
||||
return nil, fmt.Errorf("auth store: %w", storeErr)
|
||||
}
|
||||
authSvc := auth.NewService(stores.Auth, stores.Sessions, cfg)
|
||||
pwSvc := svc.ProjectWorkflow()
|
||||
if pwSvc == nil {
|
||||
pwSvc = projectworkflow.NewService(projectworkflow.NewStore(nil), nil)
|
||||
}
|
||||
|
||||
authzSvc := authz.NewService(pool)
|
||||
wfDefSvc := workflowdef.NewService(pool, svc.CAS())
|
||||
instStore := projectinstance.NewStore(pool)
|
||||
var instSvc *projectinstance.Service
|
||||
if wfDefSvc.Available() {
|
||||
docChecker := projectinstance.NewDocGovChecker(svc.DocGov())
|
||||
instSvc = projectinstance.NewService(instStore, wfDefSvc.Store(), pwSvc.Store(), docChecker)
|
||||
if pool != nil {
|
||||
instSvc.SetActiveMemberLookup(projectinstance.NewPGMemberLookup(pool))
|
||||
}
|
||||
}
|
||||
taskMgr := taskrun.NewManager(pwSvc, svc.ContextManager())
|
||||
if pool != nil {
|
||||
taskMgr.SetStore(taskrun.NewPGStore(pool))
|
||||
}
|
||||
wsCap := workspace.NewCapability(cfg.DataDir, workspace.NewLeaseTable())
|
||||
cmds := &taskrun.Commands{
|
||||
Ctx: svc.ContextManager(),
|
||||
PW: pwSvc,
|
||||
Env: cfg.Env,
|
||||
StorageIdentity: "env:" + cfg.Env,
|
||||
}
|
||||
if pool != nil {
|
||||
cmds.UoW = uow.NewPG(pool)
|
||||
cmds.Fav = &artifactvault.Ingest{Env: cfg.Env}
|
||||
}
|
||||
taskMgr.SetCommands(cmds)
|
||||
|
||||
stages := stageflow.New(svc)
|
||||
coordEngine.SetStageTaskRunner(stages)
|
||||
if instSvc != nil && instSvc.Available() {
|
||||
coordEngine.SetInstanceService(instSvc)
|
||||
}
|
||||
|
||||
rt := &Runtime{
|
||||
Cfg: cfg,
|
||||
Biz: svc,
|
||||
Engine: coordEngine,
|
||||
Auth: authSvc,
|
||||
PW: pwSvc,
|
||||
TaskRuns: taskMgr,
|
||||
Authorizer: authz.NewAuthorizer(pool),
|
||||
Authz: authzSvc,
|
||||
WFDef: wfDefSvc,
|
||||
Inst: instSvc,
|
||||
Notify: notification.NewService(pool),
|
||||
Resolver: workspace.NewResolver(cfg.DataDir, pool),
|
||||
WSCap: wsCap,
|
||||
Commands: cmds,
|
||||
Rec: rec,
|
||||
Stageflow: stages,
|
||||
}
|
||||
rt.DocNotify = &docnotify.Subscriber{Notify: rt.Notify, Docs: svc.DocGov()}
|
||||
rt.bindDocumentNotify()
|
||||
rt.bindWorkflowApprovalHook()
|
||||
return rt, nil
|
||||
}
|
||||
|
||||
func (r *Runtime) bindDocumentNotify() {
|
||||
if r == nil || r.notifyBound {
|
||||
return
|
||||
}
|
||||
if r.Biz != nil && r.Biz.DocRevision() != nil {
|
||||
if docnotify.Bind(r.Biz.DocRevision(), r.DocNotify) {
|
||||
r.subscribers++
|
||||
}
|
||||
}
|
||||
r.notifyBound = true
|
||||
}
|
||||
|
||||
func (r *Runtime) bindWorkflowApprovalHook() {
|
||||
if r == nil || r.hookBound || r.Biz == nil || r.Biz.DocGov() == nil || r.Inst == nil || !r.Inst.Available() {
|
||||
r.hookBound = true
|
||||
return
|
||||
}
|
||||
r.Biz.DocGov().AddOnApprovedHook(r.onWorkflowDocumentApproved)
|
||||
r.hookBound = true
|
||||
r.subscribers++
|
||||
}
|
||||
|
||||
func (r *Runtime) onWorkflowDocumentApproved(ctx context.Context, doc *docgov.DocumentRecord) {
|
||||
if doc == nil || doc.TaskKey == "" || doc.ProjectID <= 0 || r.Inst == nil {
|
||||
return
|
||||
}
|
||||
result, err := r.Inst.HandleDocumentApproved(ctx, doc.ProjectID, doc.TaskKey)
|
||||
if err != nil || result == nil || !result.StageReady {
|
||||
return
|
||||
}
|
||||
if r.Notify == nil || !r.Notify.Available() {
|
||||
return
|
||||
}
|
||||
if r.PW == nil {
|
||||
return
|
||||
}
|
||||
project, err := r.PW.GetProject(ctx, doc.ProjectID, 0)
|
||||
if err != nil || project == nil || project.CreatorUserID <= 0 {
|
||||
return
|
||||
}
|
||||
_, _ = r.Notify.EmitStageReadyToAdvance(ctx, project.CreatorUserID, notification.StageReadyPayload{
|
||||
ProjectID: doc.ProjectID,
|
||||
WorkflowInstanceID: result.InstanceID,
|
||||
StageInstanceID: result.StageInstanceID,
|
||||
StageKey: result.StageKey,
|
||||
StageName: result.StageName,
|
||||
ConfigRevision: result.ConfigRevision,
|
||||
RequiredDocCount: result.RequiredDocCount,
|
||||
ApprovedDocCount: result.ApprovedDocCount,
|
||||
})
|
||||
}
|
||||
|
||||
// Start launches background workers. Repeat calls on a running runtime are no-ops.
|
||||
func (r *Runtime) Start(_ context.Context) error {
|
||||
if r == nil {
|
||||
return fmt.Errorf("app: nil runtime")
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.running {
|
||||
return nil
|
||||
}
|
||||
r.bindDocumentNotify()
|
||||
r.bindWorkflowApprovalHook()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
r.reminderCancel = cancel
|
||||
go r.runReminderLoop(ctx)
|
||||
r.running = true
|
||||
r.starts++
|
||||
if r.subscribers == 0 {
|
||||
r.subscribers = 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop cancels workers. A later Start recreates the reminder loop without
|
||||
// double-registering document subscribers.
|
||||
func (r *Runtime) Stop(_ context.Context) error {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if !r.running {
|
||||
return nil
|
||||
}
|
||||
if r.reminderCancel != nil {
|
||||
r.reminderCancel()
|
||||
r.reminderCancel = nil
|
||||
}
|
||||
r.running = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runtime) SubscriberCount() int {
|
||||
if r == nil {
|
||||
return 0
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.subscribers
|
||||
}
|
||||
|
||||
func (r *Runtime) StartCount() int {
|
||||
if r == nil {
|
||||
return 0
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.starts
|
||||
}
|
||||
|
||||
func (r *Runtime) Running() bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.running
|
||||
}
|
||||
|
||||
func (r *Runtime) runReminderLoop(ctx context.Context) {
|
||||
r.runStageAdvanceReminders(ctx)
|
||||
ticker := time.NewTicker(defaultReminderPollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
r.runStageAdvanceReminders(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runtime) runStageAdvanceReminders(ctx context.Context) {
|
||||
if r.Notify == nil || !r.Notify.Available() {
|
||||
return
|
||||
}
|
||||
n, err := r.Notify.ProcessStageAdvanceReminders(ctx)
|
||||
if err != nil {
|
||||
slog.Warn("stage advance reminder scan failed", "error", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
slog.Info("stage advance 24h reminders sent", "count", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestStartStopRestartIdempotent(t *testing.T) {
|
||||
rt := &Runtime{}
|
||||
ctx := context.Background()
|
||||
if err := rt.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := rt.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rt.StartCount() != 1 {
|
||||
t.Fatalf("duplicate Start spawned workers: starts=%d", rt.StartCount())
|
||||
}
|
||||
if got := rt.SubscriberCount(); got != 1 {
|
||||
t.Fatalf("subscribers=%d", got)
|
||||
}
|
||||
if !rt.Running() {
|
||||
t.Fatal("expected running")
|
||||
}
|
||||
if err := rt.Stop(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rt.Running() {
|
||||
t.Fatal("expected stopped")
|
||||
}
|
||||
if err := rt.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rt.StartCount() != 2 {
|
||||
t.Fatalf("restart did not relaunch worker: starts=%d", rt.StartCount())
|
||||
}
|
||||
if rt.SubscriberCount() != 1 {
|
||||
t.Fatalf("restart re-registered subscribers: %d", rt.SubscriberCount())
|
||||
}
|
||||
_ = rt.Stop(ctx)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
@@ -70,16 +70,22 @@ func (a *Assembler) Assemble(ctx context.Context, p Principal, req AssembleReque
|
||||
if restored == nil {
|
||||
return userCtx, nil
|
||||
}
|
||||
if restored.ProjectID > 0 && userCtx.Project.ID == "" {
|
||||
userCtx.Project.ID = fmt.Sprintf("%d", restored.ProjectID)
|
||||
upd := coordinator.ContextUpdate{
|
||||
ExpectedVersion: userCtx.Version,
|
||||
BlackboardFill: restored.Facts,
|
||||
SetRecentDialogue: true,
|
||||
RecentDialogue: authorizedTurns(restored),
|
||||
}
|
||||
if userCtx.BlackboardFacts == nil {
|
||||
userCtx.BlackboardFacts = make(map[string]string)
|
||||
if restored.ProjectID > 0 {
|
||||
upd.ProjectIDIfEmpty = fmt.Sprintf("%d", restored.ProjectID)
|
||||
}
|
||||
for k, v := range restored.Facts {
|
||||
if _, exists := userCtx.BlackboardFacts[k]; !exists {
|
||||
userCtx.BlackboardFacts[k] = v
|
||||
}
|
||||
_ = coordinator.ApplyContextUpdate(userCtx, upd)
|
||||
return userCtx, nil
|
||||
}
|
||||
|
||||
func authorizedTurns(restored *ctxmgr.RestoredSessionContext) []coordinator.DialogueTurn {
|
||||
if restored == nil {
|
||||
return nil
|
||||
}
|
||||
turns := make([]coordinator.DialogueTurn, 0, len(restored.Messages))
|
||||
records := make([]ctxmgr.ChatMessageRecord, 0, len(restored.Messages))
|
||||
@@ -101,6 +107,5 @@ func (a *Assembler) Assemble(ctx context.Context, p Principal, req AssembleReque
|
||||
turns = append(turns, coordinator.DialogueTurn{Role: m.Role, Content: m.Content})
|
||||
}
|
||||
}
|
||||
userCtx.RecentDialogue = turns
|
||||
return userCtx, nil
|
||||
return turns
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package docnotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/docrevision"
|
||||
"chat-agent-system/internal/notification"
|
||||
)
|
||||
|
||||
// DocumentLookup resolves a document for recipient and project fill-in.
|
||||
type DocumentLookup interface {
|
||||
GetDocument(ctx context.Context, docID string) (*docgov.DocumentRecord, error)
|
||||
}
|
||||
|
||||
// Subscriber is the unique consumer for document OnOutbox notification events.
|
||||
// Bind once from the app composition root; do not assign OnOutbox in server.
|
||||
type Subscriber struct {
|
||||
Notify notificationEmitter
|
||||
Docs DocumentLookup
|
||||
}
|
||||
|
||||
type notificationEmitter interface {
|
||||
Available() bool
|
||||
EmitDocumentEvent(ctx context.Context, eventType, notifType, title, body, dedupeKey string, recipientUserID int64, payload notification.DocumentEventPayload) (bool, error)
|
||||
}
|
||||
|
||||
// Bind assigns the subscriber exactly once. Repeat calls keep the first handler.
|
||||
func Bind(rev *docrevision.Service, sub *Subscriber) bool {
|
||||
if rev == nil || sub == nil || rev.OnOutbox != nil {
|
||||
return false
|
||||
}
|
||||
rev.OnOutbox = sub.Handle
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle maps one document outbox event onto a permission-safe inbox notification.
|
||||
func (s *Subscriber) Handle(ctx context.Context, eventType string, payload map[string]any) error {
|
||||
if s == nil || s.Notify == nil || !s.Notify.Available() {
|
||||
return nil
|
||||
}
|
||||
docID, _ := payload["doc_id"].(string)
|
||||
projectID, _ := payload["project_id"].(int64)
|
||||
risk, _ := payload["risk"].(string)
|
||||
var recipient int64
|
||||
if s.Docs != nil && docID != "" {
|
||||
if doc, err := s.Docs.GetDocument(ctx, docID); err == nil && doc != nil && doc.AuthorID > 0 {
|
||||
recipient = doc.AuthorID
|
||||
projectID = doc.ProjectID
|
||||
}
|
||||
}
|
||||
if recipient == 0 {
|
||||
return nil
|
||||
}
|
||||
p := notification.DocumentEventPayload{
|
||||
ProjectID: projectID, DocID: docID, RiskLevel: risk,
|
||||
}
|
||||
switch eventType {
|
||||
case notification.EventDocAgentCandidateReady:
|
||||
if v, ok := payload["candidate_id"].(int64); ok {
|
||||
p.CandidateID = v
|
||||
}
|
||||
_, err := s.Notify.EmitDocumentEvent(ctx, eventType, notification.TypeDocAgentCandidateReady,
|
||||
"智能体变更待确认", "文档有高风险智能体候选变更,请确认后生效。",
|
||||
fmt.Sprintf("doc_candidate:%s:%d:%d", docID, p.CandidateID, recipient), recipient, p)
|
||||
return err
|
||||
case notification.EventDocAgentVersionCommitted:
|
||||
if v, ok := payload["version_no"].(int); ok {
|
||||
p.VersionNo = v
|
||||
}
|
||||
_, err := s.Notify.EmitDocumentEvent(ctx, eventType, notification.TypeDocAgentVersionCommitted,
|
||||
"智能体版本已生效", "新的智能体文档版本已提交。",
|
||||
fmt.Sprintf("doc_agent_version:%s:%d:%d", docID, p.VersionNo, recipient), recipient, p)
|
||||
return err
|
||||
case notification.EventDocDownstreamImpact:
|
||||
impacted, _ := payload["impacted_doc_id"].(string)
|
||||
p.ImpactedDocID = impacted
|
||||
_, err := s.Notify.EmitDocumentEvent(ctx, eventType, notification.TypeDocDownstreamImpact,
|
||||
"下游文档受影响", "上游文档变更,请复核关联文档。",
|
||||
fmt.Sprintf("doc_impact:%s:%s:%d", docID, impacted, recipient), recipient, p)
|
||||
return err
|
||||
case notification.EventDocChangeReportDegraded:
|
||||
if v, ok := payload["report_id"].(int64); ok {
|
||||
p.ReportID = v
|
||||
}
|
||||
_, err := s.Notify.EmitDocumentEvent(ctx, eventType, notification.TypeDocChangeReportDegraded,
|
||||
"变更报告未完整评估", "变更报告降级,受控文档暂不可送审。",
|
||||
fmt.Sprintf("doc_report_degraded:%s:%d:%d", docID, p.ReportID, recipient), recipient, p)
|
||||
return err
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package docnotify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/docrevision"
|
||||
"chat-agent-system/internal/notification"
|
||||
)
|
||||
|
||||
type stubNotify struct {
|
||||
available bool
|
||||
calls int
|
||||
lastType string
|
||||
}
|
||||
|
||||
func (s *stubNotify) Available() bool { return s != nil && s.available }
|
||||
|
||||
func (s *stubNotify) EmitDocumentEvent(_ context.Context, eventType, _, _, _, _ string, _ int64, _ notification.DocumentEventPayload) (bool, error) {
|
||||
s.calls++
|
||||
s.lastType = eventType
|
||||
return true, nil
|
||||
}
|
||||
|
||||
type stubDocs struct {
|
||||
doc *docgov.DocumentRecord
|
||||
}
|
||||
|
||||
func (s stubDocs) GetDocument(_ context.Context, _ string) (*docgov.DocumentRecord, error) {
|
||||
return s.doc, nil
|
||||
}
|
||||
|
||||
func TestBindOnce(t *testing.T) {
|
||||
rev := &docrevision.Service{}
|
||||
sub := &Subscriber{Notify: &stubNotify{available: true}}
|
||||
if !Bind(rev, sub) {
|
||||
t.Fatal("first bind")
|
||||
}
|
||||
if Bind(rev, sub) {
|
||||
t.Fatal("second bind must be a no-op")
|
||||
}
|
||||
if rev.OnOutbox == nil {
|
||||
t.Fatal("hook missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCandidateReady(t *testing.T) {
|
||||
n := &stubNotify{available: true}
|
||||
sub := &Subscriber{
|
||||
Notify: n,
|
||||
Docs: stubDocs{doc: &docgov.DocumentRecord{AuthorID: 9, ProjectID: 4}},
|
||||
}
|
||||
if err := sub.Handle(context.Background(), notification.EventDocAgentCandidateReady, map[string]any{
|
||||
"doc_id": "d1", "candidate_id": int64(3),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n.calls != 1 || n.lastType != notification.EventDocAgentCandidateReady {
|
||||
t.Fatalf("calls=%d type=%s", n.calls, n.lastType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSkipsUnknownAndNoRecipient(t *testing.T) {
|
||||
n := &stubNotify{available: true}
|
||||
sub := &Subscriber{Notify: n}
|
||||
if err := sub.Handle(context.Background(), "document.unknown", map[string]any{"doc_id": "d1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n.calls != 0 {
|
||||
t.Fatalf("unknown event must not emit, calls=%d", n.calls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package stageflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"chat-agent-system/internal/bizdev"
|
||||
)
|
||||
|
||||
var ErrClosed = errors.New("stageflow: executor not configured")
|
||||
|
||||
// Executor is the unique application owner for task and stage runs.
|
||||
// HTTP, CLI and coordinator must call this instead of bizdev directly.
|
||||
type Executor interface {
|
||||
ExecuteTask(ctx context.Context, req bizdev.TaskRunRequest) (*bizdev.TaskRunResult, error)
|
||||
ExecuteStage(ctx context.Context, req bizdev.StageRunRequest) (*bizdev.StageRunResult, error)
|
||||
}
|
||||
|
||||
// UseCase adapts bizdev orchestration without moving domain tables.
|
||||
type UseCase struct {
|
||||
Biz Executor
|
||||
}
|
||||
|
||||
func New(biz Executor) *UseCase {
|
||||
return &UseCase{Biz: biz}
|
||||
}
|
||||
|
||||
func (u *UseCase) ExecuteTask(ctx context.Context, req bizdev.TaskRunRequest) (*bizdev.TaskRunResult, error) {
|
||||
if u == nil || u.Biz == nil {
|
||||
return nil, ErrClosed
|
||||
}
|
||||
return u.Biz.ExecuteTask(ctx, req)
|
||||
}
|
||||
|
||||
func (u *UseCase) ExecuteStage(ctx context.Context, req bizdev.StageRunRequest) (*bizdev.StageRunResult, error) {
|
||||
if u == nil || u.Biz == nil {
|
||||
return nil, ErrClosed
|
||||
}
|
||||
return u.Biz.ExecuteStage(ctx, req)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package stageflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/bizdev"
|
||||
)
|
||||
|
||||
type stubBiz struct {
|
||||
tasks int
|
||||
stages int
|
||||
}
|
||||
|
||||
func (s *stubBiz) ExecuteTask(_ context.Context, req bizdev.TaskRunRequest) (*bizdev.TaskRunResult, error) {
|
||||
s.tasks++
|
||||
return &bizdev.TaskRunResult{Success: true, ArtifactTitle: req.TaskKey}, nil
|
||||
}
|
||||
|
||||
func (s *stubBiz) ExecuteStage(_ context.Context, req bizdev.StageRunRequest) (*bizdev.StageRunResult, error) {
|
||||
s.stages++
|
||||
return &bizdev.StageRunResult{Success: true, StageTitle: string(req.Stage)}, nil
|
||||
}
|
||||
|
||||
func TestUseCaseDelegates(t *testing.T) {
|
||||
biz := &stubBiz{}
|
||||
httpUC := New(biz)
|
||||
cliUC := New(biz)
|
||||
if _, err := httpUC.ExecuteTask(context.Background(), bizdev.TaskRunRequest{TaskKey: "intake"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := cliUC.ExecuteStage(context.Background(), bizdev.StageRunRequest{Stage: bizdev.StageV01_Triage}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if biz.tasks != 1 || biz.stages != 1 {
|
||||
t.Fatalf("shared executor counts task=%d stage=%d", biz.tasks, biz.stages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUseCaseClosed(t *testing.T) {
|
||||
var u *UseCase
|
||||
if _, err := u.ExecuteTask(context.Background(), bizdev.TaskRunRequest{}); !errors.Is(err, ErrClosed) {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,25 @@ func TestArchitectureBoundaries(t *testing.T) {
|
||||
if ev.Failed() {
|
||||
t.Fatal(ev.Report())
|
||||
}
|
||||
if os.Getenv("ARCHTEST_STRICT") != "" && len(ev.Excepted) > 0 {
|
||||
t.Fatalf("strict/block mode: %d baseline exceptions remain\n%s", len(ev.Excepted), ev.Report())
|
||||
if os.Getenv("ARCHTEST_STRICT") == "" {
|
||||
return
|
||||
}
|
||||
if len(b.BlockKinds) == 0 {
|
||||
if len(ev.Excepted) > 0 {
|
||||
t.Fatalf("strict/block mode: %d baseline exceptions remain\n%s", len(ev.Excepted), ev.Report())
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, f := range ev.Excepted {
|
||||
for _, kind := range b.BlockKinds {
|
||||
if f.Kind == kind {
|
||||
t.Fatalf("strict/block mode: cleared kind %s still has exception %s\n%s", kind, f.Key(), ev.Report())
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, kind := range b.BlockKinds {
|
||||
if ev.Counts[kind] > 0 {
|
||||
t.Fatalf("strict/block mode: cleared kind %s count=%d\n%s", kind, ev.Counts[kind], ev.Report())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ type Boundaries struct {
|
||||
UnwrapOwners []string `yaml:"unwrap_owners"`
|
||||
Exceptions []Exception `yaml:"exceptions"`
|
||||
BaselineCounts map[string]int `yaml:"baseline_counts"`
|
||||
BlockKinds []string `yaml:"block_kinds"`
|
||||
}
|
||||
|
||||
type Exception struct {
|
||||
|
||||
@@ -81,7 +81,11 @@ func NewSession(id string, userCtx *coordinator.UserProjectContext, rec intent.R
|
||||
}
|
||||
// 若上下文尚未挂起预期且当前已具有阶段,则初始化默认选项草稿
|
||||
if userCtx != nil && userCtx.PendingExpectation == nil && userCtx.Project.CurrentStage != "" {
|
||||
userCtx.PendingExpectation = coordinator.BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name)
|
||||
_ = coordinator.ApplyContextUpdate(userCtx, coordinator.ContextUpdate{
|
||||
ExpectedVersion: userCtx.Version,
|
||||
SetPendingExpectation: true,
|
||||
PendingExpectation: coordinator.BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name),
|
||||
})
|
||||
}
|
||||
|
||||
sess := &Session{
|
||||
@@ -115,7 +119,11 @@ func (s *Session) SyncRecentDialogue() {
|
||||
}
|
||||
turns = append(turns, coordinator.DialogueTurn{Role: m.Role, Content: m.Content})
|
||||
}
|
||||
s.UserCtx.RecentDialogue = turns
|
||||
_ = coordinator.ApplyContextUpdate(s.UserCtx, coordinator.ContextUpdate{
|
||||
ExpectedVersion: s.UserCtx.Version,
|
||||
SetRecentDialogue: true,
|
||||
RecentDialogue: turns,
|
||||
})
|
||||
|
||||
// 若发生了截断且存在早期消息,同步计算或加载滚动摘要
|
||||
if truncated && len(early) > 0 && s.CtxMgr != nil {
|
||||
@@ -416,11 +424,17 @@ func (s *Session) sendMessageInternal(ctx context.Context, userInput string, str
|
||||
}
|
||||
|
||||
if coordRes.ClearExpectation {
|
||||
s.UserCtx.PendingExpectation = nil
|
||||
_ = coordinator.ApplyContextUpdate(s.UserCtx, coordinator.ContextUpdate{
|
||||
ExpectedVersion: s.UserCtx.Version, SetPendingExpectation: true,
|
||||
})
|
||||
} else if coordRes.NextExpectation != nil {
|
||||
s.UserCtx.PendingExpectation = coordRes.NextExpectation
|
||||
_ = coordinator.ApplyContextUpdate(s.UserCtx, coordinator.ContextUpdate{
|
||||
ExpectedVersion: s.UserCtx.Version, SetPendingExpectation: true, PendingExpectation: coordRes.NextExpectation,
|
||||
})
|
||||
} else if recRes.Branch == intent.BranchExpectationHit {
|
||||
s.UserCtx.PendingExpectation = nil
|
||||
_ = coordinator.ApplyContextUpdate(s.UserCtx, coordinator.ContextUpdate{
|
||||
ExpectedVersion: s.UserCtx.Version, SetPendingExpectation: true,
|
||||
})
|
||||
}
|
||||
// 若为 BranchNewIntentPreempt 且未产生新预期,原挂起预期在焦点底静默保留,不阻拦用户
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/bizdev"
|
||||
@@ -76,6 +77,8 @@ type DocumentSummaryReader func(ctx context.Context, target string) (*ReadableDo
|
||||
|
||||
// UserProjectContext 用户项目级全量统一上下文 (对话系统与执行系统共同消费)
|
||||
type UserProjectContext struct {
|
||||
mu sync.Mutex
|
||||
Version int64 `json:"version"`
|
||||
SnapshotID string `json:"snapshot_id"`
|
||||
AssembledAt time.Time `json:"assembled_at"`
|
||||
User UserInfo `json:"user"`
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package coordinator
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"chat-agent-system/internal/intent"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNilContext = errors.New("coordinator: nil user project context")
|
||||
ErrStaleContext = errors.New("coordinator: stale context version")
|
||||
ErrInvalidUpdate = errors.New("coordinator: empty context update")
|
||||
)
|
||||
|
||||
// ContextUpdate is the only typed write against UserProjectContext.
|
||||
// Callers must supply ExpectedVersion from a prior read; a mismatch fails closed.
|
||||
type ContextUpdate struct {
|
||||
ExpectedVersion int64
|
||||
|
||||
BlackboardFill map[string]string
|
||||
BlackboardSet map[string]string
|
||||
|
||||
SetPendingExpectation bool
|
||||
PendingExpectation *intent.PendingExpectation
|
||||
ExpirePending bool
|
||||
|
||||
SetInteractiveCard bool
|
||||
InteractiveCard *intent.InteractiveCardPayload
|
||||
TickInteractive bool
|
||||
DismissCardStatus string // cancelled | resolved | expired; then nil
|
||||
|
||||
SetPersonalizedGuidance bool
|
||||
PersonalizedGuidance string
|
||||
|
||||
SetRecentDialogue bool
|
||||
RecentDialogue []DialogueTurn
|
||||
|
||||
ProjectIDIfEmpty string
|
||||
}
|
||||
|
||||
// ApplyContextUpdate applies one versioned patch. Concurrent writers with the
|
||||
// same ExpectedVersion: exactly one succeeds, the rest get ErrStaleContext.
|
||||
func ApplyContextUpdate(ctx *UserProjectContext, u ContextUpdate) error {
|
||||
if ctx == nil {
|
||||
return ErrNilContext
|
||||
}
|
||||
ctx.mu.Lock()
|
||||
defer ctx.mu.Unlock()
|
||||
if u.ExpectedVersion != ctx.Version {
|
||||
return ErrStaleContext
|
||||
}
|
||||
applied := false
|
||||
if len(u.BlackboardFill) > 0 {
|
||||
if ctx.BlackboardFacts == nil {
|
||||
ctx.BlackboardFacts = make(map[string]string, len(u.BlackboardFill))
|
||||
}
|
||||
for k, v := range u.BlackboardFill {
|
||||
if _, exists := ctx.BlackboardFacts[k]; !exists {
|
||||
ctx.BlackboardFacts[k] = v
|
||||
}
|
||||
}
|
||||
applied = true
|
||||
}
|
||||
if len(u.BlackboardSet) > 0 {
|
||||
if ctx.BlackboardFacts == nil {
|
||||
ctx.BlackboardFacts = make(map[string]string, len(u.BlackboardSet))
|
||||
}
|
||||
for k, v := range u.BlackboardSet {
|
||||
ctx.BlackboardFacts[k] = v
|
||||
}
|
||||
applied = true
|
||||
}
|
||||
if u.ExpirePending && ctx.PendingExpectation != nil && !ctx.PendingExpectation.ExpiresAt.IsZero() {
|
||||
// caller decides freshness; apply just clears
|
||||
ctx.PendingExpectation = nil
|
||||
applied = true
|
||||
}
|
||||
if u.SetPendingExpectation {
|
||||
ctx.PendingExpectation = u.PendingExpectation
|
||||
applied = true
|
||||
}
|
||||
if u.TickInteractive && ctx.InteractiveCard != nil {
|
||||
ctx.InteractiveCard.TTLTurns--
|
||||
if ctx.InteractiveCard.TTLTurns <= 0 || ctx.InteractiveCard.CurrentStatus != "active" {
|
||||
ctx.InteractiveCard.CurrentStatus = "expired"
|
||||
ctx.InteractiveCard = nil
|
||||
}
|
||||
applied = true
|
||||
}
|
||||
if u.DismissCardStatus != "" && ctx.InteractiveCard != nil {
|
||||
ctx.InteractiveCard.CurrentStatus = u.DismissCardStatus
|
||||
ctx.InteractiveCard = nil
|
||||
applied = true
|
||||
}
|
||||
if u.SetInteractiveCard {
|
||||
ctx.InteractiveCard = u.InteractiveCard
|
||||
applied = true
|
||||
}
|
||||
if u.SetPersonalizedGuidance {
|
||||
ctx.PersonalizedGuidance = u.PersonalizedGuidance
|
||||
applied = true
|
||||
}
|
||||
if u.SetRecentDialogue {
|
||||
ctx.RecentDialogue = u.RecentDialogue
|
||||
applied = true
|
||||
}
|
||||
if u.ProjectIDIfEmpty != "" && ctx.Project.ID == "" {
|
||||
ctx.Project.ID = u.ProjectIDIfEmpty
|
||||
applied = true
|
||||
}
|
||||
if !applied {
|
||||
return ErrInvalidUpdate
|
||||
}
|
||||
ctx.Version++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *UserProjectContext) applyPending(exp *intent.PendingExpectation) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
_ = ApplyContextUpdate(c, ContextUpdate{ExpectedVersion: c.Version, SetPendingExpectation: true, PendingExpectation: exp})
|
||||
}
|
||||
|
||||
func (c *UserProjectContext) applyCard(card *intent.InteractiveCardPayload) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
_ = ApplyContextUpdate(c, ContextUpdate{ExpectedVersion: c.Version, SetInteractiveCard: true, InteractiveCard: card})
|
||||
}
|
||||
|
||||
func (c *UserProjectContext) dismissCard(status string) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
_ = ApplyContextUpdate(c, ContextUpdate{ExpectedVersion: c.Version, DismissCardStatus: status})
|
||||
}
|
||||
|
||||
func (c *UserProjectContext) applyBlackboard(key, value string) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
_ = ApplyContextUpdate(c, ContextUpdate{ExpectedVersion: c.Version, BlackboardSet: map[string]string{key: value}})
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package coordinator
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestContextUpdateConcurrentNoLostUpdate(t *testing.T) {
|
||||
ctx := &UserProjectContext{
|
||||
Version: 1,
|
||||
BlackboardFacts: map[string]string{"market": "国内"},
|
||||
}
|
||||
var ok, stale int32
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func(label string) {
|
||||
defer wg.Done()
|
||||
err := ApplyContextUpdate(ctx, ContextUpdate{
|
||||
ExpectedVersion: 1,
|
||||
BlackboardSet: map[string]string{"market": label},
|
||||
})
|
||||
switch {
|
||||
case err == nil:
|
||||
atomic.AddInt32(&ok, 1)
|
||||
case errors.Is(err, ErrStaleContext):
|
||||
atomic.AddInt32(&stale, 1)
|
||||
default:
|
||||
t.Errorf("unexpected %v", err)
|
||||
}
|
||||
}("v" + string(rune('a'+i)))
|
||||
}
|
||||
wg.Wait()
|
||||
if ok != 1 || stale != 1 {
|
||||
t.Fatalf("ok=%d stale=%d version=%d facts=%v", ok, stale, ctx.Version, ctx.BlackboardFacts)
|
||||
}
|
||||
if ctx.Version != 2 {
|
||||
t.Fatalf("version %d", ctx.Version)
|
||||
}
|
||||
if ctx.BlackboardFacts["market"] == "国内" {
|
||||
t.Fatal("winner did not apply")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextUpdateFillDoesNotOverwrite(t *testing.T) {
|
||||
ctx := &UserProjectContext{Version: 0, BlackboardFacts: map[string]string{"a": "1"}}
|
||||
if err := ApplyContextUpdate(ctx, ContextUpdate{ExpectedVersion: 0, BlackboardFill: map[string]string{"a": "x", "b": "2"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx.BlackboardFacts["a"] != "1" || ctx.BlackboardFacts["b"] != "2" {
|
||||
t.Fatalf("%v", ctx.BlackboardFacts)
|
||||
}
|
||||
}
|
||||
@@ -73,12 +73,36 @@ type CoordinationResult struct {
|
||||
// CoordinatorEngine L2 协调引擎
|
||||
type CoordinatorEngine struct {
|
||||
bizdevSvc bizdev.BizdevService
|
||||
stageTasks StageTaskRunner
|
||||
instSvc *projectinstance.Service
|
||||
assembler *ContextAssembler
|
||||
dialogueBackend agent.Backend
|
||||
dataDir string
|
||||
}
|
||||
|
||||
// StageTaskRunner is the application owner for ExecuteTask / ExecuteStage.
|
||||
// Wired from app/stageflow; tests may leave it nil and fall back to bizdevSvc.
|
||||
type StageTaskRunner interface {
|
||||
ExecuteTask(ctx context.Context, req bizdev.TaskRunRequest) (*bizdev.TaskRunResult, error)
|
||||
ExecuteStage(ctx context.Context, req bizdev.StageRunRequest) (*bizdev.StageRunResult, error)
|
||||
}
|
||||
|
||||
func (e *CoordinatorEngine) SetStageTaskRunner(r StageTaskRunner) {
|
||||
if e != nil {
|
||||
e.stageTasks = r
|
||||
}
|
||||
}
|
||||
|
||||
func (e *CoordinatorEngine) tasks() StageTaskRunner {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
if e.stageTasks != nil {
|
||||
return e.stageTasks
|
||||
}
|
||||
return e.bizdevSvc
|
||||
}
|
||||
|
||||
// SetInstanceService 注入项目工作流实例服务(对话推进与实例门禁合流)。
|
||||
func (e *CoordinatorEngine) SetInstanceService(svc *projectinstance.Service) {
|
||||
if e != nil {
|
||||
@@ -118,14 +142,10 @@ func (e *CoordinatorEngine) ProcessIntent(
|
||||
|
||||
// 交互卡片 TTL 轮次消退与生命周期清理
|
||||
if userCtx.InteractiveCard != nil {
|
||||
userCtx.InteractiveCard.TTLTurns--
|
||||
if userCtx.InteractiveCard.TTLTurns <= 0 || userCtx.InteractiveCard.CurrentStatus != "active" {
|
||||
userCtx.InteractiveCard.CurrentStatus = "expired"
|
||||
userCtx.InteractiveCard = nil
|
||||
}
|
||||
_ = ApplyContextUpdate(userCtx, ContextUpdate{ExpectedVersion: userCtx.Version, TickInteractive: true})
|
||||
}
|
||||
if userCtx.PendingExpectation != nil && !userCtx.PendingExpectation.ExpiresAt.IsZero() && time.Now().After(userCtx.PendingExpectation.ExpiresAt) {
|
||||
userCtx.PendingExpectation = nil
|
||||
userCtx.applyPending(nil)
|
||||
}
|
||||
|
||||
top := recRes.TopIntent
|
||||
@@ -355,10 +375,9 @@ func (e *CoordinatorEngine) handleDeclineExpectation(userCtx *UserProjectContext
|
||||
if projName == "" {
|
||||
projName = "当前项目"
|
||||
}
|
||||
userCtx.PendingExpectation = nil
|
||||
userCtx.applyPending(nil)
|
||||
if userCtx.InteractiveCard != nil {
|
||||
userCtx.InteractiveCard.CurrentStatus = "cancelled"
|
||||
userCtx.InteractiveCard = nil
|
||||
userCtx.dismissCard("cancelled")
|
||||
}
|
||||
|
||||
reply := fmt.Sprintf("收到,听您的!那我们先不急着推进。\n针对「%s」,您手头还有哪些细节、技术顾虑或商务考量想先聊聊?我随时待命为您解答。", projName)
|
||||
@@ -418,7 +437,7 @@ func (e *CoordinatorEngine) handleAmbiguousConsult(ctx context.Context, cand *in
|
||||
nextExp := BuildNextExpectation(currentStage, projName)
|
||||
// 路由层:复杂自由任务画像命中时,先给即时初答,再附升级建议(复用挂起期望机制,含每会话冷却)
|
||||
reply, nextExp = maybeAttachUpgradeHint(rawInput, userCtx, reply, nextExp)
|
||||
userCtx.PendingExpectation = nextExp
|
||||
userCtx.applyPending(nextExp)
|
||||
|
||||
return &CoordinationResult{
|
||||
Status: StatusAccepted,
|
||||
@@ -436,7 +455,7 @@ func (e *CoordinatorEngine) handleQueryProgress(ctx context.Context, cand *inten
|
||||
brief := userCtx.LatestAdvisorBrief
|
||||
topic := cand.FilledSlots[intent.SlotQueryTopic]
|
||||
nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name)
|
||||
userCtx.PendingExpectation = nextExp
|
||||
userCtx.applyPending(nextExp)
|
||||
|
||||
// 1. 若为测试方案/难点/验证咨询,直接调取测试经理与参谋专业知识库解答
|
||||
if topic == "testing_analysis" {
|
||||
@@ -624,7 +643,7 @@ func (e *CoordinatorEngine) handleSupplementInfo(cand *intent.CandidateIntent, u
|
||||
|
||||
summaryKey := ""
|
||||
if factKey != "" && factVal != "" {
|
||||
userCtx.BlackboardFacts[factKey] = factVal
|
||||
userCtx.applyBlackboard(factKey, factVal)
|
||||
summaryKey = fmt.Sprintf("「%s」已更新为「%s」", factKey, factVal)
|
||||
if factKey == "target_market" {
|
||||
userCtx.Project.TargetMarket = factVal
|
||||
@@ -657,7 +676,7 @@ func (e *CoordinatorEngine) handleSupplementInfo(cand *intent.CandidateIntent, u
|
||||
}
|
||||
|
||||
nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name)
|
||||
userCtx.PendingExpectation = nextExp
|
||||
userCtx.applyPending(nextExp)
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("已收到并为您记录最新信息:%s。\n", summaryKey))
|
||||
@@ -757,7 +776,7 @@ func (e *CoordinatorEngine) handleAdvanceStage(ctx context.Context, cand *intent
|
||||
if len(cand.PatchSlots) > 0 {
|
||||
var patches []string
|
||||
for k, v := range cand.PatchSlots {
|
||||
userCtx.BlackboardFacts[k] = v
|
||||
userCtx.applyBlackboard(k, v)
|
||||
patches = append(patches, fmt.Sprintf("%s=%s", k, v))
|
||||
if k == "target_market" {
|
||||
userCtx.Project.TargetMarket = v
|
||||
@@ -795,11 +814,11 @@ func (e *CoordinatorEngine) handleAdvanceStage(ctx context.Context, cand *intent
|
||||
}, nil
|
||||
}
|
||||
|
||||
if e.bizdevSvc == nil {
|
||||
if e.tasks() == nil {
|
||||
userCtx.Project.CurrentStage = targetStage
|
||||
userCtx.AvailableActions = ComputeAvailableActions(targetStage, userCtx.User.ProjectRole)
|
||||
nextExp := BuildNextExpectation(targetStage, userCtx.Project.Name)
|
||||
userCtx.PendingExpectation = nextExp
|
||||
userCtx.applyPending(nextExp)
|
||||
|
||||
reply := fmt.Sprintf("已为您模拟推进到阶段【%s】。", targetStage)
|
||||
if patchSummary != "" {
|
||||
@@ -816,7 +835,7 @@ func (e *CoordinatorEngine) handleAdvanceStage(ctx context.Context, cand *intent
|
||||
}
|
||||
|
||||
// 3. 调用底层 L3 调度器执行阶段跃迁
|
||||
stageRes, err := e.bizdevSvc.ExecuteStage(ctx, bizdev.StageRunRequest{
|
||||
stageRes, err := e.tasks().ExecuteStage(ctx, bizdev.StageRunRequest{
|
||||
Stage: targetStage,
|
||||
ProjectName: userCtx.Project.Name,
|
||||
CustomerName: userCtx.Project.CustomerName,
|
||||
@@ -850,7 +869,7 @@ func (e *CoordinatorEngine) handleAdvanceStage(ctx context.Context, cand *intent
|
||||
}
|
||||
|
||||
nextExp := BuildNextExpectation(targetStage, userCtx.Project.Name)
|
||||
userCtx.PendingExpectation = nextExp
|
||||
userCtx.applyPending(nextExp)
|
||||
|
||||
var sb strings.Builder
|
||||
if patchSummary != "" {
|
||||
@@ -889,9 +908,9 @@ func (e *CoordinatorEngine) handleTriggerTask(ctx context.Context, cand *intent.
|
||||
}
|
||||
|
||||
nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name)
|
||||
userCtx.PendingExpectation = nextExp
|
||||
userCtx.applyPending(nextExp)
|
||||
|
||||
if e.bizdevSvc == nil {
|
||||
if e.tasks() == nil {
|
||||
return &CoordinationResult{
|
||||
Status: StatusAccepted,
|
||||
Intent: cand.Intent,
|
||||
@@ -910,7 +929,7 @@ func (e *CoordinatorEngine) handleTriggerTask(ctx context.Context, cand *intent.
|
||||
fmt.Sscanf(userCtx.User.ID, "%d", &userID)
|
||||
}
|
||||
|
||||
taskRes, err := e.bizdevSvc.ExecuteTask(ctx, bizdev.TaskRunRequest{
|
||||
taskRes, err := e.tasks().ExecuteTask(ctx, bizdev.TaskRunRequest{
|
||||
TaskKey: taskKey,
|
||||
ProjectName: userCtx.Project.Name,
|
||||
CustomerName: userCtx.Project.CustomerName,
|
||||
@@ -1026,9 +1045,9 @@ func (e *CoordinatorEngine) handleTriggerAdhocTask(ctx context.Context, cand *in
|
||||
taskKey := sanitizeAdhocTaskKey(title)
|
||||
|
||||
nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name)
|
||||
userCtx.PendingExpectation = nextExp
|
||||
userCtx.applyPending(nextExp)
|
||||
|
||||
if e.bizdevSvc == nil {
|
||||
if e.tasks() == nil {
|
||||
return &CoordinationResult{
|
||||
Status: StatusAccepted,
|
||||
Intent: cand.Intent,
|
||||
@@ -1058,7 +1077,7 @@ func (e *CoordinatorEngine) handleTriggerAdhocTask(ctx context.Context, cand *in
|
||||
Content: []byte(fmt.Sprintf("# 任务简报(用户原始诉求,权威等级:primary_evidence)\n\n%s\n", brief)),
|
||||
}
|
||||
|
||||
taskRes, err := e.bizdevSvc.ExecuteTask(ctx, bizdev.TaskRunRequest{
|
||||
taskRes, err := e.tasks().ExecuteTask(ctx, bizdev.TaskRunRequest{
|
||||
TaskKey: taskKey,
|
||||
Title: title,
|
||||
ProjectName: userCtx.Project.Name,
|
||||
@@ -1141,7 +1160,7 @@ func (e *CoordinatorEngine) handleCasualChat(ctx context.Context, userCtx *UserP
|
||||
}
|
||||
|
||||
nextExp := BuildNextExpectation(userCtx.Project.CurrentStage, userCtx.Project.Name)
|
||||
userCtx.PendingExpectation = nextExp
|
||||
userCtx.applyPending(nextExp)
|
||||
|
||||
nameSuffix := ""
|
||||
if userCtx.User.Name != "" {
|
||||
@@ -1157,7 +1176,7 @@ func (e *CoordinatorEngine) handleCasualChat(ctx context.Context, userCtx *UserP
|
||||
|
||||
// 路由层:复杂自由任务画像命中时,先给即时初答,再附升级建议(复用挂起期望机制,含每会话冷却)
|
||||
reply, nextExp = maybeAttachUpgradeHint(rawInput, userCtx, reply, nextExp)
|
||||
userCtx.PendingExpectation = nextExp
|
||||
userCtx.applyPending(nextExp)
|
||||
|
||||
return &CoordinationResult{
|
||||
Status: StatusAccepted,
|
||||
@@ -1254,8 +1273,8 @@ func (e *CoordinatorEngine) handleDocGovReject(ctx context.Context, cand *intent
|
||||
}, nil
|
||||
}
|
||||
|
||||
userCtx.InteractiveCard = resolved.Card
|
||||
userCtx.PendingExpectation = resolved.Expectation
|
||||
userCtx.applyCard(resolved.Card)
|
||||
userCtx.applyPending(resolved.Expectation)
|
||||
|
||||
return &CoordinationResult{
|
||||
Status: StatusAccepted,
|
||||
@@ -1299,7 +1318,7 @@ func (e *CoordinatorEngine) handleQueryRejectReason(ctx context.Context, cand *i
|
||||
|
||||
resolved := ResolveRejectCard(role, "", docName, docName, "", reason, RejectUtteranceQuery)
|
||||
if resolved.Card != nil {
|
||||
userCtx.InteractiveCard = resolved.Card
|
||||
userCtx.applyCard(resolved.Card)
|
||||
}
|
||||
|
||||
return &CoordinationResult{
|
||||
@@ -1442,8 +1461,8 @@ func (e *CoordinatorEngine) handleDocGovApprove(ctx context.Context, cand *inten
|
||||
},
|
||||
}
|
||||
|
||||
userCtx.InteractiveCard = cardPayload
|
||||
userCtx.PendingExpectation = nextExp
|
||||
userCtx.applyCard(cardPayload)
|
||||
userCtx.applyPending(nextExp)
|
||||
|
||||
reply := fmt.Sprintf("已为您起草针对《%s》的终审批准确认单。终审批准将使文档正式归档并永久锁定,请审阅下方卡片并在确认无误后点击确认或回复‘确认’。", docName)
|
||||
|
||||
@@ -1559,10 +1578,9 @@ func (e *CoordinatorEngine) handleDocGovProposalDecide(ctx context.Context, cand
|
||||
}
|
||||
|
||||
if userCtx.InteractiveCard != nil {
|
||||
userCtx.InteractiveCard.CurrentStatus = "resolved"
|
||||
userCtx.InteractiveCard = nil
|
||||
userCtx.dismissCard("resolved")
|
||||
}
|
||||
userCtx.PendingExpectation = nil
|
||||
userCtx.applyPending(nil)
|
||||
|
||||
rollback := docgov.StateDrafting
|
||||
if userCtx.InteractiveCard != nil && strings.Contains(userCtx.InteractiveCard.ImpactRisk, "reviewing") {
|
||||
@@ -1587,10 +1605,9 @@ func (e *CoordinatorEngine) handleDocGovProposalDecide(ctx context.Context, cand
|
||||
|
||||
if targetAction == "confirm_final_approve" {
|
||||
if userCtx.InteractiveCard != nil {
|
||||
userCtx.InteractiveCard.CurrentStatus = "resolved"
|
||||
userCtx.InteractiveCard = nil
|
||||
userCtx.dismissCard("resolved")
|
||||
}
|
||||
userCtx.PendingExpectation = nil
|
||||
userCtx.applyPending(nil)
|
||||
|
||||
return &CoordinationResult{
|
||||
Status: StatusAccepted,
|
||||
@@ -1608,10 +1625,9 @@ func (e *CoordinatorEngine) handleDocGovProposalDecide(ctx context.Context, cand
|
||||
|
||||
if targetAction == "cancel" {
|
||||
if userCtx.InteractiveCard != nil {
|
||||
userCtx.InteractiveCard.CurrentStatus = "cancelled"
|
||||
userCtx.InteractiveCard = nil
|
||||
userCtx.dismissCard("cancelled")
|
||||
}
|
||||
userCtx.PendingExpectation = nil
|
||||
userCtx.applyPending(nil)
|
||||
|
||||
return &CoordinationResult{
|
||||
Status: StatusAccepted,
|
||||
|
||||
@@ -50,7 +50,7 @@ func (e *CoordinatorEngine) handleRunResume(_ context.Context, cand *intent.Cand
|
||||
}
|
||||
|
||||
if isRunActionConfirmed(userCtx, "run.resume") {
|
||||
userCtx.PendingExpectation = nil
|
||||
userCtx.applyPending(nil)
|
||||
return &CoordinationResult{
|
||||
Status: StatusAccepted,
|
||||
Intent: intent.IntentRunResume,
|
||||
@@ -96,7 +96,7 @@ func (e *CoordinatorEngine) handleRunResume(_ context.Context, cand *intent.Cand
|
||||
|
||||
func (e *CoordinatorEngine) handleRunRestart(_ context.Context, cand *intent.CandidateIntent, userCtx *UserProjectContext) (*CoordinationResult, error) {
|
||||
if isRunActionConfirmed(userCtx, "run.restart") {
|
||||
userCtx.PendingExpectation = nil
|
||||
userCtx.applyPending(nil)
|
||||
taskKey := cand.FilledSlots["task_key"]
|
||||
if taskKey == "" && userCtx.TaskState != nil {
|
||||
taskKey = userCtx.TaskState.ActiveTask
|
||||
|
||||
@@ -253,7 +253,7 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// 1. 如果指定了 stage,则执行成熟度阶段编排
|
||||
if req.Stage != "" {
|
||||
stageRes, err := s.svc.ExecuteStage(r.Context(), bizdev.StageRunRequest{
|
||||
stageRes, err := s.taskUseCase().ExecuteStage(r.Context(), bizdev.StageRunRequest{
|
||||
Stage: bizdev.MaturityStage(req.Stage),
|
||||
ProjectName: req.ProjectName,
|
||||
CustomerName: req.CustomerName,
|
||||
@@ -301,7 +301,7 @@ func (s *Server) handleRunTask(w http.ResponseWriter, r *http.Request) {
|
||||
if req.TaskKey == "" {
|
||||
req.TaskKey = "intake"
|
||||
}
|
||||
taskRes, err := s.svc.ExecuteTask(r.Context(), bizdev.TaskRunRequest{
|
||||
taskRes, err := s.taskUseCase().ExecuteTask(r.Context(), bizdev.TaskRunRequest{
|
||||
TaskKey: req.TaskKey,
|
||||
ProjectName: req.ProjectName,
|
||||
CustomerName: req.CustomerName,
|
||||
|
||||
+77
-151
@@ -10,10 +10,9 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/agent"
|
||||
"chat-agent-system/internal/app"
|
||||
"chat-agent-system/internal/app/chatflow"
|
||||
"chat-agent-system/internal/app/uow"
|
||||
"chat-agent-system/internal/artifactvault"
|
||||
"chat-agent-system/internal/app/stageflow"
|
||||
"chat-agent-system/internal/auth"
|
||||
"chat-agent-system/internal/authz"
|
||||
"chat-agent-system/internal/bizdev"
|
||||
@@ -23,7 +22,6 @@ import (
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
"chat-agent-system/internal/intent"
|
||||
"chat-agent-system/internal/notification"
|
||||
"chat-agent-system/internal/observability"
|
||||
"chat-agent-system/internal/projectinstance"
|
||||
"chat-agent-system/internal/projectworkflow"
|
||||
"chat-agent-system/internal/taskrun"
|
||||
@@ -33,6 +31,7 @@ import (
|
||||
|
||||
// Server 提供基于 HTTP RESTful 与 SSE 的商务与研发参谋接口服务
|
||||
type Server struct {
|
||||
rt *app.Runtime
|
||||
cfg *config.Config
|
||||
svc *bizdev.Service
|
||||
pwSvc *projectworkflow.Service
|
||||
@@ -52,156 +51,55 @@ type Server struct {
|
||||
cmds *taskrun.Commands
|
||||
chatLog chatflow.ChatLog
|
||||
assembler chatflow.ContextAssembler
|
||||
stageflow *stageflow.UseCase
|
||||
httpServer *http.Server
|
||||
}
|
||||
|
||||
// NewServer 构造函数
|
||||
func NewServer(cfg *config.Config, svc *bizdev.Service) *Server {
|
||||
if cfg == nil {
|
||||
cfg, _ = config.Load("")
|
||||
// NewFromRuntime attaches the HTTP adapter to an already-wired composition root.
|
||||
func NewFromRuntime(rt *app.Runtime) *Server {
|
||||
if rt == nil {
|
||||
panic("server: nil runtime")
|
||||
}
|
||||
if cfg != nil {
|
||||
observability.InitLogger(cfg.Env)
|
||||
}
|
||||
if svc == nil {
|
||||
var err error
|
||||
svc, err = bizdev.NewService(cfg)
|
||||
if err != nil {
|
||||
panic("server: bizdev service init failed: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
rec := intent.NewRecognizer()
|
||||
dialogueBackend := agent.NewBackend(cfg.AgentBackend, cfg)
|
||||
coordEngine := coordinator.NewCoordinatorEngineWithDialogue(svc, dialogueBackend, cfg.DataDir)
|
||||
pool := svc.Pool()
|
||||
stores, storeErr := auth.NewStores(context.Background(), pool, cfg)
|
||||
if storeErr != nil {
|
||||
panic("server: auth store init failed: " + storeErr.Error())
|
||||
}
|
||||
authSvc := auth.NewService(stores.Auth, stores.Sessions, cfg)
|
||||
pwSvc := svc.ProjectWorkflow()
|
||||
if pwSvc == nil {
|
||||
pwSvc = projectworkflow.NewService(projectworkflow.NewStore(nil), nil)
|
||||
}
|
||||
|
||||
authzSvc := authz.NewService(pool)
|
||||
wfDefSvc := workflowdef.NewService(pool, svc.CAS())
|
||||
instStore := projectinstance.NewStore(pool)
|
||||
var instSvc *projectinstance.Service
|
||||
if wfDefSvc.Available() {
|
||||
docChecker := projectinstance.NewDocGovChecker(svc.DocGov())
|
||||
instSvc = projectinstance.NewService(instStore, wfDefSvc.Store(), pwSvc.Store(), docChecker)
|
||||
if pool != nil {
|
||||
instSvc.SetActiveMemberLookup(projectinstance.NewPGMemberLookup(pool))
|
||||
}
|
||||
}
|
||||
taskMgr := taskrun.NewManager(pwSvc, svc.ContextManager())
|
||||
if pool != nil {
|
||||
taskMgr.SetStore(taskrun.NewPGStore(pool))
|
||||
}
|
||||
wsCap := workspace.NewCapability(cfg.DataDir, workspace.NewLeaseTable())
|
||||
cmds := &taskrun.Commands{
|
||||
Ctx: svc.ContextManager(),
|
||||
PW: pwSvc,
|
||||
Env: cfg.Env,
|
||||
StorageIdentity: "env:" + cfg.Env,
|
||||
}
|
||||
if pool != nil {
|
||||
cmds.UoW = uow.NewPG(pool)
|
||||
cmds.Fav = &artifactvault.Ingest{Env: cfg.Env}
|
||||
}
|
||||
taskMgr.SetCommands(cmds)
|
||||
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
svc: svc,
|
||||
pwSvc: pwSvc,
|
||||
rec: rec,
|
||||
engine: coordEngine,
|
||||
authSvc: authSvc,
|
||||
rt: rt,
|
||||
cfg: rt.Cfg,
|
||||
svc: rt.Biz,
|
||||
pwSvc: rt.PW,
|
||||
rec: rt.Rec,
|
||||
engine: rt.Engine,
|
||||
authSvc: rt.Auth,
|
||||
sessions: make(map[string]*chat.Session),
|
||||
taskRuns: taskMgr,
|
||||
authorizer: authz.NewAuthorizer(pool),
|
||||
authzSvc: authzSvc,
|
||||
wfDefSvc: wfDefSvc,
|
||||
instSvc: instSvc,
|
||||
notifySvc: notification.NewService(pool),
|
||||
resolver: workspace.NewResolver(cfg.DataDir, pool),
|
||||
wsCap: wsCap,
|
||||
cmds: cmds,
|
||||
taskRuns: rt.TaskRuns,
|
||||
authorizer: rt.Authorizer,
|
||||
authzSvc: rt.Authz,
|
||||
wfDefSvc: rt.WFDef,
|
||||
instSvc: rt.Inst,
|
||||
notifySvc: rt.Notify,
|
||||
resolver: rt.Resolver,
|
||||
wsCap: rt.WSCap,
|
||||
cmds: rt.Commands,
|
||||
stageflow: rt.Stageflow,
|
||||
}
|
||||
s.chatLog = chatflow.NewAdapter(svc.ContextManager(), s.resolveStageAccess, s.failClosedAuthz())
|
||||
s.chatLog = chatflow.NewAdapter(s.svc.ContextManager(), s.resolveStageAccess, s.failClosedAuthz())
|
||||
s.assembler = chatflow.NewAssembler(coordinator.NewContextAssembler(), s.chatLog)
|
||||
if instSvc != nil && instSvc.Available() {
|
||||
svc.SetStageAccessGate(newBizdevStageGate(instSvc, pwSvc))
|
||||
coordEngine.SetInstanceService(instSvc)
|
||||
rt.ChatLog = s.chatLog
|
||||
rt.Assembler = s.assembler
|
||||
if s.instSvc != nil && s.instSvc.Available() && s.svc != nil {
|
||||
s.svc.SetStageAccessGate(newBizdevStageGate(s.instSvc, s.pwSvc))
|
||||
}
|
||||
s.registerWorkflowApprovalHook()
|
||||
s.startNotificationReminderLoop()
|
||||
s.initTaskRunExecutors()
|
||||
if svc != nil && svc.DocRevision() != nil && s.notifySvc != nil {
|
||||
rev := svc.DocRevision()
|
||||
rev.OnOutbox = func(ctx context.Context, eventType string, payload map[string]any) error {
|
||||
if !s.notifySvc.Available() {
|
||||
return nil
|
||||
}
|
||||
docID, _ := payload["doc_id"].(string)
|
||||
projectID, _ := payload["project_id"].(int64)
|
||||
risk, _ := payload["risk"].(string)
|
||||
var recipient int64
|
||||
if s.svc.DocGov() != nil {
|
||||
if doc, err := s.svc.DocGov().GetDocument(ctx, docID); err == nil && doc != nil && doc.AuthorID > 0 {
|
||||
recipient = doc.AuthorID
|
||||
projectID = doc.ProjectID
|
||||
}
|
||||
}
|
||||
if recipient == 0 {
|
||||
return nil
|
||||
}
|
||||
p := notification.DocumentEventPayload{
|
||||
ProjectID: projectID, DocID: docID, RiskLevel: risk,
|
||||
}
|
||||
switch eventType {
|
||||
case notification.EventDocAgentCandidateReady:
|
||||
if v, ok := payload["candidate_id"].(int64); ok {
|
||||
p.CandidateID = v
|
||||
}
|
||||
_, err := s.notifySvc.EmitDocumentEvent(ctx, eventType, notification.TypeDocAgentCandidateReady,
|
||||
"智能体变更待确认", "文档有高风险智能体候选变更,请确认后生效。",
|
||||
fmt.Sprintf("doc_candidate:%s:%d:%d", docID, p.CandidateID, recipient), recipient, p)
|
||||
return err
|
||||
case notification.EventDocAgentVersionCommitted:
|
||||
if v, ok := payload["version_no"].(int); ok {
|
||||
p.VersionNo = v
|
||||
}
|
||||
_, err := s.notifySvc.EmitDocumentEvent(ctx, eventType, notification.TypeDocAgentVersionCommitted,
|
||||
"智能体版本已生效", "新的智能体文档版本已提交。",
|
||||
fmt.Sprintf("doc_agent_version:%s:%d:%d", docID, p.VersionNo, recipient), recipient, p)
|
||||
return err
|
||||
case notification.EventDocDownstreamImpact:
|
||||
impacted, _ := payload["impacted_doc_id"].(string)
|
||||
p.ImpactedDocID = impacted
|
||||
_, err := s.notifySvc.EmitDocumentEvent(ctx, eventType, notification.TypeDocDownstreamImpact,
|
||||
"下游文档受影响", "上游文档变更,请复核关联文档。",
|
||||
fmt.Sprintf("doc_impact:%s:%s:%d", docID, impacted, recipient), recipient, p)
|
||||
return err
|
||||
case notification.EventDocChangeReportDegraded:
|
||||
if v, ok := payload["report_id"].(int64); ok {
|
||||
p.ReportID = v
|
||||
}
|
||||
_, err := s.notifySvc.EmitDocumentEvent(ctx, eventType, notification.TypeDocChangeReportDegraded,
|
||||
"变更报告未完整评估", "变更报告降级,受控文档暂不可送审。",
|
||||
fmt.Sprintf("doc_report_degraded:%s:%d:%d", docID, p.ReportID, recipient), recipient, p)
|
||||
return err
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// NewServer 构造函数(测试兼容:内部走 app.Assemble)
|
||||
func NewServer(cfg *config.Config, svc *bizdev.Service) *Server {
|
||||
rt, err := app.Assemble(cfg, svc)
|
||||
if err != nil {
|
||||
panic("server: runtime init failed: " + err.Error())
|
||||
}
|
||||
return NewFromRuntime(rt)
|
||||
}
|
||||
|
||||
// AuthService 获取认证服务
|
||||
func (s *Server) AuthService() *auth.Service {
|
||||
return s.authSvc
|
||||
@@ -426,6 +324,9 @@ func (s *Server) serveSPA(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Start 启动 HTTP 守护进程
|
||||
func (s *Server) Start() error {
|
||||
if s.rt != nil {
|
||||
_ = s.rt.Start(context.Background())
|
||||
}
|
||||
addr := fmt.Sprintf("%s:%d", s.cfg.HTTPHost, s.cfg.HTTPPort)
|
||||
s.httpServer = &http.Server{
|
||||
Addr: addr,
|
||||
@@ -441,6 +342,9 @@ func (s *Server) Start() error {
|
||||
|
||||
// Shutdown 优雅停机
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
if s.rt != nil {
|
||||
_ = s.rt.Stop(ctx)
|
||||
}
|
||||
if s.httpServer != nil {
|
||||
return s.httpServer.Shutdown(ctx)
|
||||
}
|
||||
@@ -558,11 +462,18 @@ func (s *Server) getOrCreateSession(
|
||||
BlackboardFacts: make(map[string]string),
|
||||
AvailableActions: coordinator.ComputeAvailableActions(bizdev.StageV01_Triage, u.ProjectRole),
|
||||
}
|
||||
if targetMarket != "" {
|
||||
userCtx.BlackboardFacts["target_market"] = targetMarket
|
||||
}
|
||||
if deviceType != "" {
|
||||
userCtx.BlackboardFacts["device_type"] = deviceType
|
||||
if targetMarket != "" || deviceType != "" {
|
||||
fill := map[string]string{}
|
||||
if targetMarket != "" {
|
||||
fill["target_market"] = targetMarket
|
||||
}
|
||||
if deviceType != "" {
|
||||
fill["device_type"] = deviceType
|
||||
}
|
||||
_ = coordinator.ApplyContextUpdate(userCtx, coordinator.ContextUpdate{
|
||||
ExpectedVersion: userCtx.Version,
|
||||
BlackboardFill: fill,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -586,10 +497,11 @@ func (s *Server) getOrCreateSession(
|
||||
if restored.ProjectID > 0 {
|
||||
sess.ProjectID = restored.ProjectID
|
||||
}
|
||||
for k, v := range restored.Facts {
|
||||
if _, exists := userCtx.BlackboardFacts[k]; !exists {
|
||||
userCtx.BlackboardFacts[k] = v
|
||||
}
|
||||
if len(restored.Facts) > 0 {
|
||||
_ = coordinator.ApplyContextUpdate(userCtx, coordinator.ContextUpdate{
|
||||
ExpectedVersion: userCtx.Version,
|
||||
BlackboardFill: restored.Facts,
|
||||
})
|
||||
}
|
||||
if s.chatLog == nil {
|
||||
for _, rm := range restored.Messages {
|
||||
@@ -607,7 +519,11 @@ func (s *Server) getOrCreateSession(
|
||||
|
||||
if sess.UserID > 0 && sess.CtxMgr != nil {
|
||||
if guidance, err := sess.CtxMgr.CompilePromptGuidance(context.Background(), sess.UserID); err == nil && guidance != "" {
|
||||
userCtx.PersonalizedGuidance = guidance
|
||||
_ = coordinator.ApplyContextUpdate(userCtx, coordinator.ContextUpdate{
|
||||
ExpectedVersion: userCtx.Version,
|
||||
SetPersonalizedGuidance: true,
|
||||
PersonalizedGuidance: guidance,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -641,6 +557,16 @@ func (s *Server) hydrateVisibleHistory(ctx context.Context, sess *chat.Session,
|
||||
sess.SyncRecentDialogue()
|
||||
}
|
||||
|
||||
func (s *Server) taskUseCase() *stageflow.UseCase {
|
||||
if s == nil {
|
||||
return stageflow.New(nil)
|
||||
}
|
||||
if s.stageflow != nil {
|
||||
return s.stageflow
|
||||
}
|
||||
return stageflow.New(s.svc)
|
||||
}
|
||||
|
||||
func (s *Server) corsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s.writeCORS(w, r)
|
||||
|
||||
+7
-25
@@ -13,6 +13,7 @@ import (
|
||||
"chat-agent-system/internal/ctxmgr"
|
||||
"chat-agent-system/internal/squad"
|
||||
"chat-agent-system/internal/stream"
|
||||
"chat-agent-system/internal/stream/transport"
|
||||
"chat-agent-system/internal/taskrun"
|
||||
)
|
||||
|
||||
@@ -55,7 +56,7 @@ func extendSSEWriteDeadline(w http.ResponseWriter) {
|
||||
|
||||
func writeSSEHeartbeat(w http.ResponseWriter, flusher http.Flusher) error {
|
||||
extendSSEWriteDeadline(w)
|
||||
if _, err := fmt.Fprintf(w, ": heartbeat %d\n\n", time.Now().Unix()); err != nil {
|
||||
if err := (transport.Encoder{W: w}).Heartbeat(time.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
flusher.Flush()
|
||||
@@ -63,11 +64,7 @@ func writeSSEHeartbeat(w http.ResponseWriter, flusher http.Flusher) error {
|
||||
}
|
||||
|
||||
func writeSSE(w http.ResponseWriter, flusher http.Flusher, ev stream.StreamEvent) {
|
||||
data, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Type, string(data))
|
||||
_ = (transport.Encoder{W: w}).Write(ev)
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
@@ -236,14 +233,7 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) {
|
||||
listener := func(ev stream.StreamEvent) {
|
||||
writeMu.Lock()
|
||||
defer writeMu.Unlock()
|
||||
|
||||
data, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// 按照标准 SSE 协议规范发射: event: <type>\ndata: <json>\n\n
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Type, string(data))
|
||||
flusher.Flush()
|
||||
writeSSE(w, flusher, ev)
|
||||
}
|
||||
|
||||
clientTurnID := req.ClientTurnID
|
||||
@@ -302,15 +292,12 @@ func (s *Server) handleChatStream(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
writeMu.Lock()
|
||||
info := stream.ClassifyError(err, "chat_stream", requestID, sess.ID, "")
|
||||
errEv := stream.StreamEvent{
|
||||
writeSSE(w, flusher, stream.StreamEvent{
|
||||
Type: stream.EventError,
|
||||
Timestamp: time.Now(),
|
||||
Error: info.UserMessage,
|
||||
ErrorInfo: &info,
|
||||
}
|
||||
data, _ := json.Marshal(errEv)
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", errEv.Type, string(data))
|
||||
flusher.Flush()
|
||||
})
|
||||
writeMu.Unlock()
|
||||
}
|
||||
}
|
||||
@@ -408,12 +395,7 @@ func (s *Server) handleRunTaskStream(w http.ResponseWriter, r *http.Request) {
|
||||
defer s.taskRuns.Unsubscribe(created.RunID, ch)
|
||||
|
||||
writeStored := func(stored taskrun.StoredEvent) {
|
||||
data, marshalErr := json.Marshal(stored.Event)
|
||||
if marshalErr != nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "id: %d\n", stored.Seq)
|
||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", stored.Event.Type, string(data))
|
||||
_ = (transport.Encoder{W: w}).WriteWithID(stored.Seq, stored.Event)
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
|
||||
@@ -708,7 +708,7 @@ func (s *Server) buildBizdevStageRequest(dto TaskRunRequestDTO, sessionID, clien
|
||||
func (s *Server) initTaskRunExecutors() {
|
||||
s.taskRuns.SetExecutor(
|
||||
func(ctx context.Context, _ string, req bizdev.TaskRunRequest, _ *stream.Streamer) (*bizdev.TaskRunResult, error) {
|
||||
return s.svc.ExecuteTask(ctx, req)
|
||||
return s.taskUseCase().ExecuteTask(ctx, req)
|
||||
},
|
||||
func(ctx context.Context, _ string, workDir string, _ *stream.Streamer) (*bizdev.TaskRunResult, error) {
|
||||
res, err := s.svc.ResumeOrModifyWorkspace(ctx, squad.ModifyWorkflowRequest{WorkDir: workDir})
|
||||
@@ -722,7 +722,7 @@ func (s *Server) initTaskRunExecutors() {
|
||||
return out, err
|
||||
},
|
||||
func(ctx context.Context, _ string, req bizdev.StageRunRequest, _ *stream.Streamer) (*bizdev.TaskRunResult, error) {
|
||||
stageRes, err := s.svc.ExecuteStage(ctx, req)
|
||||
stageRes, err := s.taskUseCase().ExecuteStage(ctx, req)
|
||||
if stageRes == nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/stream"
|
||||
)
|
||||
|
||||
// Encoder writes neutral SSE frames. It does not own retries, leases, or task state.
|
||||
type Encoder struct {
|
||||
W io.Writer
|
||||
}
|
||||
|
||||
func (e Encoder) Write(ev stream.StreamEvent) error {
|
||||
return e.WriteWithID(0, ev)
|
||||
}
|
||||
|
||||
func (e Encoder) WriteWithID(id int64, ev stream.StreamEvent) error {
|
||||
if e.W == nil {
|
||||
return nil
|
||||
}
|
||||
if ev.Timestamp.IsZero() {
|
||||
ev.Timestamp = time.Now()
|
||||
}
|
||||
data, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if id > 0 {
|
||||
if _, err = fmt.Fprintf(e.W, "id: %d\n", id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err = fmt.Fprintf(e.W, "event: %s\ndata: %s\n\n", ev.Type, string(data))
|
||||
return err
|
||||
}
|
||||
|
||||
func (e Encoder) Heartbeat(now time.Time) error {
|
||||
if e.W == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := fmt.Fprintf(e.W, ": heartbeat %d\n\n", now.Unix())
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/stream"
|
||||
)
|
||||
|
||||
func TestWriteNeutralFrame(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
enc := Encoder{W: &buf}
|
||||
if err := enc.Write(stream.StreamEvent{Type: stream.EventDone, Timestamp: time.Unix(1, 0)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "event: done") || !strings.Contains(got, "data: {") {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
if strings.Contains(got, "retry:") {
|
||||
t.Fatal("transport must not own retry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeat(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
if err := (Encoder{W: &buf}).Heartbeat(time.Unix(10, 0)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), ": heartbeat 10") {
|
||||
t.Fatalf("%q", buf.String())
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ fi
|
||||
|
||||
if [[ "${MODE}" == "block" ]]; then
|
||||
if ! ARCHTEST_STRICT=1 go test ./internal/archtest -count=1 -run TestArchitectureBoundaries; then
|
||||
echo "arch-check block: baseline exceptions remain; only cleared rules may use --mode block" >&2
|
||||
echo "arch-check block: a cleared kind in block_kinds still has findings" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user