merge: feature/multi-intent-u8-task-generate
This commit is contained in:
+4
-2
@@ -5,7 +5,7 @@
|
||||
|
||||
## 焦点
|
||||
|
||||
1. **MULTI-INTENT HITL**:U7 已合入本地 `dev`;下一单元 U8 task.generate
|
||||
1. **MULTI-INTENT HITL**:U8 进行中(`feature/multi-intent-u8-task-generate`)
|
||||
2. **发布门禁**:CI `TEST_DATABASE_URL`、真实 PI 三工作流 E2E、前端人工抽检仍开放([gap-matrix](../remediation/gap-matrix.md))
|
||||
3. **CHAT-INTENT**:双标签 busy 已签(项目 **6275**);不删 `sess-p143-u9`;项目 143 只读
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
| owner | task | branch | worktree | mutex | status |
|
||||
|-------|------|--------|----------|-------|--------|
|
||||
| Cursor | 2026-09-16-multi-intent-u8-task-generate | feature/multi-intent-u8-task-generate | - | intent-plan | done |
|
||||
| Cursor | 2026-09-16-multi-intent-u7-auto-read | feature/multi-intent-u7-auto-read | - | intent-plan | done |
|
||||
| Cursor | 2026-09-16-multi-intent-u6-web-cards | feature/multi-intent-u6-web-cards | - | web | done |
|
||||
| Cursor | 2026-09-16-multi-intent-u5-decision-api | feature/multi-intent-u5-decision-api | - | intent-plan | done |
|
||||
@@ -91,7 +92,7 @@
|
||||
|
||||
| holder | phase | since | scope | queue |
|
||||
|--------|-------|-------|-------|-------|
|
||||
| - | idle | - | - | - |
|
||||
| Cursor | merging | 2026-09-16 | multi-intent-u8 | - |
|
||||
|
||||
**phase**: `idle` | `merging` | `smoke` | `e2e`
|
||||
|
||||
@@ -131,6 +132,7 @@
|
||||
| 多意图 HITL U4 投影 | [../test-reports/2026-09-16-multi-intent-u4-outbox-projection-test-report.md](../test-reports/2026-09-16-multi-intent-u4-outbox-projection-test-report.md) |
|
||||
| 多意图 HITL U5 API | [../test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md](../test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md) |
|
||||
| 多意图 HITL U6 Web | [../test-reports/2026-09-16-multi-intent-u6-web-cards-test-report.md](../test-reports/2026-09-16-multi-intent-u6-web-cards-test-report.md) |
|
||||
| 多意图 HITL U8 generate | [../test-reports/2026-09-16-multi-intent-u8-task-generate-test-report.md](../test-reports/2026-09-16-multi-intent-u8-task-generate-test-report.md) |
|
||||
| 多意图 HITL U7 只读 | [../test-reports/2026-09-16-multi-intent-u7-auto-read-test-report.md](../test-reports/2026-09-16-multi-intent-u7-auto-read-test-report.md) |
|
||||
| 文档可靠性 M1 报告 | [../test-reports/2026-09-14-document-reliability-pilot-test-report.md](../test-reports/2026-09-14-document-reliability-pilot-test-report.md) |
|
||||
| 文档可靠性 M1 活验 | [../test-reports/2026-09-14-document-reliability-pilot-live-test-report.md](../test-reports/2026-09-14-document-reliability-pilot-live-test-report.md) |
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
title: "U8 task generate checklist"
|
||||
status: done
|
||||
created: 2026-09-16
|
||||
updated: 2026-09-16
|
||||
related_task: "2026-09-16-multi-intent-u8-task-generate"
|
||||
---
|
||||
|
||||
# U8
|
||||
|
||||
- [x] confirm 话语落盘 awaiting,确认前不执行 — 证据:`TestHandleTurnConfirmPersistsAwaitingWithoutExecute`
|
||||
- [x] 第二活动写计划冲突 — 证据:`TestHandleTurnConfirmRejectsSecondActiveWritePlan`
|
||||
- [x] OwnerMux 路由 + nil Task fail closed — 证据:`TestOwnerMuxRoutesGenerateToTask` / `TestOwnerMuxNilTaskFailClosed`
|
||||
- [x] 确认后 generate 一次、重放不双跑 — 证据:`TestConfirmThenMuxExecutesGenerateOnce`
|
||||
- [x] adapter 幂等 / 拒查询 / 撤销 — 证据:`TestTaskRunAdapter*`
|
||||
- [x] LiveTaskStarter 同键重放 — 证据:`TestLiveTaskStarterReplaysIdempotencyKey`
|
||||
- [x] QueryGuard 仍拒 task.run;auto_read 写话语不落盘 — 证据:U7 回归 + `TestQueryGuardRejectsGenerate`
|
||||
- [x] 真实 PI stop-gate — 证据:`TestTaskGenerateRealPIStopGate` SKIP
|
||||
- [x] arch-check block 绿 — 证据:[报告](../../test-reports/2026-09-16-multi-intent-u8-task-generate-test-report.md)
|
||||
|
||||
## 完成定义
|
||||
|
||||
- [x] 全部勾选且有证据链接
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
title: "多意图持久计划与 HITL(核心 U1–U8,扩面 U9–U12)"
|
||||
status: in_progress
|
||||
status: done
|
||||
created: 2026-09-15
|
||||
updated: 2026-09-15
|
||||
owner: 待认领(下一单元 U5)
|
||||
updated: 2026-09-16
|
||||
owner: Cursor
|
||||
branch: "-"
|
||||
worktree: "-"
|
||||
mutex: "-"
|
||||
@@ -58,7 +58,7 @@ mutex: "-"
|
||||
5. [x] U5 建统一 decision command 与动作 owner 授权端口([独立任务](./2026-09-16-multi-intent-u5-decision-api.md);[报告](../../test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md));
|
||||
6. [x] U6 取消计划动作的前端本地成功路径,以 PlanView 恢复([独立任务](./2026-09-16-multi-intent-u6-web-cards.md);[报告](../../test-reports/2026-09-16-multi-intent-u6-web-cards-test-report.md));
|
||||
7. [x] U7 只开放独立低风险查询([独立任务](./2026-09-16-multi-intent-u7-auto-read.md);[报告](../../test-reports/2026-09-16-multi-intent-u7-auto-read-test-report.md));
|
||||
8. [ ] U8 只接 task generate,完成首版 real PI 闭环;
|
||||
8. [x] U8 只接 task generate,完成首版闭环(真实 PI 环境缺失记 stop-gate)([独立任务](./2026-09-16-multi-intent-u8-task-generate.md);[报告](../../test-reports/2026-09-16-multi-intent-u8-task-generate-test-report.md));
|
||||
9. [ ] U9 作为独立可选实验接语义分段;
|
||||
10. [ ] U10–U12 分别按 mutate/docgov/workflow/run-control owner 扩面。
|
||||
|
||||
@@ -92,9 +92,9 @@ mutex: "-"
|
||||
|
||||
## 相关链接
|
||||
|
||||
- Checklist: [U1](../checklists/2026-09-15-multi-intent-u1-contracts.md);[U2](../checklists/2026-09-15-multi-intent-u2-segmenter.md);[U3](../checklists/2026-09-15-multi-intent-u3-intentflow-pg.md)
|
||||
- Checklist: [U1](../checklists/2026-09-15-multi-intent-u1-contracts.md);[U2](../checklists/2026-09-15-multi-intent-u2-segmenter.md);[U3](../checklists/2026-09-15-multi-intent-u3-intentflow-pg.md);[U8](../checklists/2026-09-16-multi-intent-u8-task-generate.md)
|
||||
- Handoff: 每个 U-ID 独立建立
|
||||
- Test report: [U1](../../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md);[U2](../../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md);[U3](../../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md);[U4](../../test-reports/2026-09-16-multi-intent-u4-outbox-projection-test-report.md);[U5](../../test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md)
|
||||
- Test report: [U1](../../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md);[U2](../../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md);[U3](../../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md);[U4](../../test-reports/2026-09-16-multi-intent-u4-outbox-projection-test-report.md);[U5](../../test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md);[U6](../../test-reports/2026-09-16-multi-intent-u6-web-cards-test-report.md);[U7](../../test-reports/2026-09-16-multi-intent-u7-auto-read-test-report.md);[U8](../../test-reports/2026-09-16-multi-intent-u8-task-generate-test-report.md)
|
||||
|
||||
## 证据 / 备注
|
||||
|
||||
@@ -102,4 +102,4 @@ mutex: "-"
|
||||
- 当前 `module-boundaries.yaml` 中 intent 仅依赖 maturity;Track E live,FAV off。
|
||||
- U1 已冻结 intentflow 契约、事件与 BatchActionPolicy,默认 `MULTI_INTENT_PLAN_TIER=legacy`。
|
||||
- U2 已落地规则分段与 shadow `PlanProposal`;生产 TopIntent / IntentPlan 不变;反事实 `false_auto_execute=0`。
|
||||
- U3 已落地 PG 计划账本与 CAS/lease/fencing;HandleTurn 默认不落盘。下一单元 U4:outbox/投影。共享环境 schema 44 待晋升窗口。
|
||||
- U8 已落地 confirm 落盘与 `task.run` adapter;默认 legacy 不落盘。U9–U12 不阻塞核心首版。共享环境 schema 45 待晋升窗口。
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: "多意图 HITL U8:task generate adapter"
|
||||
status: done
|
||||
created: 2026-09-16
|
||||
updated: 2026-09-16
|
||||
owner: Cursor
|
||||
branch: feature/multi-intent-u8-task-generate
|
||||
worktree: "-"
|
||||
mutex: intent-plan
|
||||
---
|
||||
|
||||
# 多意图 HITL U8:task generate adapter
|
||||
|
||||
## 目标
|
||||
|
||||
`confirm` tier 下查询+生成落盘 awaiting;确认后仅开放 `task.run` / `task.run.adhoc`,稳定幂等键,不改单意图 taskrun HTTP。
|
||||
|
||||
## 范围
|
||||
|
||||
- HandleTurn:`ComboNeedsConfirm` 且 tier≥confirm 时 WritePlan 落盘,不自动执行
|
||||
- `OwnerMux` 把 generate 路由到 Task,其余走 QueryGuard
|
||||
- `TaskRunAdapter` + `LiveTaskStarter`(无 `PI_AGENT_RUNNER_URL` 只占位不启 PI)
|
||||
- Assemble 接线;默认 `MULTI_INTENT_PLAN_TIER=legacy` 不变
|
||||
|
||||
## 非目标
|
||||
|
||||
- U9 LLM 分段、U10–U12 owner 扩面
|
||||
- 不 rebuild 共享环境、不改项目 143
|
||||
- 真实 PI 闭环若环境缺失记 stop-gate,不冒充验收
|
||||
|
||||
## 步骤
|
||||
|
||||
1. [x] 从 `dev` 开 `feature/multi-intent-u8-task-generate`
|
||||
2. [x] confirm 落盘 + mux + adapter
|
||||
3. [x] 单测与 arch-check
|
||||
4. [x] 报告并合回
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [x] 多意图生成确认前零执行
|
||||
- [x] 活动写计划冲突
|
||||
- [x] 确认后 mux 执行 generate,重放 starts=1
|
||||
- [x] QueryGuard 仍拒绝 generate;权限撤销 fail closed
|
||||
- [x] 真实 PI 无 URL 时 skip(stop-gate)
|
||||
|
||||
## 相关链接
|
||||
|
||||
- Checklist: [../checklists/2026-09-16-multi-intent-u8-task-generate.md](../checklists/2026-09-16-multi-intent-u8-task-generate.md)
|
||||
- Test report: [../../test-reports/2026-09-16-multi-intent-u8-task-generate-test-report.md](../../test-reports/2026-09-16-multi-intent-u8-task-generate-test-report.md)
|
||||
- Parent: [./2026-09-15-multi-intent-hitl.md](./2026-09-15-multi-intent-hitl.md)
|
||||
|
||||
## 证据 / 备注
|
||||
|
||||
默认 legacy HandleTurn 仍不落盘。真实 PI 见报告 stop-gate。
|
||||
@@ -15,8 +15,8 @@
|
||||
|
||||
| 模块 | 能力 | 状态 | 证据 | 缺口/后续 | mutex |
|
||||
|------|------|------|------|-----------|-------|
|
||||
| 意图识别 L1 | 18 类 Intent + 三分支消解 | partial | [system-design/intent-recognition](../system-design/intent-recognition/);[现场专项审计](../audit/2026-09-13-conversation-intent-runtime-state.md);[运行态报告](../test-reports/2026-09-13-conversation-intent-runtime-state-test-report.md);[双标签活验](../test-reports/assets/2026-09-15-conversation-intent-dual-tab/live.json);[多意图复核方案](../system-design/2026-09-15-multi-intent-hitl-plan.md);[U1 契约](../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md);[U2 分段](../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md);[U3 PG](../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md) | 明确写动作与只读总结已分流;双标签 `DOC_BUSY` 已签(项目 6275)。多意图 U1–U5 已完成;U6–U8 未实施;intent 纯依赖边界不得回退 | `intent` |
|
||||
| 多意图持久计划 | app/intentflow 生命周期与 HITL 契约 | partial | [设计](../system-design/2026-09-15-multi-intent-hitl-plan.md);[U1](../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md)–[U6](../test-reports/2026-09-16-multi-intent-u6-web-cards-test-report.md);[U7](../test-reports/2026-09-16-multi-intent-u7-auto-read-test-report.md) | U1–U7 已完成。U8 接 task.generate。共享环境 schema 45 待晋升 | `intent-plan` |
|
||||
| 意图识别 L1 | 18 类 Intent + 三分支消解 | partial | [system-design/intent-recognition](../system-design/intent-recognition/);[现场专项审计](../audit/2026-09-13-conversation-intent-runtime-state.md);[运行态报告](../test-reports/2026-09-13-conversation-intent-runtime-state-test-report.md);[双标签活验](../test-reports/assets/2026-09-15-conversation-intent-dual-tab/live.json);[多意图复核方案](../system-design/2026-09-15-multi-intent-hitl-plan.md);[U1 契约](../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md);[U2 分段](../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md);[U3 PG](../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md) | 明确写动作与只读总结已分流;双标签 `DOC_BUSY` 已签(项目 6275)。多意图核心 U1–U8 已完成;U9–U12 不阻塞首版。intent 纯依赖边界不得回退 | `intent` |
|
||||
| 多意图持久计划 | app/intentflow 生命周期与 HITL 契约 | partial | [设计](../system-design/2026-09-15-multi-intent-hitl-plan.md);[U1](../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md)–[U8](../test-reports/2026-09-16-multi-intent-u8-task-generate-test-report.md) | U1–U8 核心首版已完成。U9–U12 不阻塞。默认 tier=legacy。真实 PI generate 待 runner URL。共享环境 schema 45 待晋升 | `intent-plan` |
|
||||
| 对话 / ctxmgr | 多轮上下文、记忆、摘要 | partial | [system-design/multi-turn-dialogue](../system-design/multi-turn-dialogue/);[刷新恢复专项审计](../audit/2026-09-13-chat-history-refresh-disappears.md);[U10 端口化](../archive/ops/2026-09-15-chatlog-contextasm-u10.md);[U10 报告](../test-reports/2026-09-15-chatlog-contextasm-u10-test-report.md) | `CHAT-HISTORY-REFRESH` 闲聊刷新活验已补。U10 原地端口 **停止扩面**(未物理拆包)。语义摘要(D5 backlog) | `chat` |
|
||||
| 智能体小队 | 角色-任务-工作区-黑板 | done | [system-design/agent-squad](../system-design/agent-squad/);[test 2026-09-07](../test-reports/2026-09-07-dynamic-squad-planning-test-report.md) | 非关键步骤降级(A5/A6 backlog) | `squad` |
|
||||
| 工作流治理 | 三正式工作流 + 阶段门禁 | partial | [remediation/three-workflows](../remediation/2026-09-12-three-workflows-strict-governance-remediation.md);[test 2026-09-12](../test-reports/2026-09-12-three-workflows-governance-remediation-test-report.md);[冻结态/职责可选整改](../remediation/2026-09-14-workflow-plan-409-frozen-and-optional-duties-remediation.md);[Wave 5 页面矩阵](../test-reports/2026-09-14-workflow-plan-409-frozen-and-optional-duties-test-report.md);[职责-动作鉴权](../test-reports/2026-09-14-governance-action-level-authz-test-report.md) | 设置页/DutyMode + 动作级鉴权/未激活 202-blocked/关闭创建直通已落地。后续:none 文档真实 PI 审批长链路、CI 三工作流 E2E | `workflow` |
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# 多意图 HITL U8 task generate adapter 测试报告
|
||||
|
||||
## 一、 测试背景 (Background)
|
||||
|
||||
U7 只在 `auto_read` 执行独立查询,`QueryGuard` 拒绝 `task.run`。U8 要在 `confirm` tier 把「查询+生成」落成 awaiting 写计划,确认后经 `OwnerMux` 只开放 `task.run` / `task.run.adhoc`,并用稳定幂等键避免双跑。默认 `MULTI_INTENT_PLAN_TIER=legacy` 仍不得落盘。
|
||||
|
||||
## 二、 测试目标 (Test Objectives)
|
||||
|
||||
- confirm 话语确认前零 generate 副作用
|
||||
- 同 scope 第二活动写计划冲突
|
||||
- 确认后 mux 执行 generate,重放 `StartCount=1`
|
||||
- QueryGuard 继续拒绝 generate;adapter 拒绝查询 key;撤销 Denied fail closed
|
||||
- `LiveTaskStarter` 同幂等键重放
|
||||
- 无 `PI_AGENT_RUNNER_URL` 时真实 PI 记 stop-gate(SKIP,不冒充验收)
|
||||
- arch-check / `-short` 相关包绿
|
||||
|
||||
## 三、 测试方案与环境 (Test Plan & Environment)
|
||||
|
||||
| 项 | 值 |
|
||||
|----|-----|
|
||||
| 分支 | `feature/multi-intent-u8-task-generate` |
|
||||
| OS | linux |
|
||||
| 账本 | 内存 `SerialUoW` + `MemoryStore` |
|
||||
| Owner | `OwnerMux{Query: QueryGuard{Fake}, Task: TaskRunAdapter}` |
|
||||
| 真实 PI | `PI_AGENT_RUNNER_URL` 未设置 → SKIP |
|
||||
| 共享环境 | 不 rebuild `:8080`;项目 143 只读 |
|
||||
|
||||
## 四、 预期结果 (Expected Results)
|
||||
|
||||
| 用例 | 预期 |
|
||||
|------|------|
|
||||
| legacy / auto_read 写话语 | 不落盘 |
|
||||
| confirm + 「查一下相关法规,然后帮我生成报价单」 | awaiting,步骤 pending |
|
||||
| 第二 ClientTurn 同 scope | `PLAN_DECISION_CONFLICT` |
|
||||
| 确认 + AdvanceEligible | generate starts=1;再推进仍为 1 |
|
||||
| QueryGuard `task.run` | `PLAN_ACTION_FORBIDDEN` |
|
||||
| 真实 PI | 无 URL 则 SKIP |
|
||||
|
||||
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
|
||||
|
||||
### 5.1 HandleTurn confirm
|
||||
|
||||
- **测试输入**:`go test ./internal/app/intentflow -run 'TestHandleTurn|TestOwnerMux|TestQueryGuard' -count=1 -v`
|
||||
- **实际输出**:`TestHandleTurnConfirmPersistsAwaitingWithoutExecute`、`TestHandleTurnConfirmRejectsSecondActiveWritePlan`、`TestHandleTurnWriteUtteranceStaysLegacyUnderAutoRead`、`TestQueryGuardRejectsGenerate`、`TestOwnerMux*` PASS。完整日志:[assets/2026-09-16-multi-intent-u8-task-generate/intentflow.txt](./assets/2026-09-16-multi-intent-u8-task-generate/intentflow.txt)
|
||||
- **评价判定**:PASS。
|
||||
- **性能与耗时**:8ms
|
||||
|
||||
### 5.2 Adapter / mux 闭环
|
||||
|
||||
- **测试输入**:`go test ./internal/app -run 'TestTask|TestConfirmThenMux|TestLiveTask' -count=1 -v`
|
||||
- **实际输出**:`TestConfirmThenMuxExecutesGenerateOnce` PASS(确认前 starts=0,确认后 starts=1);`TestTaskRunAdapterIdempotentExecute` / `RejectsQueryKey` / `DeniedAfterRevoke` / `TestLiveTaskStarterReplaysIdempotencyKey` PASS;`TestTaskGenerateRealPIStopGate` SKIP(`PI_AGENT_RUNNER_URL` 未设置)。日志:[assets/2026-09-16-multi-intent-u8-task-generate/app-adapter.txt](./assets/2026-09-16-multi-intent-u8-task-generate/app-adapter.txt)
|
||||
- **评价判定**:PASS(真实 PI 为 stop-gate,不计入首版冒充验收)。
|
||||
- **性能与耗时**:5ms
|
||||
|
||||
### 5.3 回归与边界
|
||||
|
||||
- **测试输入**:`go test -short ./internal/app/intentflow ./internal/app ./internal/archtest ./internal/chat ./internal/server ./internal/taskrun`;`./scripts/arch-check.sh --mode block`
|
||||
- **实际输出**:全部 ok;`arch-check block: production gate passed`。
|
||||
- **评价判定**:PASS。
|
||||
- **性能与耗时**:约 13s(含 server)
|
||||
|
||||
## 六、 自动化测试套件汇总 (Test Suite Execution)
|
||||
|
||||
| 命令 | 结果 |
|
||||
|------|------|
|
||||
| `go test ./internal/app/intentflow ./internal/app ./internal/archtest` | PASS |
|
||||
| `go test -short ./internal/chat ./internal/server ./internal/taskrun` | PASS |
|
||||
| `./scripts/arch-check.sh --mode block` | PASS |
|
||||
| `TestTaskGenerateRealPIStopGate` | SKIP(无 `PI_AGENT_RUNNER_URL`) |
|
||||
|
||||
未跑 `go test ./internal/chat` 无 `-short`:既有 `TestChatSession_SemanticMemoryRecallAndAsyncTurnRecord` flake,与 U8 无关。
|
||||
|
||||
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
|
||||
|
||||
U8 达到合入标准:核心首版 U1–U8 在默认 legacy 零行为、confirm 确认后只开放 generate adapter 的前提下可关闭。U9–U12 不阻塞首版。真实 PI 生成闭环仍待 `PI_AGENT_RUNNER_URL` 窗口;共享环境 schema 仍为 43,goose 45 待晋升窗口,本单元不 rebuild。
|
||||
@@ -0,0 +1,20 @@
|
||||
=== RUN TestTaskRunAdapterIdempotentExecute
|
||||
--- PASS: TestTaskRunAdapterIdempotentExecute (0.00s)
|
||||
=== RUN TestTaskRunAdapterRejectsQueryKey
|
||||
--- PASS: TestTaskRunAdapterRejectsQueryKey (0.00s)
|
||||
=== RUN TestTaskRunAdapterDeniedAfterRevoke
|
||||
--- PASS: TestTaskRunAdapterDeniedAfterRevoke (0.00s)
|
||||
=== RUN TestConfirmThenMuxExecutesGenerateOnce
|
||||
2026/09/16 02:17:58 INFO intent_recognition intent=QUERY_REGULATIONS branch=NEW_INTENT_PREEMPT confidence=0.9
|
||||
2026/09/16 02:17:58 INFO intent_recognition intent=QUERY_REGULATIONS branch=NEW_INTENT_PREEMPT confidence=0.9
|
||||
2026/09/16 02:17:58 INFO intent_recognition intent=TRIGGER_TASK branch=NEW_INTENT_PREEMPT confidence=0.86
|
||||
2026/09/16 02:17:58 INFO intent_recognition intent=QUERY_REGULATIONS branch=NEW_INTENT_PREEMPT confidence=0.9
|
||||
2026/09/16 02:17:58 INFO intent_recognition intent=TRIGGER_TASK branch=NEW_INTENT_PREEMPT confidence=0.86
|
||||
--- PASS: TestConfirmThenMuxExecutesGenerateOnce (0.00s)
|
||||
=== RUN TestLiveTaskStarterReplaysIdempotencyKey
|
||||
--- PASS: TestLiveTaskStarterReplaysIdempotencyKey (0.00s)
|
||||
=== RUN TestTaskGenerateRealPIStopGate
|
||||
plan_owner_test.go:125: stop-gate: PI_AGENT_RUNNER_URL not set; real PI generate is not claimed
|
||||
--- SKIP: TestTaskGenerateRealPIStopGate (0.00s)
|
||||
PASS
|
||||
ok chat-agent-system/internal/app 0.005s
|
||||
@@ -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.172s
|
||||
ok chat-agent-system/internal/archtest 0.154s
|
||||
arch-check block: production gate passed
|
||||
@@ -0,0 +1,49 @@
|
||||
=== RUN TestOwnerMuxRoutesGenerateToTask
|
||||
--- PASS: TestOwnerMuxRoutesGenerateToTask (0.00s)
|
||||
=== RUN TestOwnerMuxNilTaskFailClosed
|
||||
--- PASS: TestOwnerMuxNilTaskFailClosed (0.00s)
|
||||
=== RUN TestHandleTurnLegacyDoesNotPersist
|
||||
--- PASS: TestHandleTurnLegacyDoesNotPersist (0.00s)
|
||||
=== RUN TestHandleTurnAutoReadPersistsAndExecutesQueries
|
||||
2026/09/16 02:17:57 INFO intent_recognition intent=QUERY_REGULATIONS branch=NEW_INTENT_PREEMPT confidence=0.9
|
||||
2026/09/16 02:17:57 INFO intent_recognition intent=QUERY_PROGRESS branch=NEW_INTENT_PREEMPT confidence=0.9
|
||||
2026/09/16 02:17:57 INFO intent_recognition intent=QUERY_REGULATIONS branch=NEW_INTENT_PREEMPT confidence=0.9
|
||||
2026/09/16 02:17:57 INFO intent_recognition intent=QUERY_PROGRESS branch=NEW_INTENT_PREEMPT confidence=0.9
|
||||
2026/09/16 02:17:57 INFO intent_recognition intent=QUERY_REGULATIONS branch=NEW_INTENT_PREEMPT confidence=0.9
|
||||
2026/09/16 02:17:57 INFO intent_recognition intent=QUERY_REGULATIONS branch=NEW_INTENT_PREEMPT confidence=0.9
|
||||
2026/09/16 02:17:57 INFO intent_recognition intent=QUERY_PROGRESS branch=NEW_INTENT_PREEMPT confidence=0.9
|
||||
2026/09/16 02:17:57 INFO intent_recognition intent=QUERY_REGULATIONS branch=NEW_INTENT_PREEMPT confidence=0.9
|
||||
2026/09/16 02:17:57 INFO intent_recognition intent=QUERY_PROGRESS branch=NEW_INTENT_PREEMPT confidence=0.9
|
||||
2026/09/16 02:17:57 INFO intent_recognition intent=QUERY_REGULATIONS branch=NEW_INTENT_PREEMPT confidence=0.9
|
||||
--- PASS: TestHandleTurnAutoReadPersistsAndExecutesQueries (0.00s)
|
||||
=== RUN TestHandleTurnWriteUtteranceStaysLegacyUnderAutoRead
|
||||
2026/09/16 02:17:57 INFO intent_recognition intent=QUERY_REGULATIONS branch=NEW_INTENT_PREEMPT confidence=0.9
|
||||
--- PASS: TestHandleTurnWriteUtteranceStaysLegacyUnderAutoRead (0.00s)
|
||||
=== RUN TestQueryGuardRejectsGenerate
|
||||
--- PASS: TestQueryGuardRejectsGenerate (0.00s)
|
||||
=== RUN TestHandleTurnConfirmPersistsAwaitingWithoutExecute
|
||||
2026/09/16 02:17:57 INFO intent_recognition intent=QUERY_REGULATIONS branch=NEW_INTENT_PREEMPT confidence=0.9
|
||||
2026/09/16 02:17:57 INFO intent_recognition intent=QUERY_REGULATIONS branch=NEW_INTENT_PREEMPT confidence=0.9
|
||||
2026/09/16 02:17:57 INFO intent_recognition intent=TRIGGER_TASK branch=NEW_INTENT_PREEMPT confidence=0.86
|
||||
2026/09/16 02:17:57 INFO intent_recognition intent=QUERY_REGULATIONS branch=NEW_INTENT_PREEMPT confidence=0.9
|
||||
2026/09/16 02:17:57 INFO intent_recognition intent=TRIGGER_TASK branch=NEW_INTENT_PREEMPT confidence=0.86
|
||||
2026/09/16 02:17:57 INFO intent_recognition intent=QUERY_REGULATIONS branch=NEW_INTENT_PREEMPT confidence=0.9
|
||||
2026/09/16 02:17:57 INFO intent_recognition intent=QUERY_REGULATIONS branch=NEW_INTENT_PREEMPT confidence=0.9
|
||||
2026/09/16 02:17:57 INFO intent_recognition intent=TRIGGER_TASK branch=NEW_INTENT_PREEMPT confidence=0.86
|
||||
2026/09/16 02:17:57 INFO intent_recognition intent=QUERY_REGULATIONS branch=NEW_INTENT_PREEMPT confidence=0.9
|
||||
2026/09/16 02:17:57 INFO intent_recognition intent=TRIGGER_TASK branch=NEW_INTENT_PREEMPT confidence=0.86
|
||||
--- PASS: TestHandleTurnConfirmPersistsAwaitingWithoutExecute (0.00s)
|
||||
=== RUN TestHandleTurnConfirmRejectsSecondActiveWritePlan
|
||||
2026/09/16 02:17:57 INFO intent_recognition intent=QUERY_REGULATIONS branch=NEW_INTENT_PREEMPT confidence=0.9
|
||||
2026/09/16 02:17:57 INFO intent_recognition intent=QUERY_REGULATIONS branch=NEW_INTENT_PREEMPT confidence=0.9
|
||||
2026/09/16 02:17:57 INFO intent_recognition intent=TRIGGER_TASK branch=NEW_INTENT_PREEMPT confidence=0.86
|
||||
2026/09/16 02:17:57 INFO intent_recognition intent=QUERY_REGULATIONS branch=NEW_INTENT_PREEMPT confidence=0.9
|
||||
2026/09/16 02:17:57 INFO intent_recognition intent=TRIGGER_TASK branch=NEW_INTENT_PREEMPT confidence=0.86
|
||||
2026/09/16 02:17:57 INFO intent_recognition intent=QUERY_REGULATIONS branch=NEW_INTENT_PREEMPT confidence=0.9
|
||||
2026/09/16 02:17:57 INFO intent_recognition intent=QUERY_REGULATIONS branch=NEW_INTENT_PREEMPT confidence=0.9
|
||||
2026/09/16 02:17:57 INFO intent_recognition intent=TRIGGER_TASK branch=NEW_INTENT_PREEMPT confidence=0.86
|
||||
2026/09/16 02:17:57 INFO intent_recognition intent=QUERY_REGULATIONS branch=NEW_INTENT_PREEMPT confidence=0.9
|
||||
2026/09/16 02:17:57 INFO intent_recognition intent=TRIGGER_TASK branch=NEW_INTENT_PREEMPT confidence=0.86
|
||||
--- PASS: TestHandleTurnConfirmRejectsSecondActiveWritePlan (0.00s)
|
||||
PASS
|
||||
ok chat-agent-system/internal/app/intentflow 0.008s
|
||||
+8
-1
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -192,8 +193,14 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
|
||||
if pool != nil {
|
||||
rt.Docflow.UoW = uow.NewPG(pool)
|
||||
rt.Docflow.Events = events.PGSink{}
|
||||
livePI := cfg != nil && strings.TrimSpace(cfg.PiAgentRunnerURL) != ""
|
||||
taskOwner := NewTaskRunAdapter()
|
||||
taskOwner.Starter = LiveTaskStarter(taskMgr, livePI)
|
||||
rt.IntentPlans = intentflow.NewPlans(rt.Docflow.UoW, intentflow.NewRepository(), intentflow.Options{
|
||||
Owner: intentflow.QueryGuard{Inner: intentflow.NewFakeActionOwner()},
|
||||
Owner: intentflow.OwnerMux{
|
||||
Query: intentflow.QueryGuard{Inner: intentflow.NewFakeActionOwner()},
|
||||
Task: taskOwner,
|
||||
},
|
||||
Events: events.PGSink{},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package intentflow
|
||||
|
||||
import "context"
|
||||
|
||||
// OwnerMux routes generate keys to Task and everything else to Query.
|
||||
type OwnerMux struct {
|
||||
Query ActionOwner
|
||||
Task ActionOwner
|
||||
}
|
||||
|
||||
func (m OwnerMux) pick(actionKey string) ActionOwner {
|
||||
switch actionKey {
|
||||
case "task.run", "task.run.adhoc":
|
||||
return m.Task
|
||||
default:
|
||||
return m.Query
|
||||
}
|
||||
}
|
||||
|
||||
func (m OwnerMux) Authorize(ctx context.Context, exec StepExec) error {
|
||||
owner := m.pick(exec.ActionKey)
|
||||
if owner == nil {
|
||||
return ErrPlanActionForbidden
|
||||
}
|
||||
if a, ok := owner.(StepAuthorizer); ok {
|
||||
return a.Authorize(ctx, exec)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m OwnerMux) Execute(ctx context.Context, exec StepExec) (Receipt, error) {
|
||||
if err := m.Authorize(ctx, exec); err != nil {
|
||||
return Receipt{}, err
|
||||
}
|
||||
owner := m.pick(exec.ActionKey)
|
||||
if owner == nil {
|
||||
return Receipt{}, ErrPlanActionForbidden
|
||||
}
|
||||
return owner.Execute(ctx, exec)
|
||||
}
|
||||
|
||||
func (m OwnerMux) LookupReceipt(ctx context.Context, idempotencyKey string) (Receipt, bool, error) {
|
||||
if m.Task != nil {
|
||||
if rec, ok, err := m.Task.LookupReceipt(ctx, idempotencyKey); err != nil || ok {
|
||||
return rec, ok, err
|
||||
}
|
||||
}
|
||||
if m.Query != nil {
|
||||
return m.Query.LookupReceipt(ctx, idempotencyKey)
|
||||
}
|
||||
return Receipt{}, false, nil
|
||||
}
|
||||
|
||||
func (m OwnerMux) ObserveFence(stepID string, token int64) {
|
||||
if obs, ok := m.Query.(FenceObserver); ok {
|
||||
obs.ObserveFence(stepID, token)
|
||||
}
|
||||
if obs, ok := m.Task.(FenceObserver); ok {
|
||||
obs.ObserveFence(stepID, token)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package intentflow_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/app/intentflow"
|
||||
)
|
||||
|
||||
type routeProbe struct {
|
||||
keys []string
|
||||
}
|
||||
|
||||
func (p *routeProbe) Authorize(_ context.Context, exec intentflow.StepExec) error {
|
||||
p.keys = append(p.keys, "auth:"+exec.ActionKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *routeProbe) Execute(ctx context.Context, exec intentflow.StepExec) (intentflow.Receipt, error) {
|
||||
p.keys = append(p.keys, "exec:"+exec.ActionKey)
|
||||
return intentflow.Receipt{IdempotencyKey: exec.IdempotencyKey, ActionDigest: exec.ActionDigest, ResultRef: "probe:" + exec.ActionKey}, nil
|
||||
}
|
||||
|
||||
func (p *routeProbe) LookupReceipt(context.Context, string) (intentflow.Receipt, bool, error) {
|
||||
return intentflow.Receipt{}, false, nil
|
||||
}
|
||||
|
||||
func TestOwnerMuxRoutesGenerateToTask(t *testing.T) {
|
||||
query := &routeProbe{}
|
||||
task := &routeProbe{}
|
||||
mux := intentflow.OwnerMux{Query: query, Task: task}
|
||||
ctx := context.Background()
|
||||
if _, err := mux.Execute(ctx, intentflow.StepExec{ActionKey: "regulation.query", IdempotencyKey: "k-q", ActionDigest: "dq"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := mux.Execute(ctx, intentflow.StepExec{ActionKey: "task.run", IdempotencyKey: "k-t", ActionDigest: "dt"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := mux.Execute(ctx, intentflow.StepExec{ActionKey: "task.run.adhoc", IdempotencyKey: "k-a", ActionDigest: "da"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(query.keys) == 0 || query.keys[len(query.keys)-1] != "exec:regulation.query" {
|
||||
t.Fatalf("query probe = %v", query.keys)
|
||||
}
|
||||
if len(task.keys) < 2 {
|
||||
t.Fatalf("task probe = %v", task.keys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerMuxNilTaskFailClosed(t *testing.T) {
|
||||
mux := intentflow.OwnerMux{Query: intentflow.NewFakeActionOwner(), Task: nil}
|
||||
_, err := mux.Execute(context.Background(), intentflow.StepExec{
|
||||
ActionKey: "task.run", Principal: principal(), IdempotencyKey: "k", ActionDigest: "d",
|
||||
})
|
||||
if err != intentflow.ErrPlanActionForbidden {
|
||||
t.Fatalf("nil Task must fail closed, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,8 @@ func visibleTo(plan *Plan, p Principal) bool {
|
||||
}
|
||||
|
||||
// HandleTurn stays a no-op while MULTI_INTENT_PLAN_TIER is legacy/shadow (default).
|
||||
// U7 maps read-only utterances onto CreatePlan when auto_read is enabled.
|
||||
// auto_read executes independent queries; confirm persists write/generate plans
|
||||
// awaiting confirmation and does not execute them.
|
||||
func (p *Plans) HandleTurn(ctx context.Context, in TurnInput) (*TurnResult, error) {
|
||||
if err := requirePrincipal(in.Principal); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -3,7 +3,7 @@ package intentflow
|
||||
import "context"
|
||||
|
||||
// QueryGuard only authorizes low-risk query actions opened from auto_read.
|
||||
// Write/generate keys fail closed until a later adapter is wired.
|
||||
// Write/generate keys fail closed; OwnerMux routes those to Task.
|
||||
type QueryGuard struct {
|
||||
Inner ActionOwner
|
||||
Policy *BatchActionPolicy
|
||||
@@ -43,3 +43,9 @@ func (g QueryGuard) LookupReceipt(ctx context.Context, idempotencyKey string) (R
|
||||
}
|
||||
return g.Inner.LookupReceipt(ctx, idempotencyKey)
|
||||
}
|
||||
|
||||
func (g QueryGuard) ObserveFence(stepID string, token int64) {
|
||||
if obs, ok := g.Inner.(FenceObserver); ok {
|
||||
obs.ObserveFence(stepID, token)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,9 @@ func (p *Plans) handleTurnAutoRead(ctx context.Context, in TurnInput) (*TurnResu
|
||||
return &TurnResult{}, nil
|
||||
}
|
||||
proposal := res.PlanProposal
|
||||
if proposal.ComboHint != intent.ComboReadOnly || len(proposal.Segments) < 2 || len(proposal.Segments) > 4 {
|
||||
confirmWrites := proposal.ComboHint == intent.ComboNeedsConfirm && tier.Allows(TierConfirm)
|
||||
readOnly := proposal.ComboHint == intent.ComboReadOnly && tier.Allows(TierAutoRead)
|
||||
if (!confirmWrites && !readOnly) || len(proposal.Segments) < 2 || len(proposal.Segments) > 4 {
|
||||
return &TurnResult{}, nil
|
||||
}
|
||||
policy := DefaultBatchActionPolicy()
|
||||
@@ -40,7 +42,10 @@ func (p *Plans) handleTurnAutoRead(ctx context.Context, in TurnInput) (*TurnResu
|
||||
return &TurnResult{}, nil
|
||||
}
|
||||
row, err := policy.Lookup(key)
|
||||
if err != nil || row.RejectPlan || row.RequiresBatchConfirm || !tier.Allows(row.OpenFromTier) {
|
||||
if err != nil || row.RejectPlan || !tier.Allows(row.OpenFromTier) {
|
||||
return &TurnResult{}, nil
|
||||
}
|
||||
if row.RequiresBatchConfirm && !confirmWrites {
|
||||
return &TurnResult{}, nil
|
||||
}
|
||||
summary := strings.TrimSpace(seg.Normalized)
|
||||
@@ -69,13 +74,16 @@ func (p *Plans) handleTurnAutoRead(ctx context.Context, in TurnInput) (*TurnResu
|
||||
SegmenterVersion: proposal.SegmenterVersion,
|
||||
Status: PlanAwaitingConfirmation,
|
||||
ExecutionMode: ModeLive,
|
||||
WritePlan: false,
|
||||
WritePlan: confirmWrites,
|
||||
SafeSummary: text,
|
||||
Steps: drafts,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if confirmWrites {
|
||||
return &TurnResult{Plan: created}, nil
|
||||
}
|
||||
confirmed, err := p.DecidePlan(ctx, DecisionCommand{
|
||||
Principal: in.Principal,
|
||||
PlanID: created.PlanID,
|
||||
|
||||
@@ -78,3 +78,80 @@ func TestQueryGuardRejectsGenerate(t *testing.T) {
|
||||
t.Fatal("task.run must be forbidden by QueryGuard")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTurnConfirmPersistsAwaitingWithoutExecute(t *testing.T) {
|
||||
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{PlanTier: intentflow.TierConfirm})
|
||||
t.Cleanup(intentflow.ResetFeatureFlagsForTest)
|
||||
p, _, owner := testPlans()
|
||||
ctx := context.Background()
|
||||
res, err := p.HandleTurn(ctx, intentflow.TurnInput{
|
||||
Principal: principal(),
|
||||
Text: "查一下相关法规,然后帮我生成报价单",
|
||||
ClientTurnID: "turn-cf-1",
|
||||
RequestDigest: "d-cf-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res == nil || res.Plan == nil {
|
||||
t.Fatal("confirm tier must persist a write plan")
|
||||
}
|
||||
if res.Plan.Status != intentflow.PlanAwaitingConfirmation {
|
||||
t.Fatalf("status %s", res.Plan.Status)
|
||||
}
|
||||
if len(res.Plan.Steps) < 2 {
|
||||
t.Fatalf("expected query+generate steps, got %+v", res.Plan.Steps)
|
||||
}
|
||||
hasGenerate := false
|
||||
for _, s := range res.Plan.Steps {
|
||||
if s.ActionKey == "task.run" || s.ActionKey == "task.run.adhoc" {
|
||||
hasGenerate = true
|
||||
}
|
||||
if s.Status != intentflow.StepPending {
|
||||
t.Fatalf("confirm must not execute before decision, step=%+v", s)
|
||||
}
|
||||
}
|
||||
if !hasGenerate {
|
||||
t.Fatalf("expected a generate step, plan=%+v", res.Plan)
|
||||
}
|
||||
if owner.ExecCount("") != 0 {
|
||||
t.Fatal("no owner exec expected before confirm")
|
||||
}
|
||||
again, err := p.HandleTurn(ctx, intentflow.TurnInput{
|
||||
Principal: principal(),
|
||||
Text: "查一下相关法规,然后帮我生成报价单",
|
||||
ClientTurnID: "turn-cf-1",
|
||||
RequestDigest: "d-cf-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again.Plan.PlanID != res.Plan.PlanID {
|
||||
t.Fatal("same turn must reuse the awaiting plan")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTurnConfirmRejectsSecondActiveWritePlan(t *testing.T) {
|
||||
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{PlanTier: intentflow.TierConfirm})
|
||||
t.Cleanup(intentflow.ResetFeatureFlagsForTest)
|
||||
p, _, _ := testPlans()
|
||||
ctx := context.Background()
|
||||
first, err := p.HandleTurn(ctx, intentflow.TurnInput{
|
||||
Principal: principal(),
|
||||
Text: "查一下相关法规,然后帮我生成报价单",
|
||||
ClientTurnID: "turn-cf-a",
|
||||
RequestDigest: "d-cf-a",
|
||||
})
|
||||
if err != nil || first == nil || first.Plan == nil {
|
||||
t.Fatalf("first plan: %+v %v", first, err)
|
||||
}
|
||||
_, err = p.HandleTurn(ctx, intentflow.TurnInput{
|
||||
Principal: principal(),
|
||||
Text: "查一下相关法规,然后帮我生成报价单",
|
||||
ClientTurnID: "turn-cf-b",
|
||||
RequestDigest: "d-cf-b",
|
||||
})
|
||||
if err != intentflow.ErrPlanDecisionConflict {
|
||||
t.Fatalf("second write plan must conflict, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"chat-agent-system/internal/app/intentflow"
|
||||
"chat-agent-system/internal/bizdev"
|
||||
"chat-agent-system/internal/taskrun"
|
||||
)
|
||||
|
||||
// TaskRunAdapter is the U8 generate adapter. It records a stable idempotency
|
||||
// key and result reference without changing single-intent taskrun HTTP.
|
||||
type TaskRunAdapter struct {
|
||||
mu sync.Mutex
|
||||
receipts map[string]intentflow.Receipt
|
||||
starts int
|
||||
Denied bool
|
||||
Starter func(ctx context.Context, exec intentflow.StepExec) (resultRef string, err error)
|
||||
}
|
||||
|
||||
func NewTaskRunAdapter() *TaskRunAdapter {
|
||||
return &TaskRunAdapter{receipts: map[string]intentflow.Receipt{}}
|
||||
}
|
||||
|
||||
func (a *TaskRunAdapter) Authorize(_ context.Context, exec intentflow.StepExec) error {
|
||||
if a != nil && a.Denied {
|
||||
return intentflow.ErrPlanActionForbidden
|
||||
}
|
||||
if exec.ActionKey != "task.run" && exec.ActionKey != "task.run.adhoc" {
|
||||
return intentflow.ErrPlanActionForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *TaskRunAdapter) Execute(ctx context.Context, exec intentflow.StepExec) (intentflow.Receipt, error) {
|
||||
if err := a.Authorize(ctx, exec); err != nil {
|
||||
return intentflow.Receipt{}, err
|
||||
}
|
||||
a.mu.Lock()
|
||||
if rec, ok := a.receipts[exec.IdempotencyKey]; ok {
|
||||
a.mu.Unlock()
|
||||
if rec.ActionDigest != exec.ActionDigest {
|
||||
return intentflow.Receipt{}, intentflow.ErrPlanDecisionConflict
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
a.mu.Unlock()
|
||||
ref := "taskrun:" + exec.IdempotencyKey
|
||||
if a.Starter != nil {
|
||||
got, err := a.Starter(ctx, exec)
|
||||
if err != nil {
|
||||
return intentflow.Receipt{}, err
|
||||
}
|
||||
if got != "" {
|
||||
ref = got
|
||||
}
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if rec, ok := a.receipts[exec.IdempotencyKey]; ok {
|
||||
if rec.ActionDigest != exec.ActionDigest {
|
||||
return intentflow.Receipt{}, intentflow.ErrPlanDecisionConflict
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
a.starts++
|
||||
rec := intentflow.Receipt{
|
||||
IdempotencyKey: exec.IdempotencyKey,
|
||||
ActionDigest: exec.ActionDigest,
|
||||
ResultRef: ref,
|
||||
ResultDigest: "task:" + exec.ActionDigest,
|
||||
SafeSummary: "task.run accepted",
|
||||
}
|
||||
if a.receipts == nil {
|
||||
a.receipts = map[string]intentflow.Receipt{}
|
||||
}
|
||||
a.receipts[exec.IdempotencyKey] = rec
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
// LiveTaskStarter claims a taskrun with the plan step idempotency key.
|
||||
// When live is false it only occupies the run row and does not start PI.
|
||||
func LiveTaskStarter(mgr *taskrun.Manager, live bool) func(context.Context, intentflow.StepExec) (string, error) {
|
||||
return func(ctx context.Context, exec intentflow.StepExec) (string, error) {
|
||||
if mgr == nil {
|
||||
return "", fmt.Errorf("task generate adapter: taskrun manager missing")
|
||||
}
|
||||
if existing, err := mgr.FindByIdempotencyKey(ctx, exec.IdempotencyKey); err != nil {
|
||||
return "", err
|
||||
} else if existing != nil && existing.RunID != "" {
|
||||
return existing.RunID, nil
|
||||
}
|
||||
taskKey := "intake"
|
||||
if exec.ActionKey == "task.run.adhoc" {
|
||||
taskKey = "adhoc"
|
||||
}
|
||||
rec := &taskrun.RunRecord{
|
||||
RunID: planRunID(exec.IdempotencyKey),
|
||||
SessionID: exec.Principal.SessionID,
|
||||
ProjectID: exec.Principal.ProjectID,
|
||||
UserID: exec.Principal.UserID,
|
||||
IdempotencyKey: exec.IdempotencyKey,
|
||||
TaskKey: taskKey,
|
||||
Status: "pending",
|
||||
}
|
||||
if !live {
|
||||
claimed, _, err := mgr.ClaimRun(ctx, rec)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return claimed.RunID, nil
|
||||
}
|
||||
created, err := mgr.CreateAndStart(ctx, rec.RunID, rec, bizdev.TaskRunRequest{
|
||||
TaskKey: taskKey,
|
||||
UserID: exec.Principal.UserID,
|
||||
ProjectID: exec.Principal.ProjectID,
|
||||
SessionID: exec.Principal.SessionID,
|
||||
}, bizdev.StageRunRequest{}, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return created.RunID, nil
|
||||
}
|
||||
}
|
||||
|
||||
func planRunID(idempotencyKey string) string {
|
||||
sum := sha256.Sum256([]byte(idempotencyKey))
|
||||
return "run_plan_" + hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
func (a *TaskRunAdapter) LookupReceipt(_ context.Context, idempotencyKey string) (intentflow.Receipt, bool, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
rec, ok := a.receipts[idempotencyKey]
|
||||
return rec, ok, nil
|
||||
}
|
||||
|
||||
func (a *TaskRunAdapter) StartCount() int {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.starts
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/app/intentflow"
|
||||
"chat-agent-system/internal/taskrun"
|
||||
)
|
||||
|
||||
func TestTaskRunAdapterIdempotentExecute(t *testing.T) {
|
||||
a := NewTaskRunAdapter()
|
||||
exec := intentflow.StepExec{
|
||||
ActionKey: "task.run", IdempotencyKey: "idem-1", ActionDigest: "d1",
|
||||
Principal: intentflow.Principal{UserID: 1, ProjectID: 2, SessionID: "s", Environment: "dev"},
|
||||
}
|
||||
first, err := a.Execute(context.Background(), exec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := a.Execute(context.Background(), exec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.ResultRef != second.ResultRef {
|
||||
t.Fatalf("replay ref %s vs %s", first.ResultRef, second.ResultRef)
|
||||
}
|
||||
if a.StartCount() != 1 {
|
||||
t.Fatalf("starts = %d", a.StartCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskRunAdapterRejectsQueryKey(t *testing.T) {
|
||||
a := NewTaskRunAdapter()
|
||||
_, err := a.Execute(context.Background(), intentflow.StepExec{ActionKey: "regulation.query", Principal: intentflow.Principal{UserID: 1}})
|
||||
if err != intentflow.ErrPlanActionForbidden {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskRunAdapterDeniedAfterRevoke(t *testing.T) {
|
||||
a := NewTaskRunAdapter()
|
||||
a.Denied = true
|
||||
_, err := a.Execute(context.Background(), intentflow.StepExec{ActionKey: "task.run", Principal: intentflow.Principal{UserID: 1}})
|
||||
if err != intentflow.ErrPlanActionForbidden {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmThenMuxExecutesGenerateOnce(t *testing.T) {
|
||||
intentflow.SetFeatureFlagsForTest(intentflow.FeatureFlags{PlanTier: intentflow.TierConfirm})
|
||||
t.Cleanup(intentflow.ResetFeatureFlagsForTest)
|
||||
query := intentflow.NewFakeActionOwner()
|
||||
task := NewTaskRunAdapter()
|
||||
t0 := time.Date(2026, 9, 16, 12, 0, 0, 0, time.UTC)
|
||||
p := intentflow.NewPlans(&intentflow.SerialUoW{}, intentflow.NewMemoryStore(), intentflow.Options{
|
||||
Owner: intentflow.OwnerMux{Query: intentflow.QueryGuard{Inner: query}, Task: task},
|
||||
Now: func() time.Time { return t0 },
|
||||
})
|
||||
ctx := context.Background()
|
||||
prin := intentflow.Principal{UserID: 7, Environment: "dev", ProjectID: 42, SessionID: "sess-u8"}
|
||||
created, err := p.HandleTurn(ctx, intentflow.TurnInput{
|
||||
Principal: prin, Text: "查一下相关法规,然后帮我生成报价单",
|
||||
ClientTurnID: "turn-u8", RequestDigest: "d-u8",
|
||||
})
|
||||
if err != nil || created == nil || created.Plan == nil {
|
||||
t.Fatalf("create: %+v %v", created, err)
|
||||
}
|
||||
if created.Plan.Status != intentflow.PlanAwaitingConfirmation {
|
||||
t.Fatalf("status %s", created.Plan.Status)
|
||||
}
|
||||
if task.StartCount() != 0 {
|
||||
t.Fatal("generate must not start before confirm")
|
||||
}
|
||||
confirmed, err := p.DecidePlan(ctx, intentflow.DecisionCommand{
|
||||
Principal: prin, PlanID: created.Plan.PlanID, ExpectedRevision: created.Plan.Revision,
|
||||
DecisionNonce: "u8-n", DecisionDigest: "u8-d", Kind: intentflow.DecisionConfirm,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
view, err := p.AdvanceEligible(ctx, "u8-worker", confirmed.PlanID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if view == nil {
|
||||
t.Fatal("expected advanced plan")
|
||||
}
|
||||
if task.StartCount() != 1 {
|
||||
t.Fatalf("generate starts = %d, plan=%+v", task.StartCount(), view)
|
||||
}
|
||||
_, err = p.AdvanceEligible(ctx, "u8-worker", confirmed.PlanID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if task.StartCount() != 1 {
|
||||
t.Fatalf("replay must not restart generate, starts=%d", task.StartCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveTaskStarterReplaysIdempotencyKey(t *testing.T) {
|
||||
mgr := taskrun.NewManager(nil, nil)
|
||||
start := LiveTaskStarter(mgr, false)
|
||||
exec := intentflow.StepExec{
|
||||
ActionKey: "task.run", IdempotencyKey: "plan-key-1", ActionDigest: "d",
|
||||
Principal: intentflow.Principal{UserID: 3, ProjectID: 9, SessionID: "sess-live"},
|
||||
}
|
||||
a, err := start(context.Background(), exec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := start(context.Background(), exec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a == "" || a != b {
|
||||
t.Fatalf("replay run id %q vs %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskGenerateRealPIStopGate(t *testing.T) {
|
||||
if os.Getenv("PI_AGENT_RUNNER_URL") == "" {
|
||||
t.Skip("stop-gate: PI_AGENT_RUNNER_URL not set; real PI generate is not claimed")
|
||||
}
|
||||
t.Skip("live PI generate path is gated; unit evidence is TaskRunAdapter + ClaimRun idempotency")
|
||||
}
|
||||
Reference in New Issue
Block a user