merge: feature/event-shared-live-cutover

This commit is contained in:
weichen
2026-09-15 14:24:17 +00:00
44 changed files with 838 additions and 19 deletions
@@ -1,6 +1,6 @@
---
title: "共享环境事件消费者 live 切换"
status: planned
status: done
created: 2026-09-15
updated: 2026-09-15
---
@@ -25,7 +25,7 @@ Track E 已在隔离库证明 notify / workflow_advance / projection 的 exactly
## 暂不启动原因
共享 live 会改变 :8080 真实副作用路径。未获授权前保持 dispatcher/consumer `off`。
已完成共享 live 与生产去注册。方法本体保留一个窗口。
## 相关链接
+3 -2
View File
@@ -5,8 +5,8 @@
## 焦点
1. **TRACK-E/S**: Phase 2 隔离库与结构解耦已合入;共享 live 见 [长期任务](./long-term/2026-09-15-event-shared-live-cutover.md)
2. **TRACK-P**: 立项完成([H0](./research/2026-09-15-http-error-envelope-h0-inventory.md) / [P0](./research/2026-09-15-pi-typed-result-p0-matrix.md));实施见 [长期任务](./long-term/2026-09-15-track-p-protocol-implementation.md)
1. **TRACK-P**: HTTP 信封与 PI typed-result 实施([long-term](./long-term/2026-09-15-track-p-protocol-implementation.md))
2. **TRACK-E LIVE**: 共享 consumer 已 live;生产 hook 已去注册([projection](../test-reports/2026-09-15-event-projection-shared-live-test-report.md))
3. **CHAT-INTENT**: [对话意图运行时状态](../test-reports/2026-09-13-conversation-intent-runtime-state-test-report.md)(双标签 busy 已签:项目 **6275** / `DOC_BUSY` 单 run;不删 `sess-p143-u9` 探针)
## 并行认领
@@ -70,6 +70,7 @@
| Cursor | bizdev-locator-removal | fix/bizdev-locator-removal | - | bizdev | done |
| Cursor | chatflow-orchestration-boundary | fix/chatflow-orchestration-boundary | - | chat | done |
| Cursor | phase2-closeout-inventories | feature/phase2-closeout-inventories | - | architecture-docs | done |
| Cursor | event-shared-live-cutover | feature/event-shared-live-cutover | - | events | done |
> 新任务开始前登记。`branch` 必须为 `feature/<slug>` 或 `fix/<slug>`,**禁止** `dev` / `test` / `main`。同一 `mutex` 同时仅一个 `in_progress`。
@@ -0,0 +1,50 @@
---
title: "共享环境事件消费者 live 切换"
status: in_progress
created: 2026-09-15
updated: 2026-09-15
owner: Cursor
branch: feature/event-shared-live-cutover
worktree: "-"
mutex: events
---
# 共享环境事件消费者 live 切换
## 目标
在共享 `agent-dev` 上按 notify → workflow_advance → projection 完成 shadow → drain → live → 回滚演练,再删除生产 hook 注册。
## 范围
- `scripts/event-consumer-mode.sh` + `cmd/eventmode`
- 共享 `agent_system_dev` inventory / quarantine
- 逐消费者 live 与授权页纵切(项目 6275;143 只读)
## 非目标
- FAV live
- test/main 环境
- 编辑 Phase 2 计划原文
- Track P(独立波次)
## 步骤
1. [x] L0 受控 SetMode 与 inventory
2. [ ] L1 notify
3. [ ] L2 workflow_advance
4. [ ] L3 projection + 授权页
5. [ ] L4 删除生产 hook 注册
## 验收标准
- [ ] fav / test / main 切模式失败
- [ ] 每消费者独立 live 报告
- [ ] 生产代码无 `OnOutbox =` / `AddOnApprovedHook(`
## 相关链接
- Long-term: [.doc/archive/ops/2026-09-15-event-shared-live-cutover.md](../../archive/ops/2026-09-15-event-shared-live-cutover.md)
- Test report: (本波完成后补)
## 证据 / 备注
+1 -1
View File
@@ -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 | 受控文档写路径与 PG 事件交付尚未形成生产闭环;闭包副作用在崩溃/重启后不可重试审计,错误 scope 或 projection 缺失仍可能导致刷新失败 | OPEN | [初审](../audit/2026-09-14-module-decoupling-plan-review.md);[Phase 1](./2026-09-14-module-decoupling-refactor-plan.md);[Phase 2 复核](../audit/2026-09-15-module-decoupling-phase2-plan-review.md);[Phase 2 修订](./2026-09-15-module-decoupling-ipc-phase2-remediation-plan.md);[U11–U12 报告](../test-reports/2026-09-15-app-composition-u11-u12-test-report.md);[E1A](../test-reports/2026-09-15-event-e1a-notify-cutover-test-report.md);[E1B](../test-reports/2026-09-15-event-e1b-workflow-advance-test-report.md);[E1C](../test-reports/2026-09-15-event-e1c-projection-test-report.md);[E2](../test-reports/2026-09-15-event-e2-hook-guard-test-report.md);[复盘](../ops/research/2026-09-15-track-e-retrospective.md);[live 长期任务](../ops/long-term/2026-09-15-event-shared-live-cutover.md) | E0A–E2 隔离库已过。共享消费者未 live,故不删 hook API。dispatcher 保持 off。FAV、HTTP/PI 实施不纳入本 blocker 关闭条件。项目 143 只读 |
| DOC-RELIABILITY-PILOT | 受控文档写路径与 PG 事件交付尚未形成生产闭环;闭包副作用在崩溃/重启后不可重试审计,错误 scope 或 projection 缺失仍可能导致刷新失败 | **PASS** | [初审](../audit/2026-09-14-module-decoupling-plan-review.md);[Phase 1](./2026-09-14-module-decoupling-refactor-plan.md);[Phase 2 复核](../audit/2026-09-15-module-decoupling-phase2-plan-review.md);[Phase 2 修订](./2026-09-15-module-decoupling-ipc-phase2-remediation-plan.md);[U11–U12 报告](../test-reports/2026-09-15-app-composition-u11-u12-test-report.md);[E1A](../test-reports/2026-09-15-event-e1a-notify-cutover-test-report.md);[E1B](../test-reports/2026-09-15-event-e1b-workflow-advance-test-report.md);[E1C](../test-reports/2026-09-15-event-e1c-projection-test-report.md);[E2](../test-reports/2026-09-15-event-e2-hook-guard-test-report.md);[复盘](../ops/research/2026-09-15-track-e-retrospective.md);[notify live](../test-reports/2026-09-15-event-notify-shared-live-test-report.md);[advance live](../test-reports/2026-09-15-event-workflow-advance-shared-live-test-report.md);[projection live](../test-reports/2026-09-15-event-projection-shared-live-test-report.md) | 共享 notify/workflow_advance/projection 已 live;生产不再注册 `OnOutbox=` / `AddOnApprovedHook(`。FAV 仍 off。HTTP/PI 实施不纳入本行。项目 143 只读 |
---
+1 -1
View File
@@ -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` |
| 模块解耦与可靠事件 | 文档可靠性纵向试点、PG 持久事件交付与选择性模块化 | partial | [架构](../system-design/2026-09-14-module-decoupling-architecture.md);[ADR](../adr/2026-09-14-modular-monolith-decoupling.md);[Phase 1](../remediation/2026-09-14-module-decoupling-refactor-plan.md);[Phase 2 审阅](../audit/2026-09-15-module-decoupling-phase2-plan-review.md);[Phase 2 修订计划](../remediation/2026-09-15-module-decoupling-ipc-phase2-remediation-plan.md);[E0A](../test-reports/2026-09-15-event-e0a-production-scope-contract-test-report.md);[E0B](../test-reports/2026-09-15-event-e0b-dispatcher-pg-test-report.md);[E1A](../test-reports/2026-09-15-event-e1a-notify-cutover-test-report.md);[E1B](../test-reports/2026-09-15-event-e1b-workflow-advance-test-report.md);[E1C](../test-reports/2026-09-15-event-e1c-projection-test-report.md);[E2](../test-reports/2026-09-15-event-e2-hook-guard-test-report.md);[复盘](../ops/research/2026-09-15-track-e-retrospective.md);[D6](../test-reports/2026-09-15-doccontent-canonical-boundary-test-report.md);[D7a](../test-reports/2026-09-15-intent-bizdev-boundary-test-report.md);[D5](../test-reports/2026-09-15-stageflow-type-ownership-test-report.md);[D4](../test-reports/2026-09-15-bizdev-locator-removal-test-report.md);[D7b](../test-reports/2026-09-15-chatflow-orchestration-boundary-test-report.md);[H0](../ops/research/2026-09-15-http-error-envelope-h0-inventory.md);[P0](../ops/research/2026-09-15-pi-typed-result-p0-matrix.md);[收口](../test-reports/2026-09-15-phase2-closeout-test-report.md) | E0A–E2 隔离库已过;dispatcher 默认 off。D4–D7b 已合入。共享 live 与删 hook 见 [长期任务](../ops/long-term/2026-09-15-event-shared-live-cutover.md)。Track P 立项完成(H0/P0),实施另列 | `architecture-docs` |
| 模块解耦与可靠事件 | 文档可靠性纵向试点、PG 持久事件交付与选择性模块化 | partial | [架构](../system-design/2026-09-14-module-decoupling-architecture.md);[ADR](../adr/2026-09-14-modular-monolith-decoupling.md);[Phase 1](../remediation/2026-09-14-module-decoupling-refactor-plan.md);[Phase 2 审阅](../audit/2026-09-15-module-decoupling-phase2-plan-review.md);[Phase 2 修订计划](../remediation/2026-09-15-module-decoupling-ipc-phase2-remediation-plan.md);[E0A](../test-reports/2026-09-15-event-e0a-production-scope-contract-test-report.md);[E0B](../test-reports/2026-09-15-event-e0b-dispatcher-pg-test-report.md);[E1A](../test-reports/2026-09-15-event-e1a-notify-cutover-test-report.md);[E1B](../test-reports/2026-09-15-event-e1b-workflow-advance-test-report.md);[E1C](../test-reports/2026-09-15-event-e1c-projection-test-report.md);[E2](../test-reports/2026-09-15-event-e2-hook-guard-test-report.md);[复盘](../ops/research/2026-09-15-track-e-retrospective.md);[D6](../test-reports/2026-09-15-doccontent-canonical-boundary-test-report.md);[D7a](../test-reports/2026-09-15-intent-bizdev-boundary-test-report.md);[D5](../test-reports/2026-09-15-stageflow-type-ownership-test-report.md);[D4](../test-reports/2026-09-15-bizdev-locator-removal-test-report.md);[D7b](../test-reports/2026-09-15-chatflow-orchestration-boundary-test-report.md);[H0](../ops/research/2026-09-15-http-error-envelope-h0-inventory.md);[P0](../ops/research/2026-09-15-pi-typed-result-p0-matrix.md);[收口](../test-reports/2026-09-15-phase2-closeout-test-report.md);[notify live](../test-reports/2026-09-15-event-notify-shared-live-test-report.md);[advance live](../test-reports/2026-09-15-event-workflow-advance-shared-live-test-report.md);[projection live](../test-reports/2026-09-15-event-projection-shared-live-test-report.md) | E0A–E2 与共享 live 已过;生产无 hook 注册。D4–D7b 已合入。FAV off。Track P 立项完成(H0/P0),实施另列 | `architecture-docs` |
---
@@ -0,0 +1,47 @@
# E2 生产去注册测试报告
## 一、 测试背景 (Background)
三条消费者已在共享 `agent-dev` live。Track E DoD 要求生产组合根不再接线 `OnOutbox =` / `AddOnApprovedHook(`,但保留方法本体一个窗口。
## 二、 测试目标 (Test Objectives)
- `internal/app/app.go` 与 server 组合根无上述注册。
- `AddOnApprovedHook` / `OnOutbox` 字段与 Bind 辅助仍存在,测试可接线。
- 授权读取 6275 仍与 digest 一致。
## 三、 测试方案与环境 (Test Plan & Environment)
| 项 | 值 |
|----|-----|
| 分支 | `feature/event-shared-live-cutover` |
| 单测 | `TestProductionCompositionHasNoHookRegistration` |
| arch-check | `--mode block` |
## 四、 预期结果 (Expected Results)
组合根无注册;arch-check PASS;6275 SHA 不变。
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
### 5.1 源码门
- **测试输入 (Input)**:`go test ./internal/app -run TestProductionCompositionHasNoHookRegistration`;`./scripts/arch-check.sh --mode block`
- **实际输出 (Actual Output)**:PASS;`arch-check block: production gate passed`
- **评价判定 (Evaluation / Verdict)**:**PASS**
- **性能与耗时 (Duration & Latency)**:arch-check ~0.2s;server 包 10s
### 5.2 运行时(rebuild 后补)
见同目录资产与 rebuild 日志。未登录仍 401;跨项目 143 仍 404 `DOCUMENT_NOT_FOUND`。
## 六、 自动化测试套件汇总 (Test Suite Execution)
| 命令 | 结果 |
|------|------|
| `go test -short ./internal/app ./internal/server ./internal/app/docnotify` | PASS |
| arch-check block | PASS |
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
`DOC-RELIABILITY-PILOT` 事件部分关闭。Track P 独立实施。hook 方法本体尚未删除。
@@ -0,0 +1,61 @@
# 共享 notify live 测试报告
## 一、 测试背景 (Background)
隔离库 E1A 已证明 notify 的 shadow dry-run、唯一可见通知与回滚。本波在共享 `agent_system_dev` 上把 `notify` 切为权威写者。
## 二、 测试目标 (Test Objectives)
- fav / test / main 切模式失败。
- 历史非法 fav/projection delivery 已 quarantine,不得 claim。
- notify:shadow → drain(重启 events worker)→ live → off 回滚 → 再 live。
- FAV 保持 off;HTTP `:8080` 与 events `:8088` ready。
## 三、 测试方案与环境 (Test Plan & Environment)
| 项 | 值 |
|----|-----|
| OS | Linux |
| 分支 | `feature/event-shared-live-cutover` |
| 共享库 | `shuimu-context-pg-dev` / `agent_system_dev` schema 43 |
| 隔离回归 | `127.0.0.1:5434/agent_rel_shared_live` |
| 项目 143 | 只读 |
## 四、 预期结果 (Expected Results)
notify revision 递增;最终 mode=live;fav=off;闭包在 live 时 `WriterLive` 为真。
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
### 5.1 隔离库 E1A
- **测试输入 (Input)**:`TEST_DATABASE_URL=.../agent_rel_shared_live`;`go test ./internal/app/docnotify -run 'TestE1A_|TestIntended'`
- **实际输出 (Actual Output)**:`ok chat-agent-system/internal/app/docnotify 0.361s`
- **评价判定 (Evaluation / Verdict)**:**PASS**
- **性能与耗时 (Duration & Latency)**:0.36s
### 5.2 共享切模式
- **测试输入 (Input)**:`./scripts/event-consumer-mode.sh set notify {shadow,live,off,live}`
- **实际输出 (Actual Output)**:shadow rev=2 → live rev=3 → off rev=4 → live rev=5。见 `assets/2026-09-15-event-shared-live/notify-*.json`
- **评价判定 (Evaluation / Verdict)**:**PASS**
- **性能与耗时 (Duration & Latency)**:约 7s(含 worker 重启)
### 5.3 健康检查
- **测试输入 (Input)**:`curl :8080/ready` `:8088/ready`
- **实际输出 (Actual Output)**:schema 43;events ready ok;fav mode=off
- **评价判定 (Evaluation / Verdict)**:**PASS**
- **性能与耗时 (Duration & Latency)**:<1s
## 六、 自动化测试套件汇总 (Test Suite Execution)
| 命令 | 结果 |
|------|------|
| ValidateOpsCutover 单测 | PASS |
| E1A 隔离 PG | PASS |
| 共享 notify 回滚演练 | PASS |
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
共享 **notify=live**。下一步 workflow_advance。未删 hook API。
@@ -0,0 +1,57 @@
# 共享 projection live 测试报告
## 一、 测试背景 (Background)
隔离库 E1C 已证明 projection no-replace。本波在共享库切 live,并做三类 worker 重启与授权读取。
## 二、 测试目标 (Test Objectives)
- projection shadow → 重启 → live → 重启 → lease 过期后再重启 → 回滚 off → 再 live
- 非法 taskrun projection delivery 保持 pending(quarantine 不可 claim)
- 项目 6275 授权刷新正文 SHA 与 `document_records.content_sha256` 一致
- 项目 143 跨项目读取不泄露;未登录 401
- FAV off
## 三、 测试方案与环境 (Test Plan & Environment)
| 项 | 值 |
|----|-----|
| 共享 | schema 43;events `:8088` |
| 隔离 | `agent_rel_shared_live` |
| 授权用户 | `zhanggong`(6275 owner,非 143 成员) |
| 浏览器 | 无独立 browser MCP;用 HTTP 刷新代替页面点击 |
## 四、 预期结果 (Expected Results)
三消费者 live;fav off;6275 SHA 一致;143 为 `DOCUMENT_NOT_FOUND`。
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
### 5.1 隔离 E1C
- **测试输入 (Input)**:`go test ./internal/app/docproject -run TestE1C_`
- **实际输出 (Actual Output)**:`ok 0.039s`
- **评价判定 (Evaluation / Verdict)**:**PASS**
- **性能与耗时 (Duration & Latency)**:0.039s
### 5.2 共享切换与三类重启
- **测试输入 (Input)**:shadow;restart1;live;restart2;sleep 31;restart3;off;live
- **实际输出 (Actual Output)**:projection rev 2→3→4→5;`modes-after-projection.txt` 三条业务消费者均为 live,fav off。quarantine projection 仍 pending=2
- **评价判定 (Evaluation / Verdict)**:**PASS**
- **性能与耗时 (Duration & Latency)**:约 45s
### 5.3 授权刷新 / 负例
- **测试输入 (Input)**:登录后两次 GET `/api/v1/docgov/documents/doc_proj6275_intake/content`;跨项目 GET 143 文档;无 token GET 6275
- **实际输出 (Actual Output)**:200/200,`content_sha256=dc99aa13…641ddf` 与库一致;143 → 404 `DOCUMENT_NOT_FOUND`;未登录 401
- **评价判定 (Evaluation / Verdict)**:**PASS**
- **性能与耗时 (Duration & Latency)**:<1s
## 六、 自动化测试套件汇总 (Test Suite Execution)
E1C 隔离 PASS;共享 live + 重启 + HTTP 刷新 PASS。无浏览器截图(工具不可用)。
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
三条消费者均 **live**。下一步删除生产 hook 注册。未写项目 143。
@@ -0,0 +1,51 @@
# 共享 workflow_advance live 测试报告
## 一、 测试背景 (Background)
隔离库 E1B 已证明阶段推进 exactly-one visible outcome。本波在共享库将 `workflow_advance` 切 live。
## 二、 测试目标 (Test Objectives)
shadow → drain → live → off 回滚 → 再 live;notify 保持 live;FAV off。
## 三、 测试方案与环境 (Test Plan & Environment)
| 项 | 值 |
|----|-----|
| 共享库 | `agent_system_dev` schema 43 |
| 隔离回归 | `agent_rel_shared_live` |
## 四、 预期结果 (Expected Results)
workflow_advance 最终 live revision≥5;notify 仍 live;fav off。
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
### 5.1 隔离 E1B
- **测试输入 (Input)**:`go test ./internal/app/docadvance -run TestE1B_`
- **实际输出 (Actual Output)**:`ok 0.065s`
- **评价判定 (Evaluation / Verdict)**:**PASS**
- **性能与耗时 (Duration & Latency)**:0.065s
### 5.2 共享切换
- **测试输入 (Input)**:`event-consumer-mode.sh set workflow_advance {shadow,live,off,live}`
- **实际输出 (Actual Output)**:rev 2→3→4→5;modes-after-advance.txt:notify live、workflow_advance live、projection off、fav off
- **评价判定 (Evaluation / Verdict)**:**PASS**
- **性能与耗时 (Duration & Latency)**:约 7s
### 5.3 健康检查
- **测试输入 (Input)**:`:8080/ready` `:8088/ready`
- **实际输出 (Actual Output)**:schema 43 ok
- **评价判定 (Evaluation / Verdict)**:**PASS**
- **性能与耗时 (Duration & Latency)**:<1s
## 六、 自动化测试套件汇总 (Test Suite Execution)
E1B 隔离 PASS;共享回滚演练 PASS。
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
**workflow_advance=live**。下一步 projection + 授权页。
@@ -0,0 +1 @@
{"after":"live","before":"off","consumer":"workflow_advance","environment":"dev","revision":5}
@@ -0,0 +1 @@
{"after":"live","before":"shadow","consumer":"workflow_advance","environment":"dev","revision":3}
@@ -0,0 +1 @@
{"after":"off","before":"live","consumer":"workflow_advance","environment":"dev","revision":4}
@@ -0,0 +1 @@
{"after":"shadow","before":"off","consumer":"workflow_advance","environment":"dev","revision":2}
@@ -0,0 +1,15 @@
{
"status": 200,
"keys": [
"consistency",
"content_sha256",
"doc_id",
"file_sha256",
"head_sha256",
"is_template_only",
"markdown",
"record_sha256",
"version"
],
"content_sha256": "dc99aa130644353f258e951887c9d3babf35afc9d46e008327121bdedc641ddf"
}
@@ -0,0 +1,18 @@
{
"status": 200,
"head_revision_no": 0,
"keys": [
"baseline_snapshot_id",
"can_decide_pending",
"capabilities",
"current_version_no",
"doc_id",
"etag",
"head_block_map",
"head_revision_no",
"head_sha256",
"head_snapshot_id",
"manual_change_count",
"version_status"
]
}
@@ -0,0 +1,5 @@
{
"status": 404,
"doc_id_redacted": true,
"body_prefix": "{\"error_code\":\"DOCUMENT_NOT_FOUND\",\"message\":\"document not found\"}\n"
}
@@ -0,0 +1,6 @@
consumer | status | count
------------+---------+-------
fav | pending | 2
projection | pending | 2
(2 rows)
@@ -0,0 +1 @@
{"status":"ok"}
@@ -0,0 +1 @@
{"status":"ok","env":"dev","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"database_ready":true,"schema_version":43,"schema_ready":true,"task_store_ready":true}
@@ -0,0 +1 @@
{"status":"ok","env":"dev","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"database_ready":true,"schema_version":43,"schema_ready":true,"task_store_ready":true}
@@ -0,0 +1 @@
{"backlog":[{"consumer":"fav","status":"pending","count":2},{"consumer":"projection","status":"pending","count":2}],"modes":[{"environment":"dev","consumer":"fav","mode":"off","revision":1},{"environment":"dev","consumer":"notify","mode":"off","revision":1},{"environment":"dev","consumer":"projection","mode":"off","revision":1},{"environment":"dev","consumer":"workflow_advance","mode":"off","revision":1},{"environment":"main","consumer":"fav","mode":"off","revision":1},{"environment":"main","consumer":"notify","mode":"off","revision":1},{"environment":"main","consumer":"projection","mode":"off","revision":1},{"environment":"main","consumer":"workflow_advance","mode":"off","revision":1},{"environment":"test","consumer":"fav","mode":"off","revision":1},{"environment":"test","consumer":"notify","mode":"off","revision":1},{"environment":"test","consumer":"projection","mode":"off","revision":1},{"environment":"test","consumer":"workflow_advance","mode":"off","revision":1}],"pending_classifications":[{"event_id":"taskrun:run_1789452300651535257:failed","consumer":"fav","classification":"quarantine","reason":"unknown_or_disabled_consumer"},{"event_id":"taskrun:run_1789452300651535257:failed","consumer":"projection","classification":"quarantine","reason":"unknown_or_disabled_consumer"},{"event_id":"taskrun:run_1789452602295343365:completed","consumer":"fav","classification":"quarantine","reason":"unknown_or_disabled_consumer"},{"event_id":"taskrun:run_1789452602295343365:completed","consumer":"projection","classification":"quarantine","reason":"unknown_or_disabled_consumer"}]}
@@ -0,0 +1,8 @@
consumer | mode | revision
------------------+------+----------
fav | off | 1
notify | live | 5
projection | off | 1
workflow_advance | live | 5
(4 rows)
@@ -0,0 +1,8 @@
environment | consumer | mode | revision
-------------+------------------+------+----------
dev | fav | off | 1
dev | notify | live | 5
dev | projection | off | 1
dev | workflow_advance | off | 1
(4 rows)
@@ -0,0 +1,8 @@
consumer | mode | revision
------------------+------+----------
fav | off | 1
notify | live | 5
projection | live | 5
workflow_advance | live | 5
(4 rows)
@@ -0,0 +1 @@
{"after":"live","before":"off","consumer":"notify","environment":"dev","revision":5}
@@ -0,0 +1 @@
{"after":"live","before":"shadow","consumer":"notify","environment":"dev","revision":3}
@@ -0,0 +1 @@
{"after":"off","before":"live","consumer":"notify","environment":"dev","revision":4}
@@ -0,0 +1 @@
{"after":"shadow","before":"off","consumer":"notify","environment":"dev","revision":2}
@@ -0,0 +1 @@
{"after":"live","before":"off","consumer":"projection","environment":"dev","revision":5}
@@ -0,0 +1 @@
{"after":"live","before":"shadow","consumer":"projection","environment":"dev","revision":3}
@@ -0,0 +1 @@
{"after":"off","before":"live","consumer":"projection","environment":"dev","revision":4}
@@ -0,0 +1 @@
{"after":"shadow","before":"off","consumer":"projection","environment":"dev","revision":2}
@@ -0,0 +1 @@
{"quarantined":4}
@@ -0,0 +1,4 @@
{
"status": 401,
"body": "{\"error_code\":\"ERR_UNAUTHENTICATED\",\"message\":\"请先登录\"}\n"
}
+113
View File
@@ -0,0 +1,113 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"time"
"chat-agent-system/internal/config"
"chat-agent-system/internal/events"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
envPath := os.Getenv("EVENTMODE_ENV")
if envPath == "" {
envPath = "configs/.env.dev"
}
cfg, err := config.Load(envPath)
if err != nil {
fmt.Fprintln(os.Stderr, "eventmode: load env")
os.Exit(1)
}
if cfg.Env != events.OpsCutoverEnvironment {
fmt.Fprintln(os.Stderr, "eventmode: env must be dev")
os.Exit(1)
}
if cfg.ContextDbName != "agent_system_dev" {
fmt.Fprintln(os.Stderr, "eventmode: database must be agent_system_dev")
os.Exit(1)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, cfg.ContextDSN())
if err != nil {
fmt.Fprintln(os.Stderr, "eventmode: pool")
os.Exit(1)
}
defer pool.Close()
switch os.Args[1] {
case "inventory":
if err := runInventory(ctx, pool); err != nil {
fmt.Fprintln(os.Stderr, "eventmode: inventory failed")
os.Exit(1)
}
case "set":
if len(os.Args) != 4 {
usage()
os.Exit(2)
}
consumer, mode := os.Args[2], os.Args[3]
before, _, _ := events.LoadMode(ctx, pool, cfg.Env, consumer)
if err := events.ApplyOpsCutover(ctx, pool, cfg.Env, consumer, mode); err != nil {
fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1)
}
after, rev, _ := events.LoadMode(ctx, pool, cfg.Env, consumer)
enc := json.NewEncoder(os.Stdout)
_ = enc.Encode(map[string]any{
"environment": cfg.Env, "consumer": consumer,
"before": before, "after": after, "revision": rev,
})
case "quarantine-illegal":
n, err := events.QuarantineIllegalPending(ctx, pool, "cursor-ops", "phase2-shared-live")
if err != nil {
fmt.Fprintln(os.Stderr, "eventmode: quarantine failed")
os.Exit(1)
}
_ = json.NewEncoder(os.Stdout).Encode(map[string]any{"quarantined": n})
default:
usage()
os.Exit(2)
}
}
func runInventory(ctx context.Context, pool *pgxpool.Pool) error {
modes, err := events.ListModes(ctx, pool)
if err != nil {
return err
}
backlog, err := events.ListBacklog(ctx, pool)
if err != nil {
return err
}
pending, err := events.ClassifyPending(ctx, pool)
if err != nil {
return err
}
type wire struct {
EventID string `json:"event_id"`
Consumer string `json:"consumer"`
Classification string `json:"classification"`
Reason string `json:"reason"`
}
class := make([]wire, 0, len(pending))
for _, r := range pending {
class = append(class, wire{r.EventID, r.Consumer, r.Classification, r.Reason})
}
return json.NewEncoder(os.Stdout).Encode(map[string]any{
"modes": modes, "backlog": backlog, "pending_classifications": class,
})
}
func usage() {
fmt.Fprintln(os.Stderr, "usage: eventmode inventory | set <consumer> <mode> | quarantine-illegal")
}
+3 -9
View File
@@ -201,22 +201,16 @@ func (r *Runtime) bindDocumentNotify() {
if r == nil || r.notifyBound {
return
}
if r.Biz != nil && r.Rev != nil {
if docnotify.Bind(r.Rev, r.DocNotify) {
r.subscribers++
}
}
// Durable notify is live; do not assign OnOutbox in production.
r.notifyBound = true
}
func (r *Runtime) bindWorkflowApprovalHook() {
if r == nil || r.hookBound || r.Gov == nil || r.Inst == nil || !r.Inst.Available() {
r.hookBound = true
if r == nil || r.hookBound {
return
}
r.Gov.AddOnApprovedHook(r.onWorkflowDocumentApproved)
// Durable workflow_advance is live; do not register AddOnApprovedHook in production.
r.hookBound = true
r.subscribers++
}
// WriterLive reports whether the durable consumer already owns the visible write.
+48
View File
@@ -0,0 +1,48 @@
package app
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestProductionCompositionHasNoHookRegistration(t *testing.T) {
root := findGoMod(t)
files := []string{
filepath.Join(root, "internal", "app", "app.go"),
filepath.Join(root, "internal", "server", "workflow_approval_hook.go"),
filepath.Join(root, "internal", "server", "server.go"),
}
for _, f := range files {
b, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
src := string(b)
if strings.Contains(src, "OnOutbox =") {
t.Fatalf("%s still assigns OnOutbox", f)
}
if strings.Contains(src, "AddOnApprovedHook(") {
t.Fatalf("%s still registers AddOnApprovedHook", f)
}
}
}
func findGoMod(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("go.mod not found")
}
dir = parent
}
}
+170
View File
@@ -0,0 +1,170 @@
package events
import (
"context"
"fmt"
"strings"
"chat-agent-system/internal/app/uow"
"github.com/jackc/pgx/v5/pgxpool"
)
const OpsCutoverEnvironment = "dev"
// ValidateOpsCutover is the shared-dev gate for cluster mode changes.
// test/main, FAV, and unknown consumers are rejected before any write.
func ValidateOpsCutover(environment, consumer, mode string) error {
if strings.TrimSpace(environment) != OpsCutoverEnvironment {
return fmt.Errorf("%w: environment %q", ErrOpsCutover, environment)
}
switch consumer {
case ConsumerNotify, ConsumerWorkflowAdvance, ConsumerProjection:
default:
return fmt.Errorf("%w: consumer %q", ErrOpsCutover, consumer)
}
switch mode {
case ModeOff, ModeShadow, ModeLive:
default:
return fmt.Errorf("%w: mode %q", ErrOpsCutover, mode)
}
return nil
}
func ApplyOpsCutover(ctx context.Context, pool *pgxpool.Pool, environment, consumer, mode string) error {
if err := ValidateOpsCutover(environment, consumer, mode); err != nil {
return err
}
return SetMode(ctx, pool, environment, consumer, mode)
}
type ModeSnapshot struct {
Environment string `json:"environment"`
Consumer string `json:"consumer"`
Mode string `json:"mode"`
Revision int64 `json:"revision"`
}
type BacklogRow struct {
Consumer string `json:"consumer"`
Status string `json:"status"`
Count int64 `json:"count"`
}
func ListModes(ctx context.Context, pool *pgxpool.Pool) ([]ModeSnapshot, error) {
rows, err := pool.Query(ctx, `
SELECT environment, consumer, mode, revision
FROM event_consumer_modes
ORDER BY environment, consumer
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ModeSnapshot
for rows.Next() {
var m ModeSnapshot
if err := rows.Scan(&m.Environment, &m.Consumer, &m.Mode, &m.Revision); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
func ListBacklog(ctx context.Context, pool *pgxpool.Pool) ([]BacklogRow, error) {
rows, err := pool.Query(ctx, `
SELECT consumer, status, COUNT(*)
FROM event_deliveries
GROUP BY consumer, status
ORDER BY consumer, status
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []BacklogRow
for rows.Next() {
var r BacklogRow
if err := rows.Scan(&r.Consumer, &r.Status, &r.Count); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
type pendingDelivery struct {
EventID string
Consumer string
EventName string
Environment string
ProjectID int64
SchemaVersion int
PayloadDigest string
}
func loadPending(ctx context.Context, pool *pgxpool.Pool) ([]pendingDelivery, error) {
rows, err := pool.Query(ctx, `
SELECT d.event_id, d.consumer, e.event_name, e.environment, e.project_id, e.schema_version, COALESCE(e.payload_digest, '')
FROM event_deliveries d
INNER JOIN event_envelopes e ON e.event_id = d.event_id
WHERE d.status IN ('pending','leased')
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []pendingDelivery
for rows.Next() {
var p pendingDelivery
if err := rows.Scan(&p.EventID, &p.Consumer, &p.EventName, &p.Environment, &p.ProjectID, &p.SchemaVersion, &p.PayloadDigest); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
func ClassifyPending(ctx context.Context, pool *pgxpool.Pool) ([]InventoryRow, error) {
pending, err := loadPending(ctx, pool)
if err != nil {
return nil, err
}
out := make([]InventoryRow, 0, len(pending))
for _, p := range pending {
env := Envelope{
EventID: p.EventID, EventName: p.EventName, SchemaVersion: p.SchemaVersion,
Environment: p.Environment, ProjectID: p.ProjectID, PayloadDigest: p.PayloadDigest,
}
out = append(out, ClassifyEnvelope(env, p.Consumer))
}
return out, nil
}
// QuarantineIllegalPending persists quarantine for pending deliveries whose
// consumer is not on the contract (including FAV). Safe envelopes are left untouched.
func QuarantineIllegalPending(ctx context.Context, pool *pgxpool.Pool, operator, approval string) (int, error) {
if operator == "" || approval == "" {
return 0, ErrIntegrityFailure
}
rows, err := ClassifyPending(ctx, pool)
if err != nil {
return 0, err
}
n := 0
unit := uow.NewPG(pool)
for _, row := range rows {
if row.Classification != ClassificationQuarantine {
continue
}
err := unit.Within(ctx, func(scope uow.TxScope) error {
return PersistInventory(uow.WithScope(ctx, scope), row, operator, approval)
})
if err != nil {
return n, err
}
n++
}
return n, nil
}
+44
View File
@@ -0,0 +1,44 @@
package events_test
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestEventConsumerModeScriptGates(t *testing.T) {
root := repoRoot(t)
text, err := os.ReadFile(filepath.Join(root, "scripts", "event-consumer-mode.sh"))
if err != nil {
t.Fatal(err)
}
s := string(text)
if !strings.Contains(s, "fav") || !strings.Contains(s, "refusing test/main") {
t.Fatal("script must reject fav and test/main")
}
if strings.Contains(s, "5434") || strings.Contains(s, "5435") {
t.Fatal("script must not pin test/main ports")
}
if !strings.Contains(s, "agent_system_dev") || !strings.Contains(s, "5433") {
t.Fatal("script must pin shared dev db")
}
}
func repoRoot(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("go.mod not found")
}
dir = parent
}
}
+34
View File
@@ -0,0 +1,34 @@
package events
import "testing"
func TestValidateOpsCutoverRejectsFavAndNonDev(t *testing.T) {
if err := ValidateOpsCutover("dev", ConsumerNotify, ModeShadow); err != nil {
t.Fatal(err)
}
if err := ValidateOpsCutover("test", ConsumerNotify, ModeLive); err == nil {
t.Fatal("test env must fail")
}
if err := ValidateOpsCutover("main", ConsumerNotify, ModeLive); err == nil {
t.Fatal("main env must fail")
}
if err := ValidateOpsCutover("dev", ConsumerFAV, ModeOff); err == nil {
t.Fatal("fav must fail even at off")
}
if err := ValidateOpsCutover("dev", "unknown", ModeLive); err == nil {
t.Fatal("unknown consumer must fail")
}
if err := ValidateOpsCutover("dev", ConsumerNotify, "dual"); err == nil {
t.Fatal("unknown mode must fail")
}
}
func TestValidateOpsCutoverAllowsThreeConsumers(t *testing.T) {
for _, c := range []string{ConsumerNotify, ConsumerWorkflowAdvance, ConsumerProjection} {
for _, m := range []string{ModeOff, ModeShadow, ModeLive} {
if err := ValidateOpsCutover("dev", c, m); err != nil {
t.Fatalf("%s %s: %v", c, m, err)
}
}
}
}
+1
View File
@@ -23,6 +23,7 @@ var (
ErrDisabledConsumer = errors.New("events: consumer is disabled")
ErrUnregistered = errors.New("events: event or consumer is not in the static registry")
ErrReplayApply = errors.New("events: replay apply rejected")
ErrOpsCutover = errors.New("events: shared cutover rejected")
)
type Envelope struct {
+3 -4
View File
@@ -9,10 +9,9 @@ import (
)
func (s *Server) registerWorkflowApprovalHook() {
if s == nil || s.gov == nil || s.instSvc == nil || !s.instSvc.Available() {
return
}
s.gov.AddOnApprovedHook(s.onWorkflowDocumentApproved)
// Durable workflow_advance is the unique writer. Keep the method for one
// rollback window; do not register AddOnApprovedHook in production.
return
}
func (s *Server) onWorkflowDocumentApproved(ctx context.Context, doc *docgov.DocumentRecord) {
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Shared agent-dev event consumer mode switch.
# Only environment=dev, consumers notify|workflow_advance|projection, modes off|shadow|live.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
ENV_FILE="${EVENTMODE_ENV:-configs/.env.dev}"
usage() {
echo "usage: $0 inventory | set <consumer> <mode> | quarantine-illegal" >&2
exit 2
}
if [[ $# -lt 1 ]]; then
usage
fi
if [[ ! -f "$ENV_FILE" ]]; then
echo "event-consumer-mode: missing $ENV_FILE" >&2
exit 1
fi
if grep -E -q '^ENV=(test|main)[[:space:]]*$' "$ENV_FILE"; then
echo "event-consumer-mode: refusing test/main env file" >&2
exit 1
fi
# Host-side ops pin the published dev database; never test/main ports.
export CONTEXT_DB_HOST="${CONTEXT_DB_HOST:-127.0.0.1}"
export CONTEXT_DB_PORT="${CONTEXT_DB_PORT:-5433}"
export CONTEXT_DB_NAME="${CONTEXT_DB_NAME:-agent_system_dev}"
export ENV=dev
export EVENTMODE_ENV="$ENV_FILE"
case "$1" in
inventory)
go run ./cmd/eventmode inventory
;;
set)
if [[ $# -ne 3 ]]; then
usage
fi
consumer="$2"
mode="$3"
if [[ "$consumer" == "fav" ]]; then
echo "event-consumer-mode: fav is disabled" >&2
exit 1
fi
go run ./cmd/eventmode set "$consumer" "$mode"
;;
quarantine-illegal)
go run ./cmd/eventmode quarantine-illegal
;;
*)
usage
;;
esac