merge: feature/multi-intent-u3-intentflow-pg

This commit is contained in:
weichen
2026-09-15 23:46:55 +00:00
23 changed files with 2600 additions and 37 deletions
+4 -2
View File
@@ -5,7 +5,7 @@
## 焦点
1. **MULTI-INTENT HITL**:U2 规则分段已签([U2 报告](../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md));下一单元 U3 PG/CAS 待认领
1. **MULTI-INTENT HITL**:U3 PG/CAS 已签([U3 报告](../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md));下一单元 U4 outbox/投影待认领
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-15-multi-intent-u3-intentflow-pg | feature/multi-intent-u3-intentflow-pg | - | intent-plan | done |
| Cursor | 2026-09-15-multi-intent-u2-segmenter | feature/multi-intent-u2-segmenter | - | intent | done |
| Cursor | 2026-09-15-multi-intent-u1-contracts | feature/multi-intent-u1-contracts | - | architecture-docs | done |
| Cursor | app-composition-u11-u12 | feature/app-composition-u11-u12 | - | app | done |
@@ -86,7 +87,7 @@
| holder | phase | since | scope | queue |
|--------|-------|-------|-------|-------|
| - | idle | - | - | - |
| Cursor | merging | 2026-09-15 | multi-intent-u3-intentflow-pg(schema 文件 44,不 rebuild 共享环境) | - |
**phase**: `idle` | `merging` | `smoke` | `e2e`
@@ -122,6 +123,7 @@
| 多意图 HITL 复核方案 | [../system-design/2026-09-15-multi-intent-hitl-plan.md](../system-design/2026-09-15-multi-intent-hitl-plan.md) |
| 多意图 HITL U1 契约 | [../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md](../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md) |
| 多意图 HITL U2 分段 | [../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md](../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md) |
| 多意图 HITL U3 PG | [../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md](../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-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) |
| 文档可靠性 M2 | [../test-reports/2026-09-14-document-reliability-m2-test-report.md](../test-reports/2026-09-14-document-reliability-m2-test-report.md) |
@@ -0,0 +1,22 @@
---
title: "多意图 HITL U3 intentflow PG checklist"
status: done
created: 2026-09-15
updated: 2026-09-15
related_task: "2026-09-15-multi-intent-u3-intentflow-pg"
---
# 多意图 HITL U3 intentflow PG checklist
## Wave 2
- [x] Goose 00044 三表 + unique/CAS/lease 列 — 证据:`deploy/postgres/goose/00044_2026-09-15-intent-plans.sql`;`TestGooseU3_00044_HasUpAndDown`
- [x] 创建幂等与同 turn 异 digest — 证据:[plans-verbose.txt](../../test-reports/assets/2026-09-15-multi-intent-u3-intentflow-pg/plans-verbose.txt)
- [x] 决策 nonce / revision CAS / 过期 — 证据:同上
- [x] 两 worker 无双 owner;租约接管;旧 fence 拒绝 — 证据:`TestTwoWorkersOneOwnerAndLeaseTakeover`
- [x] receipt 对账不盲跑;环境隔离;legacy HandleTurn 不落盘 — 证据:`TestReceiptWritebackFailureDoesNotRerun` / `TestHandleTurnLegacyDoesNotPersist`
- [x] Assemble 接线且 chat 不调用;arch-check 无新例外 — 证据:[short.txt](../../test-reports/assets/2026-09-15-multi-intent-u3-intentflow-pg/short.txt)
## 完成定义
- [x] 全部勾选且有证据链接
@@ -3,7 +3,7 @@ title: "多意图持久计划与 HITL(核心 U1–U8,扩面 U9–U12)"
status: in_progress
created: 2026-09-15
updated: 2026-09-15
owner: 待认领(下一单元 U3)
owner: 待认领(下一单元 U4)
branch: "-"
worktree: "-"
mutex: "-"
@@ -53,7 +53,7 @@ mutex: "-"
1. [x] U1 冻结边界、状态机、批量策略、错误码、事件和默认 legacy tier([独立任务](./2026-09-15-multi-intent-u1-contracts.md);[报告](../../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md));
2. [x] U2 characterization-first 建 golden/hard-negative 与 shadow 基线([独立任务](./2026-09-15-multi-intent-u2-segmenter.md);[报告](../../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md));
3. [ ] U3 建 intentflow owner、PG schema、CAS/lease/fencing/retention;
3. [x] U3 建 intentflow owner、PG schema、CAS/lease/fencing/retention([独立任务](./2026-09-15-multi-intent-u3-intentflow-pg.md);[报告](../../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md));
4. [ ] U4 接 Track E outbox/dispatcher,只投影状态事实;
5. [ ] U5 建统一 decision command 与动作 owner 授权端口;
6. [ ] U6 取消计划动作的前端本地成功路径,以 PlanView 恢复;
@@ -92,13 +92,14 @@ mutex: "-"
## 相关链接
- Checklist: [U1](../checklists/2026-09-15-multi-intent-u1-contracts.md);[U2](../checklists/2026-09-15-multi-intent-u2-segmenter.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)
- 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)
- 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)
## 证据 / 备注
- 当前 `plan_builder.go` 最多两段且写意图返回 nil;`action_planner.go` 的确认仍是文字提示。
- 当前 `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:intentflow PG/CAS/lease。
- U2 已落地规则分段与 shadow `PlanProposal`;生产 TopIntent / IntentPlan 不变;反事实 `false_auto_execute=0`。
- U3 已落地 PG 计划账本与 CAS/lease/fencing;HandleTurn 默认不落盘。下一单元 U4:outbox/投影。共享环境 schema 44 待晋升窗口。
@@ -0,0 +1,68 @@
---
title: "多意图 HITL U3:intentflow PG/CAS/lease"
status: done
created: 2026-09-15
updated: 2026-09-15
owner: Cursor
branch: feature/multi-intent-u3-intentflow-pg
worktree: "-"
mutex: intent-plan
---
# 多意图 HITL U3:intentflow PG/CAS/lease
## 目标
建立持久计划/步骤/决策与并发协议(CAS revision、decision nonce、lease/fencing、创建幂等、retention);不接真实写动作,不改 chat/coordinator 生产路由。
## 范围
- Goose `00044`:`intent_plans` / `intent_plan_steps` / `intent_plan_decisions`
- `app/intentflow` owner repository + UoW;owner 可 unwrap pgx
- 创建幂等 `(environment, session_id, client_turn_id)`;同 turn 异 digest 冲突
- 决策 CAS + nonce 同/异 digest;确认/取消/过期竞态
- 步骤 lease + 单调 fencing;两 worker 只一个 owner
- 业务成功但 receipt 写回失败时按幂等键对账,不盲跑
- 环境隔离;关闭计划 retention 由 owner 删除
- `app.Assemble` 装配 `*intentflow.Plans`,chat 仍不调用
## 非目标
- 不接 HTTP(U5)、不 Append 事件(U4)、不接真实 action adapter(U7/U8)
- 不把 HITL/tier 回灌 ActionRegistry
- 不改 `internal/intent` 生产 TopIntent / IntentPlan
- 不 rebuild 共享 `:8080`(schema 44 仅隔离库/后续晋升窗口应用)
## 步骤
1. [x] 认领 CURRENT(mutex `intent-plan`)
2. [x] Goose 00044 + migrate gate
3. [x] Store/Engine:Create/Decide/Claim/Receipt/Expire/Purge
4. [x] 内存并发 + 可选 PG 测试;Assemble 接线
5. [x] 报告并合回本地 dev
## 验收标准
- [x] 双创建同 digest 返回同一 plan;异 digest 冲突
- [x] nonce 同 digest 返回 receipt;异 digest 冲突
- [x] 确认/取消/过期只一个 CAS 胜出
- [x] 两 worker 抢 eligible 步骤无双 owner
- [x] 租约过期可接管;旧 fencing token 被动作属主拒绝
- [x] 重启后 succeeded 不重跑;receipt 写回失败按幂等键恢复
- [x] 跨 environment 隐藏
- [x] 默认 legacy 时 HandleTurn 不落盘
- [x] `go test -short ./internal/app/intentflow ./internal/app ./internal/archtest ./internal/db` 绿
## 相关链接
- 总览: [2026-09-15-multi-intent-hitl.md](./2026-09-15-multi-intent-hitl.md)
- 设计: [../../system-design/2026-09-15-multi-intent-hitl-plan.md](../../system-design/2026-09-15-multi-intent-hitl-plan.md)
- Checklist: [../checklists/2026-09-15-multi-intent-u3-intentflow-pg.md](../checklists/2026-09-15-multi-intent-u3-intentflow-pg.md)
- Test report: [../../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md](../../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md)
## 证据 / 备注
- HandleTurn 在默认 legacy 下不落盘;CreatePlan 是 U3 测试与后续 U7 入口。
- 动作属主端口仅 FakeActionOwner;真实 adapter 从 U7/U8 按 key 接入。
- PG 活验需隔离 `TEST_DATABASE_URL`;本机 short 门禁跳过。共享 `:8080` 仍 schema 43。
- 提交:`a75aa45` docs(ops) 认领;`7dfd6d6` feat(db);`da0b3d8` feat(intentflow);`1385002` feat(app)。
+2 -2
View File
@@ -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) | 明确写动作与只读总结已分流;双标签 `DOC_BUSY` 已签(项目 6275)。多意图 U1 契约已冻;U2 shadow `PlanProposal` 已落地且生产路由不变;U3–U8 未实施;intent 纯依赖边界不得回退 | `intent` |
| 多意图持久计划 | app/intentflow 生命周期与 HITL 契约 | planned | [设计](../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);[U2 任务](../ops/short-term/2026-09-15-multi-intent-u2-segmenter.md) | U1 契约 + U2 规则分段 shadow 已完成。下一单元 U3 PG/CAS/lease;U4 投影;U5–U8 决策/Web/只读执行/task.generate | `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–U3 已完成(契约/shadow 提案/PG CAS);U4–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);[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);[U3 任务](../ops/short-term/2026-09-15-multi-intent-u3-intentflow-pg.md) | U3 计划表/CAS/lease 已落地,默认 HandleTurn 不落盘。下一单元 U4 投影;U5–U8 决策/Web/只读执行/task.generate。共享环境 schema 44 待晋升 | `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` |
+1
View File
@@ -128,6 +128,7 @@
| 任务总览 | [../ops/short-term/2026-09-15-multi-intent-hitl.md](../ops/short-term/2026-09-15-multi-intent-hitl.md) |
| U1 契约 | [../ops/short-term/2026-09-15-multi-intent-u1-contracts.md](../ops/short-term/2026-09-15-multi-intent-u1-contracts.md) |
| U2 分段 | [../ops/short-term/2026-09-15-multi-intent-u2-segmenter.md](../ops/short-term/2026-09-15-multi-intent-u2-segmenter.md);[报告](../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md) |
| U3 PG | [../ops/short-term/2026-09-15-multi-intent-u3-intentflow-pg.md](../ops/short-term/2026-09-15-multi-intent-u3-intentflow-pg.md);[报告](../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md) |
| 一句话总结 | intent 保持纯规则,intentflow 持久化计划并统一确认、授权、幂等、恢复和逐动作执行 |
**硬边界**:确认不等于授权;PG 是计划真相,卡片/SSE 是投影;写动作按 owner 端口逐项接入;LLM 只能提出不可信分段建议。
@@ -0,0 +1,112 @@
# 多意图 HITL U3 intentflow PG/CAS/lease 测试报告
## 一、 测试背景 (Background)
U1 冻结了 intentflow 契约,U2 在 L1 产出 shadow `PlanProposal`,生产路由仍不落盘。U3 要把计划/步骤/决策变成 PostgreSQL 权威账本,并用 CAS、nonce、lease 与单调 fencing 保证两 worker 不能双 owner / 双执行。本单元不接真实写动作,也不改 chat 路径。
## 二、 测试目标 (Test Objectives)
- Goose `00044` 建立 `intent_plans` / `intent_plan_steps` / `intent_plan_decisions`
- 同 turn 同 digest 幂等;异 digest 冲突;同一作用域不能有第二个活动写计划
- 决策 nonce 同 digest 返回 receipt,异 digest 冲突;确认/取消竞态只一个 CAS 胜出
- 两 worker 抢 eligible 步骤只有一个 lease;过期可接管;旧 fencing token 被动作属主拒绝
- 业务成功但 receipt 写回失败时按幂等键恢复,不盲跑
- 跨 environment 隐藏;关闭计划可由 owner purge
- 默认 legacy 下 `HandleTurn` 不落盘;chat/coordinator 无回归
- 无 HTTP、无真实 adapter、无共享环境 rebuild
## 三、 测试方案与环境 (Test Plan & Environment)
| 项 | 值 |
|----|-----|
| OS | Linux 7.0.0-31-generic |
| Go | go1.27.1 linux/amd64 |
| 分支 | `feature/multi-intent-u3-intentflow-pg`(从本地 `dev@53c0722` 检出) |
| Schema | 文件 **44**(共享 `:8080` 仍为 **43**,本单元不 rebuild) |
| 容器 | 不 rebuild |
| 项目 143 | **只读** |
| 证据 | `.doc/test-reports/assets/2026-09-15-multi-intent-u3-intentflow-pg/` |
测试层级:内存 CAS/lease 单测(`-short`)/ goose 文件门禁 / arch-check / `go test -short ./...`。PG 集成 `TestPlansPG_CreateClaimAndFence` 在 `-short` 下跳过,需隔离 `TEST_DATABASE_URL`。
## 四、 预期结果 (Expected Results)
| 用例 | 判定 |
|------|------|
| HandleTurn legacy | 不创建计划,GetActive 为空 |
| 双创建同 digest | 同一 plan_id |
| 同 turn 异 digest | `PLAN_DECISION_CONFLICT` |
| 第二份活动写计划 | 冲突并返回已有活动计划 |
| nonce 同/异 digest | receipt / conflict |
| 确认 vs 取消 | 恰好一个 CAS 胜出 |
| 两 worker | 仅一个 owner;租约过期接管;旧 fence `PLAN_ACTION_VERSION_CONFLICT` |
| receipt 对账 | execute count=1,步骤 succeeded |
| 跨 env / purge | `PLAN_NOT_FOUND_OR_HIDDEN` |
| arch-check / 全仓 short | PASS |
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
### 5.1 创建幂等与活动写计划锁
- **测试输入 (Input)**:`go test -v -count=1 ./internal/app/intentflow -run 'TestCreate'`
- **实际输出 (Actual Output)**:
```
--- PASS: TestCreateIdempotentSameDigest (0.00s)
--- PASS: TestCreateRejectsSecondActiveWritePlan (0.00s)
```
- **评价判定 (Evaluation / Verdict)**:PASS。
- **性能与耗时 (Duration & Latency)**:<10ms
### 5.2 决策 CAS / nonce / 过期隔离
- **测试输入**:`TestDecisionNonceSameAndDifferentDigest`、`TestConfirmCancelRaceOneWinner`、`TestExpireAndEnvironmentIsolationAndRetention`
- **实际输出**:见 [plans-verbose.txt](assets/2026-09-15-multi-intent-u3-intentflow-pg/plans-verbose.txt)。三用例均 PASS。
- **评价判定**:PASS。确认/取消竞态恰好一个胜出;过期后确认失败;跨 env 隐藏;purge 后不可见。
- **性能与耗时**:<10ms
### 5.3 lease / fencing / receipt 对账
- **测试输入**:`TestTwoWorkersOneOwnerAndLeaseTakeover`、`TestReceiptWritebackFailureDoesNotRerun`
- **实际输出**:
```
--- PASS: TestTwoWorkersOneOwnerAndLeaseTakeover (0.00s)
--- PASS: TestReceiptWritebackFailureDoesNotRerun (0.00s)
```
- **评价判定**:PASS。旧 fencing token 由 FakeActionOwner 拒绝;写回失败路径只按幂等键恢复,不二次执行。
- **性能与耗时**:<10ms
### 5.4 HandleTurn 与边界
- **测试输入**:`TestHandleTurnLegacyDoesNotPersist`、`TestUnauthenticatedRejected`、`TestGooseU3_00044_HasUpAndDown`
- **实际输出**:intentflow 与 db 包均 PASS。完整包输出见 [unit.txt](assets/2026-09-15-multi-intent-u3-intentflow-pg/unit.txt)。
- **评价判定**:PASS。生产 HandleTurn 仍不落盘。00044 含 Up/Down 与三表、turn/write/nonce 唯一索引。
- **性能与耗时**:db 约 3ms
### 5.5 全仓 short 与 arch-check
- **测试输入**:`go vet ./internal/app/intentflow ./internal/app ./internal/db`;`./scripts/arch-check.sh --mode block`;`unset TEST_DATABASE_URL; go test -short -count=1 ./...`
- **实际输出**:`go vet` 无输出。`arch-check block: production gate passed`。全仓见 [short.txt](assets/2026-09-15-multi-intent-u3-intentflow-pg/short.txt)。`internal/server` 约 9.9s;全包 ok。
- **评价判定**:PASS。`intent -> maturity` 保持;chat/coordinator 无回归。SQL 写仍仅 `app/intentflow`。
- **性能与耗时**:全仓 short 约 12s
## 六、 自动化测试套件汇总 (Test Suite Execution)
| 命令 | 结果 |
|------|------|
| `go vet ./internal/app/intentflow ./internal/app ./internal/db` | PASS |
| `go test -count=1 ./internal/app/intentflow` | PASS(PG 用例 `-short` 跳过) |
| `go test -short -count=1 ./internal/db ./internal/archtest ./internal/intent ./internal/coordinator ./internal/app` | PASS |
| `go test -short -count=1 ./...` | PASS |
| `./scripts/arch-check.sh --mode block` | PASS |
通过率 100%。隔离库 `TEST_DATABASE_URL` 未在本机设置,PG 活验按 skip 记录,不冒充已跑。共享 `:8080` schema 仍为 43。
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
U3 达到合入标准:intentflow 成为计划表唯一写者,CAS/lease/fencing/retention 在内存路径闭环,默认 legacy 不改变对话路由。
后续:U4 在独立分支接 Track E outbox/投影;不得修改通用 dispatcher lease 语义。U5 再挂 HTTP。共享环境应用 schema 44 放到后续晋升/rebuild 窗口,不在本单元对项目 143 做迁移。
@@ -0,0 +1,20 @@
=== RUN TestHandleTurnLegacyDoesNotPersist
--- PASS: TestHandleTurnLegacyDoesNotPersist (0.00s)
=== RUN TestCreateIdempotentSameDigest
--- PASS: TestCreateIdempotentSameDigest (0.00s)
=== RUN TestCreateRejectsSecondActiveWritePlan
--- PASS: TestCreateRejectsSecondActiveWritePlan (0.00s)
=== RUN TestDecisionNonceSameAndDifferentDigest
--- PASS: TestDecisionNonceSameAndDifferentDigest (0.00s)
=== RUN TestConfirmCancelRaceOneWinner
--- PASS: TestConfirmCancelRaceOneWinner (0.00s)
=== RUN TestTwoWorkersOneOwnerAndLeaseTakeover
--- PASS: TestTwoWorkersOneOwnerAndLeaseTakeover (0.00s)
=== RUN TestReceiptWritebackFailureDoesNotRerun
--- PASS: TestReceiptWritebackFailureDoesNotRerun (0.00s)
=== RUN TestExpireAndEnvironmentIsolationAndRetention
--- PASS: TestExpireAndEnvironmentIsolationAndRetention (0.00s)
=== RUN TestUnauthenticatedRejected
--- PASS: TestUnauthenticatedRejected (0.00s)
PASS
ok chat-agent-system/internal/app/intentflow 0.005s
@@ -0,0 +1,61 @@
ok chat-agent-system/cmd/bizdev-cli 1.946s
? chat-agent-system/cmd/dataset [no test files]
ok chat-agent-system/cmd/document-content-reconcile 0.002s
? chat-agent-system/cmd/document-revision-backfill [no test files]
? chat-agent-system/cmd/eventmode [no test files]
? chat-agent-system/cmd/events [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 3.163s
ok chat-agent-system/internal/app 0.015s
ok chat-agent-system/internal/app/chatflow 0.007s
ok chat-agent-system/internal/app/docadvance 0.005s
ok chat-agent-system/internal/app/docflow 0.005s
ok chat-agent-system/internal/app/docnotify 0.005s
ok chat-agent-system/internal/app/docproject 0.005s
ok chat-agent-system/internal/app/intentflow 0.005s
ok chat-agent-system/internal/app/stageflow 0.005s
ok chat-agent-system/internal/app/uow 0.006s
ok chat-agent-system/internal/archtest 0.200s
ok chat-agent-system/internal/artifactvault 0.007s
ok chat-agent-system/internal/auth 0.840s
ok chat-agent-system/internal/authz 0.004s
ok chat-agent-system/internal/bizdev 0.169s
ok chat-agent-system/internal/chat 0.018s
ok chat-agent-system/internal/config 0.004s
ok chat-agent-system/internal/coordinator 0.013s
ok chat-agent-system/internal/ctxmgr 0.096s
ok chat-agent-system/internal/dataset 0.005s
ok chat-agent-system/internal/db 0.005s
ok chat-agent-system/internal/doccontent 0.002s
ok chat-agent-system/internal/docduty 0.005s
ok chat-agent-system/internal/docgov 0.020s
ok chat-agent-system/internal/docquery 0.005s
ok chat-agent-system/internal/docrevision 0.007s
ok chat-agent-system/internal/events 0.007s
ok chat-agent-system/internal/freedoc 0.029s
ok chat-agent-system/internal/fsutil 0.005s
ok chat-agent-system/internal/httpx 0.005s
ok chat-agent-system/internal/intent 0.029s
ok chat-agent-system/internal/kb 0.008s
ok chat-agent-system/internal/maturity 0.004s
ok chat-agent-system/internal/notification 0.083s
? chat-agent-system/internal/observability [no test files]
ok chat-agent-system/internal/projectcontext 0.007s
ok chat-agent-system/internal/projectinstance 0.007s
ok chat-agent-system/internal/projectworkflow 0.006s
ok chat-agent-system/internal/quote 0.009s
ok chat-agent-system/internal/replyexport 0.002s
ok chat-agent-system/internal/server 9.896s
ok chat-agent-system/internal/squad 0.030s
ok chat-agent-system/internal/storage 0.015s
ok chat-agent-system/internal/stream 0.003s
ok chat-agent-system/internal/stream/transport 0.002s
ok chat-agent-system/internal/taskrun 0.146s
ok chat-agent-system/internal/template 0.721s
ok chat-agent-system/internal/upload 0.002s
? 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]
@@ -0,0 +1,6 @@
ok chat-agent-system/internal/app/intentflow 0.003s
ok chat-agent-system/internal/app 0.013s
ok chat-agent-system/internal/db 0.002s
ok chat-agent-system/internal/archtest 0.157s
ok chat-agent-system/internal/intent 0.017s
ok chat-agent-system/internal/coordinator 0.009s
@@ -0,0 +1,160 @@
-- +goose Up
-- U3: persistent multi-intent plans. Owner is app/intentflow; other packages use the Service port.
-- JSONB is only for versioned slots / display snapshots. CAS, lease, and audit columns are typed.
CREATE TABLE IF NOT EXISTS intent_plans (
plan_id TEXT PRIMARY KEY,
environment TEXT NOT NULL,
project_id BIGINT NOT NULL,
session_id TEXT NOT NULL,
user_id BIGINT NOT NULL,
client_turn_id TEXT NOT NULL,
request_digest TEXT NOT NULL,
segmenter_version TEXT NOT NULL DEFAULT '',
policy_version TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL,
revision BIGINT NOT NULL DEFAULT 1,
expires_at TIMESTAMPTZ NOT NULL,
last_fencing_token BIGINT NOT NULL DEFAULT 0,
write_plan BOOLEAN NOT NULL DEFAULT FALSE,
execution_mode TEXT NOT NULL DEFAULT 'validated_only',
safe_summary TEXT NOT NULL DEFAULT '',
display_snapshot JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_intent_plan_status CHECK (status IN (
'proposed',
'awaiting_clarification',
'awaiting_confirmation',
'executing',
'partially_completed',
'completed',
'failed',
'cancelled',
'expired',
'superseded'
)),
CONSTRAINT chk_intent_plan_execution_mode CHECK (execution_mode IN (
'validated_only',
'live'
))
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_intent_plans_turn
ON intent_plans (environment, session_id, client_turn_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_intent_plans_one_active_write
ON intent_plans (environment, project_id, session_id, user_id)
WHERE write_plan AND status IN (
'proposed',
'awaiting_clarification',
'awaiting_confirmation',
'executing',
'partially_completed'
);
CREATE INDEX IF NOT EXISTS idx_intent_plans_active_scope
ON intent_plans (environment, project_id, session_id, user_id, status);
CREATE INDEX IF NOT EXISTS idx_intent_plans_expire
ON intent_plans (expires_at)
WHERE status IN ('proposed', 'awaiting_clarification', 'awaiting_confirmation');
CREATE INDEX IF NOT EXISTS idx_intent_plans_retention
ON intent_plans (updated_at)
WHERE status IN ('completed', 'failed', 'cancelled', 'expired', 'superseded');
CREATE TABLE IF NOT EXISTS intent_plan_steps (
step_id TEXT PRIMARY KEY,
plan_id TEXT NOT NULL REFERENCES intent_plans(plan_id) ON DELETE CASCADE,
ordinal INT NOT NULL,
action_key TEXT NOT NULL,
slots JSONB NOT NULL DEFAULT '{}'::jsonb,
depends_on TEXT[] NOT NULL DEFAULT '{}',
status TEXT NOT NULL,
expected_resource_version TEXT NOT NULL DEFAULT '',
action_digest TEXT NOT NULL DEFAULT '',
idempotency_key TEXT NOT NULL DEFAULT '',
attempt INT NOT NULL DEFAULT 0,
result_ref TEXT NOT NULL DEFAULT '',
result_digest TEXT NOT NULL DEFAULT '',
safe_summary TEXT NOT NULL DEFAULT '',
lease_owner TEXT NOT NULL DEFAULT '',
lease_until TIMESTAMPTZ,
fencing_token BIGINT NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 1,
execution_mode TEXT NOT NULL DEFAULT 'validated_only',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_intent_plan_step_status CHECK (status IN (
'pending',
'eligible',
'running',
'succeeded',
'failed',
'denied',
'conflicted',
'blocked_dependency',
'skipped',
'cancelled'
)),
CONSTRAINT chk_intent_plan_step_execution_mode CHECK (execution_mode IN (
'validated_only',
'live'
))
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_intent_plan_steps_ordinal
ON intent_plan_steps (plan_id, ordinal);
CREATE UNIQUE INDEX IF NOT EXISTS uq_intent_plan_steps_idempotency
ON intent_plan_steps (idempotency_key)
WHERE idempotency_key <> '';
CREATE INDEX IF NOT EXISTS idx_intent_plan_steps_claim
ON intent_plan_steps (status, lease_until);
CREATE TABLE IF NOT EXISTS intent_plan_decisions (
decision_id TEXT PRIMARY KEY,
plan_id TEXT NOT NULL REFERENCES intent_plans(plan_id) ON DELETE CASCADE,
nonce TEXT NOT NULL,
plan_revision BIGINT NOT NULL,
actor_user_id BIGINT NOT NULL,
kind TEXT NOT NULL,
step_ids TEXT[] NOT NULL DEFAULT '{}',
exclude_unselected BOOLEAN NOT NULL DEFAULT FALSE,
decision_digest TEXT NOT NULL,
outcome TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT chk_intent_plan_decision_kind CHECK (kind IN (
'confirm',
'cancel',
'exclude',
'clarify',
'edit',
'confirm_retry',
'continue',
'replace'
))
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_intent_plan_decisions_nonce
ON intent_plan_decisions (plan_id, nonce);
CREATE INDEX IF NOT EXISTS idx_intent_plan_decisions_plan
ON intent_plan_decisions (plan_id, created_at);
-- +goose Down
DROP INDEX IF EXISTS idx_intent_plan_decisions_plan;
DROP INDEX IF EXISTS uq_intent_plan_decisions_nonce;
DROP TABLE IF EXISTS intent_plan_decisions;
DROP INDEX IF EXISTS idx_intent_plan_steps_claim;
DROP INDEX IF EXISTS uq_intent_plan_steps_idempotency;
DROP INDEX IF EXISTS uq_intent_plan_steps_ordinal;
DROP TABLE IF EXISTS intent_plan_steps;
DROP INDEX IF EXISTS idx_intent_plans_retention;
DROP INDEX IF EXISTS idx_intent_plans_expire;
DROP INDEX IF EXISTS idx_intent_plans_active_scope;
DROP INDEX IF EXISTS uq_intent_plans_one_active_write;
DROP INDEX IF EXISTS uq_intent_plans_turn;
DROP TABLE IF EXISTS intent_plans;
+30 -27
View File
@@ -11,6 +11,7 @@ import (
"chat-agent-system/internal/app/chatflow"
"chat-agent-system/internal/app/docflow"
"chat-agent-system/internal/app/docnotify"
"chat-agent-system/internal/app/intentflow"
"chat-agent-system/internal/app/stageflow"
"chat-agent-system/internal/app/uow"
"chat-agent-system/internal/artifactvault"
@@ -43,33 +44,34 @@ 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
Docflow *docflow.Commands
Pool *pgxpool.Pool
Gov *docgov.GovernanceEngine
Rev *docrevision.Service
Ctx *ctxmgr.ContextManager
CAS *storage.CAS
FreeDoc *freedoc.Service
KB *kb.Client
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
Docflow *docflow.Commands
IntentPlans *intentflow.Plans
Pool *pgxpool.Pool
Gov *docgov.GovernanceEngine
Rev *docrevision.Service
Ctx *ctxmgr.ContextManager
CAS *storage.CAS
FreeDoc *freedoc.Service
KB *kb.Client
mu sync.Mutex
running bool
@@ -190,6 +192,7 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
if pool != nil {
rt.Docflow.UoW = uow.NewPG(pool)
rt.Docflow.Events = events.PGSink{}
rt.IntentPlans = intentflow.NewPlans(rt.Docflow.UoW, intentflow.NewRepository(), intentflow.Options{})
}
svc.SetAgentMaterializer(rt.Docflow)
rt.bindDocumentNotify()
+2 -1
View File
@@ -9,5 +9,6 @@
// - Confirmation is not authorization; each step re-checks the current principal.
// - PostgreSQL plan/step/decision rows are authoritative; cards and SSE are projections.
// - internal/intent stays a pure rule module (intent -> maturity).
// - This package must not import docgov, taskrun, coordinator, server, or pgx.
// - This package must not import docgov, taskrun, coordinator, or server.
// - Only the owner repository unwraps pgx via app/uow (see unwrap_owners).
package intentflow
+2
View File
@@ -14,6 +14,8 @@ var (
ErrUnauthenticated = errors.New("ERR_UNAUTHENTICATED")
)
var errTurnExists = errors.New("intentflow: turn exists")
// ErrorCode returns the stable machine code for a sentinel, or empty.
func ErrorCode(err error) string {
switch {
+322
View File
@@ -0,0 +1,322 @@
package intentflow
import (
"context"
"fmt"
"slices"
"sync"
"time"
"chat-agent-system/internal/app/uow"
)
type serialScope struct{ ctx context.Context }
func (s serialScope) Context() context.Context { return s.ctx }
// SerialUoW serializes commands. Memory tests use it so CAS races have one winner.
type SerialUoW struct{ mu sync.Mutex }
func (s *SerialUoW) Within(ctx context.Context, fn func(uow.TxScope) error) error {
if s == nil {
return fmt.Errorf("intentflow: serial uow required")
}
s.mu.Lock()
defer s.mu.Unlock()
return fn(serialScope{ctx: ctx})
}
type MemoryStore struct {
mu sync.Mutex
plans map[string]*Plan
turn map[string]string
decisions map[string]*Decision
}
func NewMemoryStore() *MemoryStore {
return &MemoryStore{
plans: map[string]*Plan{},
turn: map[string]string{},
decisions: map[string]*Decision{},
}
}
func turnKey(env, session, turn string) string {
return env + "\x1f" + session + "\x1f" + turn
}
func nonceKey(planID, nonce string) string {
return planID + "\x1f" + nonce
}
func (m *MemoryStore) InsertPlan(ctx context.Context, scope uow.TxScope, plan *Plan) error {
m.mu.Lock()
defer m.mu.Unlock()
if plan == nil || plan.PlanID == "" {
return fmt.Errorf("intentflow: plan required")
}
tk := turnKey(plan.Principal.Environment, plan.Principal.SessionID, plan.ClientTurnID)
if existingID, ok := m.turn[tk]; ok {
_ = existingID
return errTurnExists
}
if plan.WritePlan && isActiveStatus(plan.Status) {
for _, p := range m.plans {
if sameScope(p.Principal, plan.Principal) && p.WritePlan && isActiveStatus(p.Status) {
return uow.ErrBusy
}
}
}
cloned := clonePlan(plan)
m.plans[plan.PlanID] = cloned
m.turn[tk] = plan.PlanID
return nil
}
func (m *MemoryStore) GetPlan(ctx context.Context, scope uow.TxScope, planID string) (*Plan, error) {
m.mu.Lock()
defer m.mu.Unlock()
p := m.plans[planID]
if p == nil {
return nil, nil
}
return clonePlan(p), nil
}
func (m *MemoryStore) GetPlanForUpdate(ctx context.Context, scope uow.TxScope, planID string) (*Plan, error) {
return m.GetPlan(ctx, scope, planID)
}
func (m *MemoryStore) GetByTurn(ctx context.Context, scope uow.TxScope, env, sessionID, clientTurnID string) (*Plan, error) {
m.mu.Lock()
defer m.mu.Unlock()
id := m.turn[turnKey(env, sessionID, clientTurnID)]
if id == "" {
return nil, nil
}
return clonePlan(m.plans[id]), nil
}
func (m *MemoryStore) GetActiveWrite(ctx context.Context, scope uow.TxScope, p Principal) (*Plan, error) {
m.mu.Lock()
defer m.mu.Unlock()
for _, plan := range m.plans {
if sameScope(plan.Principal, p) && plan.WritePlan && isActiveStatus(plan.Status) {
return clonePlan(plan), nil
}
}
return nil, nil
}
func (m *MemoryStore) GetActive(ctx context.Context, scope uow.TxScope, p Principal) (*Plan, error) {
m.mu.Lock()
defer m.mu.Unlock()
var best *Plan
for _, plan := range m.plans {
if !sameScope(plan.Principal, p) || !isActiveStatus(plan.Status) {
continue
}
if best == nil || plan.UpdatedAt.After(best.UpdatedAt) {
cp := clonePlan(plan)
best = cp
}
}
return best, nil
}
func (m *MemoryStore) CASPlan(ctx context.Context, scope uow.TxScope, planID string, expectedRev int64, from []PlanStatus, to PlanStatus, now time.Time) (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
p := m.plans[planID]
if p == nil || p.Revision != expectedRev || !slices.Contains(from, p.Status) {
return 0, nil
}
p.Status = to
p.Revision++
p.UpdatedAt = now
return p.Revision, nil
}
func (m *MemoryStore) InsertDecision(ctx context.Context, scope uow.TxScope, d *Decision) error {
m.mu.Lock()
defer m.mu.Unlock()
k := nonceKey(d.PlanID, d.Nonce)
if _, ok := m.decisions[k]; ok {
return uow.ErrDigestConflict
}
cp := *d
cp.StepIDs = slices.Clone(d.StepIDs)
m.decisions[k] = &cp
return nil
}
func (m *MemoryStore) GetDecisionByNonce(ctx context.Context, scope uow.TxScope, planID, nonce string) (*Decision, error) {
m.mu.Lock()
defer m.mu.Unlock()
d := m.decisions[nonceKey(planID, nonce)]
if d == nil {
return nil, nil
}
cp := *d
cp.StepIDs = slices.Clone(d.StepIDs)
return &cp, nil
}
func (m *MemoryStore) ReplaceSteps(ctx context.Context, scope uow.TxScope, planID string, steps []Step) error {
m.mu.Lock()
defer m.mu.Unlock()
p := m.plans[planID]
if p == nil {
return fmt.Errorf("intentflow: plan %s not found", planID)
}
p.Steps = cloneSteps(steps)
p.UpdatedAt = time.Now().UTC()
return nil
}
func (m *MemoryStore) ClaimStep(ctx context.Context, scope uow.TxScope, planID, stepID, worker string, until, now time.Time, idempotencyKey string) (*Step, int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
p := m.plans[planID]
if p == nil {
return nil, 0, nil
}
for i := range p.Steps {
s := &p.Steps[i]
if s.StepID != stepID {
continue
}
claimable := s.Status == StepEligible ||
(s.Status == StepRunning && (s.LeaseUntil == nil || s.LeaseUntil.Before(now)))
if !claimable {
return nil, 0, nil
}
p.LastFencingToken++
s.Status = StepRunning
s.LeaseOwner = worker
untilCopy := until
s.LeaseUntil = &untilCopy
s.Attempt++
s.Version++
s.FencingToken = p.LastFencingToken
if s.IdempotencyKey == "" {
s.IdempotencyKey = idempotencyKey
}
s.UpdatedAt = now
p.UpdatedAt = now
return cloneStep(s), s.FencingToken, nil
}
return nil, 0, nil
}
func (m *MemoryStore) WriteStepReceipt(ctx context.Context, scope uow.TxScope, worker string, token int64, step Step, now time.Time) (bool, error) {
m.mu.Lock()
defer m.mu.Unlock()
p := m.plans[step.PlanID]
if p == nil {
return false, nil
}
for i := range p.Steps {
s := &p.Steps[i]
if s.StepID != step.StepID {
continue
}
if s.Status == StepSucceeded && s.ResultDigest == step.ResultDigest {
return true, nil
}
if s.Status != StepRunning || s.LeaseOwner != worker || s.FencingToken != token {
return false, nil
}
s.Status = StepSucceeded
s.ResultRef = step.ResultRef
s.ResultDigest = step.ResultDigest
s.SafeSummary = step.SafeSummary
s.LeaseOwner = ""
s.LeaseUntil = nil
s.Version++
s.UpdatedAt = now
p.UpdatedAt = now
return true, nil
}
return false, nil
}
func (m *MemoryStore) ExpireWaiting(ctx context.Context, scope uow.TxScope, now time.Time) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
n := 0
for _, p := range m.plans {
if !isWaitingStatus(p.Status) || !now.After(p.ExpiresAt) {
continue
}
p.Status = PlanExpired
p.Revision++
p.UpdatedAt = now
for i := range p.Steps {
if p.Steps[i].Status == StepPending || p.Steps[i].Status == StepEligible {
p.Steps[i].Status = StepCancelled
p.Steps[i].UpdatedAt = now
}
}
n++
}
return n, nil
}
func (m *MemoryStore) PurgeClosed(ctx context.Context, scope uow.TxScope, before time.Time) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
n := 0
for id, p := range m.plans {
switch p.Status {
case PlanCompleted, PlanFailed, PlanCancelled, PlanExpired, PlanSuperseded:
default:
continue
}
if !p.UpdatedAt.Before(before) {
continue
}
delete(m.turn, turnKey(p.Principal.Environment, p.Principal.SessionID, p.ClientTurnID))
for k, d := range m.decisions {
if d.PlanID == id {
delete(m.decisions, k)
}
}
delete(m.plans, id)
n++
}
return n, nil
}
func sameScope(a, b Principal) bool {
return a.Environment == b.Environment && a.ProjectID == b.ProjectID && a.SessionID == b.SessionID && a.UserID == b.UserID
}
func clonePlan(p *Plan) *Plan {
if p == nil {
return nil
}
cp := *p
cp.DisplaySnapshot = slices.Clone(p.DisplaySnapshot)
cp.Steps = cloneSteps(p.Steps)
return &cp
}
func cloneSteps(in []Step) []Step {
out := make([]Step, len(in))
for i := range in {
out[i] = *cloneStep(&in[i])
}
return out
}
func cloneStep(s *Step) *Step {
cp := *s
cp.Slots = slices.Clone(s.Slots)
cp.DependsOn = slices.Clone(s.DependsOn)
if s.LeaseUntil != nil {
t := *s.LeaseUntil
cp.LeaseUntil = &t
}
return &cp
}
+88
View File
@@ -0,0 +1,88 @@
package intentflow
import (
"context"
"sync"
)
// ActionOwner executes one step. Old fencing tokens must be rejected here,
// not only in the plan store. The owner also stores receipts by idempotency key.
type ActionOwner interface {
Execute(ctx context.Context, exec StepExec) (Receipt, error)
LookupReceipt(ctx context.Context, idempotencyKey string) (Receipt, bool, error)
}
// FenceObserver is optional; ClaimStep notifies it so fakes can reject stale tokens.
type FenceObserver interface {
ObserveFence(stepID string, token int64)
}
// FakeActionOwner is the U3 test double. It records executions and receipts
// without touching business tables.
type FakeActionOwner struct {
mu sync.Mutex
execCount map[string]int
receipts map[string]Receipt
fences map[string]int64
FailWrite bool
}
func NewFakeActionOwner() *FakeActionOwner {
return &FakeActionOwner{
execCount: map[string]int{},
receipts: map[string]Receipt{},
fences: map[string]int64{},
}
}
func (f *FakeActionOwner) ObserveFence(stepID string, token int64) {
if f == nil {
return
}
f.mu.Lock()
defer f.mu.Unlock()
f.fences[stepID] = token
}
func (f *FakeActionOwner) Execute(ctx context.Context, exec StepExec) (Receipt, error) {
if f == nil {
return Receipt{}, ErrPlanActionForbidden
}
f.mu.Lock()
defer f.mu.Unlock()
if cur, ok := f.fences[exec.StepID]; ok && exec.FencingToken != cur {
return Receipt{}, ErrPlanActionVersionConflict
}
if existing, ok := f.receipts[exec.IdempotencyKey]; ok {
if existing.ActionDigest != exec.ActionDigest {
return Receipt{}, ErrPlanDecisionConflict
}
return existing, nil
}
f.execCount[exec.IdempotencyKey]++
rec := Receipt{
IdempotencyKey: exec.IdempotencyKey,
ActionDigest: exec.ActionDigest,
ResultRef: "fake:" + exec.StepID,
ResultDigest: "digest:" + exec.ActionDigest,
SafeSummary: "validated_only",
}
f.receipts[exec.IdempotencyKey] = rec
return rec, nil
}
func (f *FakeActionOwner) LookupReceipt(ctx context.Context, idempotencyKey string) (Receipt, bool, error) {
if f == nil {
return Receipt{}, false, nil
}
f.mu.Lock()
defer f.mu.Unlock()
rec, ok := f.receipts[idempotencyKey]
return rec, ok, nil
}
func (f *FakeActionOwner) ExecCount(idempotencyKey string) int {
f.mu.Lock()
defer f.mu.Unlock()
return f.execCount[idempotencyKey]
}
+113
View File
@@ -0,0 +1,113 @@
package intentflow_test
import (
"context"
"errors"
"os"
"testing"
"time"
"chat-agent-system/internal/app/intentflow"
"chat-agent-system/internal/app/uow"
"chat-agent-system/internal/db"
"github.com/jackc/pgx/v5/pgxpool"
)
func init() {
if _, err := os.Stat("deploy/postgres/goose"); err == nil {
return
}
for _, cand := range []string{"../../..", "../../../.."} {
if fi, err := os.Stat(cand + "/deploy/postgres/goose"); err == nil && fi.IsDir() {
_ = os.Chdir(cand)
return
}
}
}
func TestPlansPG_CreateClaimAndFence(t *testing.T) {
if testing.Short() {
t.Skip("intentflow PG is not part of -short")
}
dsn := db.SkipIfNoTestDatabaseURL(t)
db.RefuseSharedEnvDSN(t, dsn)
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(pool.Close)
if err := db.RunMigrationsOnceForTest(ctx, pool, dsn); err != nil {
t.Fatalf("migrate: %v", err)
}
now := time.Date(2026, 9, 15, 15, 0, 0, 0, time.UTC)
clock := now
owner := intentflow.NewFakeActionOwner()
plans := intentflow.NewPlans(uow.NewPG(pool), intentflow.NewRepository(), intentflow.Options{
Owner: owner,
Now: func() time.Time { return clock },
Lease: 5 * time.Second,
})
prin := intentflow.Principal{UserID: 9, Environment: "ci", ProjectID: 1001, SessionID: "sess-pg-u3"}
created, err := plans.CreatePlan(ctx, intentflow.CreateCommand{
Principal: prin, ClientTurnID: "pg-turn-1", RequestDigest: "pg-d1",
WritePlan: true,
Steps: []intentflow.StepDraft{
{ActionKey: "regulation.query", ActionDigest: "q"},
{ActionKey: "task.run", ActionDigest: "w"},
},
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM intent_plans WHERE plan_id=$1`, created.PlanID)
})
again, err := plans.CreatePlan(ctx, intentflow.CreateCommand{
Principal: prin, ClientTurnID: "pg-turn-1", RequestDigest: "pg-d1",
WritePlan: true,
Steps: []intentflow.StepDraft{
{ActionKey: "regulation.query", ActionDigest: "q"},
{ActionKey: "task.run", ActionDigest: "w"},
},
})
if err != nil || again.PlanID != created.PlanID {
t.Fatalf("idempotent create: %v %+v", err, again)
}
confirmed, err := plans.DecidePlan(ctx, intentflow.DecisionCommand{
Principal: prin, PlanID: created.PlanID, ExpectedRevision: created.Revision,
DecisionNonce: "pg-n", DecisionDigest: "pg-dec", Kind: intentflow.DecisionConfirm,
})
if err != nil {
t.Fatal(err)
}
a, err := plans.ClaimStep(ctx, "pg-a", created.PlanID, confirmed.Steps[0].StepID)
if err != nil || a == nil {
t.Fatalf("claim a: %v", err)
}
b, err := plans.ClaimStep(ctx, "pg-b", created.PlanID, confirmed.Steps[0].StepID)
if err != nil {
t.Fatal(err)
}
if b != nil {
t.Fatal("second worker must not own a live lease")
}
clock = clock.Add(time.Minute)
c, err := plans.ClaimStep(ctx, "pg-c", created.PlanID, confirmed.Steps[0].StepID)
if err != nil || c == nil {
t.Fatalf("takeover: %v", err)
}
if _, err := plans.ExecuteClaimed(ctx, a); !errors.Is(err, intentflow.ErrPlanActionVersionConflict) {
t.Fatalf("old fence: %v", err)
}
if _, err := plans.ExecuteClaimed(ctx, c); err != nil {
t.Fatal(err)
}
other := prin
other.Environment = "dev"
if _, err := plans.GetPlan(ctx, other, created.PlanID); !errors.Is(err, intentflow.ErrPlanNotFoundOrHidden) {
t.Fatalf("env isolation: %v", err)
}
}
+563
View File
@@ -0,0 +1,563 @@
package intentflow
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"slices"
"time"
"chat-agent-system/internal/app/uow"
)
const (
defaultLeaseTTL = 15 * time.Second
defaultPlanTTL = 30 * time.Minute
)
// Plans is the owner implementation of Service plus worker/recovery methods.
type Plans struct {
uow uow.UnitOfWork
store Store
owner ActionOwner
now func() time.Time
newID func(prefix string) string
lease time.Duration
ttl time.Duration
}
type Options struct {
Owner ActionOwner
Now func() time.Time
NewID func(prefix string) string
Lease time.Duration
PlanTTL time.Duration
}
func NewPlans(unit uow.UnitOfWork, store Store, opt Options) *Plans {
now := opt.Now
if now == nil {
now = func() time.Time { return time.Now().UTC() }
}
newID := opt.NewID
if newID == nil {
newID = randomID
}
lease := opt.Lease
if lease <= 0 {
lease = defaultLeaseTTL
}
ttl := opt.PlanTTL
if ttl <= 0 {
ttl = defaultPlanTTL
}
return &Plans{uow: unit, store: store, owner: opt.Owner, now: now, newID: newID, lease: lease, ttl: ttl}
}
func randomID(prefix string) string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return prefix + fmt.Sprintf("%d", time.Now().UnixNano())
}
return prefix + hex.EncodeToString(b[:])
}
func requirePrincipal(p Principal) error {
if p.UserID <= 0 || p.ProjectID <= 0 || p.SessionID == "" || p.Environment == "" {
return ErrUnauthenticated
}
return nil
}
func visibleTo(plan *Plan, p Principal) bool {
return plan != nil && sameScope(plan.Principal, p)
}
// HandleTurn stays a no-op while MULTI_INTENT_PLAN_TIER is legacy/shadow (default).
// U7 will map utterances onto CreatePlan; chat must not grow a second owner.
func (p *Plans) HandleTurn(ctx context.Context, in TurnInput) (*TurnResult, error) {
if err := requirePrincipal(in.Principal); err != nil {
return nil, err
}
return &TurnResult{}, nil
}
func (p *Plans) GetPlan(ctx context.Context, principal Principal, planID string) (*PlanView, error) {
if err := requirePrincipal(principal); err != nil {
return nil, err
}
var view *PlanView
err := p.uow.Within(ctx, func(scope uow.TxScope) error {
plan, err := p.store.GetPlan(ctx, scope, planID)
if err != nil {
return err
}
if !visibleTo(plan, principal) {
return ErrPlanNotFoundOrHidden
}
view = toView(plan)
return nil
})
return view, err
}
func (p *Plans) GetActivePlan(ctx context.Context, principal Principal) (*PlanView, error) {
if err := requirePrincipal(principal); err != nil {
return nil, err
}
var view *PlanView
err := p.uow.Within(ctx, func(scope uow.TxScope) error {
plan, err := p.store.GetActive(ctx, scope, principal)
if err != nil {
return err
}
if plan == nil {
return nil
}
view = toView(plan)
return nil
})
return view, err
}
func (p *Plans) CreatePlan(ctx context.Context, in CreateCommand) (*PlanView, error) {
if err := requirePrincipal(in.Principal); err != nil {
return nil, err
}
if in.ClientTurnID == "" || in.RequestDigest == "" {
return nil, ErrPlanDecisionConflict
}
if len(in.Steps) == 0 || len(in.Steps) > 4 {
return nil, ErrPlanDependencyInvalid
}
now := p.now()
status := in.Status
if status == "" {
status = PlanAwaitingConfirmation
}
mode := in.ExecutionMode
if mode == "" {
mode = ModeValidatedOnly
}
expires := in.ExpiresAt
if expires.IsZero() {
expires = now.Add(p.ttl)
}
seg := in.SegmenterVersion
if seg == "" {
seg = SegmenterVersionUnknown
}
pol := in.PolicyVersion
if pol == "" {
pol = PolicyVersion
}
var view *PlanView
err := p.uow.Within(ctx, func(scope uow.TxScope) error {
existing, err := p.store.GetByTurn(ctx, scope, in.Principal.Environment, in.Principal.SessionID, in.ClientTurnID)
if err != nil {
return err
}
if existing != nil {
if existing.RequestDigest != in.RequestDigest {
return ErrPlanDecisionConflict
}
view = toView(existing)
return nil
}
if in.WritePlan {
active, err := p.store.GetActiveWrite(ctx, scope, in.Principal)
if err != nil {
return err
}
if active != nil {
view = toView(active)
return ErrPlanDecisionConflict
}
}
plan := &Plan{
PlanID: p.newID("pln_"),
Principal: in.Principal,
ClientTurnID: in.ClientTurnID,
RequestDigest: in.RequestDigest,
SegmenterVersion: seg,
PolicyVersion: pol,
Status: status,
Revision: 1,
ExpiresAt: expires,
WritePlan: in.WritePlan,
ExecutionMode: mode,
SafeSummary: in.SafeSummary,
CreatedAt: now,
UpdatedAt: now,
}
for i, d := range in.Steps {
mode := d.ExecutionMode
if mode == "" {
mode = plan.ExecutionMode
}
slots := d.Slots
if len(slots) == 0 {
slots = []byte("{}")
}
plan.Steps = append(plan.Steps, Step{
PlanID: plan.PlanID,
StepID: p.newID("stp_"),
Ordinal: i + 1,
ActionKey: d.ActionKey,
Slots: slots,
DependsOn: slices.Clone(d.DependsOn),
Status: StepPending,
ExpectedResourceVersion: d.ExpectedResourceVersion,
ActionDigest: d.ActionDigest,
SafeSummary: d.SafeSummary,
Version: 1,
ExecutionMode: mode,
CreatedAt: now,
UpdatedAt: now,
})
}
if err := p.store.InsertPlan(ctx, scope, plan); err != nil {
if errors.Is(err, errTurnExists) {
again, loadErr := p.store.GetByTurn(ctx, scope, in.Principal.Environment, in.Principal.SessionID, in.ClientTurnID)
if loadErr != nil {
return loadErr
}
if again != nil && again.RequestDigest == in.RequestDigest {
view = toView(again)
return nil
}
return ErrPlanDecisionConflict
}
if errors.Is(err, uow.ErrBusy) {
active, loadErr := p.store.GetActiveWrite(ctx, scope, in.Principal)
if loadErr != nil {
return loadErr
}
if active != nil {
view = toView(active)
}
return ErrPlanDecisionConflict
}
return err
}
view = toView(plan)
return nil
})
return view, err
}
func (p *Plans) DecidePlan(ctx context.Context, in DecisionCommand) (*PlanView, error) {
if err := requirePrincipal(in.Principal); err != nil {
return nil, err
}
if in.PlanID == "" || in.DecisionNonce == "" || in.DecisionDigest == "" {
return nil, ErrPlanDecisionConflict
}
now := p.now()
var view *PlanView
err := p.uow.Within(ctx, func(scope uow.TxScope) error {
plan, err := p.store.GetPlanForUpdate(ctx, scope, in.PlanID)
if err != nil {
return err
}
if !visibleTo(plan, in.Principal) {
return ErrPlanNotFoundOrHidden
}
existing, err := p.store.GetDecisionByNonce(ctx, scope, in.PlanID, in.DecisionNonce)
if err != nil {
return err
}
if existing != nil {
if existing.DecisionDigest != in.DecisionDigest {
return ErrPlanDecisionConflict
}
fresh, err := p.store.GetPlan(ctx, scope, in.PlanID)
if err != nil {
return err
}
view = toView(fresh)
return nil
}
if isWaitingStatus(plan.Status) && !now.Before(plan.ExpiresAt) {
if _, err := p.store.CASPlan(ctx, scope, plan.PlanID, plan.Revision, waitingStatuses(), PlanExpired, now); err != nil {
return err
}
cancelUnstarted(plan, now)
if err := p.store.ReplaceSteps(ctx, scope, plan.PlanID, plan.Steps); err != nil {
return err
}
return ErrPlanExpired
}
if in.ExpectedRevision != plan.Revision {
return ErrPlanStaleRevision
}
dec := &Decision{
DecisionID: p.newID("dec_"),
PlanID: plan.PlanID,
Nonce: in.DecisionNonce,
PlanRevision: plan.Revision,
ActorUserID: in.Principal.UserID,
Kind: in.Kind,
StepIDs: slices.Clone(in.StepIDs),
ExcludeUnselected: in.ExcludeUnselected,
DecisionDigest: in.DecisionDigest,
CreatedAt: now,
}
if err := p.store.InsertDecision(ctx, scope, dec); err != nil {
if errors.Is(err, uow.ErrDigestConflict) || errors.Is(err, errTurnExists) {
again, loadErr := p.store.GetDecisionByNonce(ctx, scope, in.PlanID, in.DecisionNonce)
if loadErr != nil {
return loadErr
}
if again != nil && again.DecisionDigest == in.DecisionDigest {
fresh, err := p.store.GetPlan(ctx, scope, in.PlanID)
if err != nil {
return err
}
view = toView(fresh)
return nil
}
return ErrPlanDecisionConflict
}
return err
}
from := []PlanStatus{plan.Status}
var to PlanStatus
switch in.Kind {
case DecisionConfirm:
if plan.Status != PlanAwaitingConfirmation && plan.Status != PlanAwaitingClarification && plan.Status != PlanProposed {
return ErrPlanStaleRevision
}
if err := applyConfirm(plan, in.StepIDs, in.ExcludeUnselected, now); err != nil {
return err
}
to = PlanExecuting
case DecisionCancel:
if !isWaitingStatus(plan.Status) && plan.Status != PlanExecuting && plan.Status != PlanPartiallyCompleted {
return ErrPlanStaleRevision
}
cancelUnstarted(plan, now)
to = PlanCancelled
default:
return ErrPlanDependencyInvalid
}
newRev, err := p.store.CASPlan(ctx, scope, plan.PlanID, plan.Revision, from, to, now)
if err != nil {
return err
}
if newRev == 0 {
return ErrPlanStaleRevision
}
if err := p.store.ReplaceSteps(ctx, scope, plan.PlanID, plan.Steps); err != nil {
return err
}
plan.Status = to
plan.Revision = newRev
view = toView(plan)
return nil
})
return view, err
}
func applyConfirm(plan *Plan, selected []string, excludeUnselected bool, now time.Time) error {
byID := map[string]*Step{}
for i := range plan.Steps {
byID[plan.Steps[i].StepID] = &plan.Steps[i]
}
chosen := map[string]bool{}
if len(selected) == 0 {
for i := range plan.Steps {
chosen[plan.Steps[i].StepID] = true
}
} else {
for _, id := range selected {
if _, ok := byID[id]; !ok {
return ErrPlanDependencyInvalid
}
chosen[id] = true
}
for id := range chosen {
for _, dep := range byID[id].DependsOn {
if !chosen[dep] && byID[dep].Status != StepSucceeded && byID[dep].Status != StepSkipped {
return ErrPlanDependencyInvalid
}
}
}
}
for i := range plan.Steps {
s := &plan.Steps[i]
if chosen[s.StepID] {
if s.Status == StepPending || s.Status == StepEligible {
s.Status = StepEligible
s.UpdatedAt = now
}
continue
}
if excludeUnselected && (s.Status == StepPending || s.Status == StepEligible) {
s.Status = StepCancelled
s.UpdatedAt = now
}
}
return nil
}
func cancelUnstarted(plan *Plan, now time.Time) {
for i := range plan.Steps {
s := &plan.Steps[i]
if s.Status == StepPending || s.Status == StepEligible {
s.Status = StepCancelled
s.UpdatedAt = now
}
}
}
func (p *Plans) ClaimStep(ctx context.Context, workerID, planID, stepID string) (*ClaimedStep, error) {
if workerID == "" || planID == "" || stepID == "" {
return nil, ErrPlanDependencyInvalid
}
now := p.now()
until := now.Add(p.lease)
var out *ClaimedStep
err := p.uow.Within(ctx, func(scope uow.TxScope) error {
plan, err := p.store.GetPlanForUpdate(ctx, scope, planID)
if err != nil {
return err
}
if plan == nil {
return ErrPlanNotFoundOrHidden
}
var digest string
for _, s := range plan.Steps {
if s.StepID == stepID {
digest = s.ActionDigest
break
}
}
key := fmt.Sprintf("%s:%d:%s:%s", planID, plan.Revision, stepID, digest)
step, token, err := p.store.ClaimStep(ctx, scope, planID, stepID, workerID, until, now, key)
if err != nil {
return err
}
if step == nil {
return nil
}
if obs, ok := p.owner.(FenceObserver); ok {
obs.ObserveFence(step.StepID, token)
}
out = &ClaimedStep{Plan: *plan, Step: *step, WorkerID: workerID, FencingToken: token, IdempotencyKey: step.IdempotencyKey}
return nil
})
return out, err
}
func (p *Plans) ExecuteClaimed(ctx context.Context, claimed *ClaimedStep) (*PlanView, error) {
if claimed == nil {
return nil, ErrPlanNotFoundOrHidden
}
if p.owner == nil {
return nil, ErrPlanActionForbidden
}
rec, err := p.owner.Execute(ctx, StepExec{
Principal: claimed.Plan.Principal,
PlanID: claimed.Plan.PlanID,
StepID: claimed.Step.StepID,
ActionKey: claimed.Step.ActionKey,
IdempotencyKey: claimed.IdempotencyKey,
ActionDigest: claimed.Step.ActionDigest,
ExpectedResourceVersion: claimed.Step.ExpectedResourceVersion,
FencingToken: claimed.FencingToken,
})
if err != nil {
return nil, err
}
return p.writeReceipt(ctx, claimed, rec)
}
func (p *Plans) RecoverReceipt(ctx context.Context, claimed *ClaimedStep) (*PlanView, error) {
if claimed == nil || p.owner == nil {
return nil, ErrPlanNotFoundOrHidden
}
rec, ok, err := p.owner.LookupReceipt(ctx, claimed.IdempotencyKey)
if err != nil {
return nil, err
}
if !ok {
return nil, ErrPlanActionVersionConflict
}
return p.writeReceipt(ctx, claimed, rec)
}
func (p *Plans) writeReceipt(ctx context.Context, claimed *ClaimedStep, rec Receipt) (*PlanView, error) {
now := p.now()
var view *PlanView
err := p.uow.Within(ctx, func(scope uow.TxScope) error {
ok, err := p.store.WriteStepReceipt(ctx, scope, claimed.WorkerID, claimed.FencingToken, Step{
PlanID: claimed.Plan.PlanID,
StepID: claimed.Step.StepID,
ResultRef: rec.ResultRef,
ResultDigest: rec.ResultDigest,
SafeSummary: rec.SafeSummary,
}, now)
if err != nil {
return err
}
if !ok {
return ErrPlanActionVersionConflict
}
plan, err := p.store.GetPlan(ctx, scope, claimed.Plan.PlanID)
if err != nil {
return err
}
view = toView(plan)
return nil
})
return view, err
}
func (p *Plans) ExpireDue(ctx context.Context) (int, error) {
var n int
err := p.uow.Within(ctx, func(scope uow.TxScope) error {
var err error
n, err = p.store.ExpireWaiting(ctx, scope, p.now())
return err
})
return n, err
}
func (p *Plans) PurgeClosed(ctx context.Context, olderThan time.Time) (int, error) {
var n int
err := p.uow.Within(ctx, func(scope uow.TxScope) error {
var err error
n, err = p.store.PurgeClosed(ctx, scope, olderThan)
return err
})
return n, err
}
func toView(plan *Plan) *PlanView {
if plan == nil {
return nil
}
v := &PlanView{
PlanID: plan.PlanID,
Revision: plan.Revision,
Status: plan.Status,
ExecutionMode: plan.ExecutionMode,
ExpiresAt: plan.ExpiresAt,
SafeSummary: plan.SafeSummary,
}
for _, s := range plan.Steps {
v.Steps = append(v.Steps, StepView{
StepID: s.StepID,
ActionKey: s.ActionKey,
Status: s.Status,
ExecutionMode: s.ExecutionMode,
DependsOn: slices.Clone(s.DependsOn),
SafeSummary: s.SafeSummary,
})
}
return v
}
+321
View File
@@ -0,0 +1,321 @@
package intentflow_test
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"chat-agent-system/internal/app/intentflow"
)
func testClock() (now *time.Time, opts intentflow.Options, owner *intentflow.FakeActionOwner) {
t0 := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
now = &t0
owner = intentflow.NewFakeActionOwner()
opts = intentflow.Options{
Owner: owner,
Now: func() time.Time { return now.UTC() },
Lease: 10 * time.Second,
PlanTTL: 30 * time.Minute,
}
return now, opts, owner
}
func testPlans() (*intentflow.Plans, *time.Time, *intentflow.FakeActionOwner) {
now, opts, owner := testClock()
return intentflow.NewPlans(&intentflow.SerialUoW{}, intentflow.NewMemoryStore(), opts), now, owner
}
func principal() intentflow.Principal {
return intentflow.Principal{UserID: 7, Environment: "dev", ProjectID: 42, SessionID: "sess-u3"}
}
func draft(digest string) intentflow.CreateCommand {
return intentflow.CreateCommand{
Principal: principal(),
ClientTurnID: "turn-1",
RequestDigest: digest,
WritePlan: true,
SafeSummary: "query then generate",
Steps: []intentflow.StepDraft{
{ActionKey: "regulation.query", ActionDigest: "q1"},
{ActionKey: "task.run", ActionDigest: "w1"},
},
}
}
func TestHandleTurnLegacyDoesNotPersist(t *testing.T) {
p, _, _ := testPlans()
ctx := context.Background()
res, err := p.HandleTurn(ctx, intentflow.TurnInput{Principal: principal(), Text: "生成报价单", ClientTurnID: "turn-1", RequestDigest: "d1"})
if err != nil {
t.Fatal(err)
}
if res == nil || res.Plan != nil {
t.Fatalf("legacy HandleTurn must not create a plan: %+v", res)
}
active, err := p.GetActivePlan(ctx, principal())
if err != nil {
t.Fatal(err)
}
if active != nil {
t.Fatal("no plan should be visible")
}
}
func TestCreateIdempotentSameDigest(t *testing.T) {
p, _, _ := testPlans()
ctx := context.Background()
a, err := p.CreatePlan(ctx, draft("digest-a"))
if err != nil {
t.Fatal(err)
}
b, err := p.CreatePlan(ctx, draft("digest-a"))
if err != nil {
t.Fatal(err)
}
if a.PlanID != b.PlanID {
t.Fatalf("same turn/digest must reuse plan %s vs %s", a.PlanID, b.PlanID)
}
other := draft("digest-b")
if _, err := p.CreatePlan(ctx, other); !errors.Is(err, intentflow.ErrPlanDecisionConflict) {
t.Fatalf("different digest must conflict, got %v", err)
}
}
func TestCreateRejectsSecondActiveWritePlan(t *testing.T) {
p, _, _ := testPlans()
ctx := context.Background()
first, err := p.CreatePlan(ctx, draft("digest-a"))
if err != nil {
t.Fatal(err)
}
second := draft("digest-x")
second.ClientTurnID = "turn-2"
got, err := p.CreatePlan(ctx, second)
if !errors.Is(err, intentflow.ErrPlanDecisionConflict) {
t.Fatalf("want conflict, got %v", err)
}
if got == nil || got.PlanID != first.PlanID {
t.Fatalf("must return the active write plan, got %+v", got)
}
}
func TestDecisionNonceSameAndDifferentDigest(t *testing.T) {
p, _, _ := testPlans()
ctx := context.Background()
created, err := p.CreatePlan(ctx, draft("digest-a"))
if err != nil {
t.Fatal(err)
}
cmd := intentflow.DecisionCommand{
Principal: principal(),
PlanID: created.PlanID,
ExpectedRevision: created.Revision,
DecisionNonce: "nonce-1",
DecisionDigest: "dec-1",
Kind: intentflow.DecisionConfirm,
}
first, err := p.DecidePlan(ctx, cmd)
if err != nil {
t.Fatal(err)
}
again, err := p.DecidePlan(ctx, cmd)
if err != nil {
t.Fatal(err)
}
if first.Revision != again.Revision {
t.Fatal("same nonce/digest must return the same receipt")
}
cmd.DecisionDigest = "dec-other"
if _, err := p.DecidePlan(ctx, cmd); !errors.Is(err, intentflow.ErrPlanDecisionConflict) {
t.Fatalf("different digest must conflict, got %v", err)
}
}
func TestConfirmCancelRaceOneWinner(t *testing.T) {
p, _, _ := testPlans()
ctx := context.Background()
created, err := p.CreatePlan(ctx, draft("digest-a"))
if err != nil {
t.Fatal(err)
}
var okConfirm, okCancel atomic.Int32
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
_, err := p.DecidePlan(ctx, intentflow.DecisionCommand{
Principal: principal(), PlanID: created.PlanID, ExpectedRevision: created.Revision,
DecisionNonce: "n-confirm", DecisionDigest: "d-confirm", Kind: intentflow.DecisionConfirm,
})
if err == nil {
okConfirm.Add(1)
}
}()
go func() {
defer wg.Done()
_, err := p.DecidePlan(ctx, intentflow.DecisionCommand{
Principal: principal(), PlanID: created.PlanID, ExpectedRevision: created.Revision,
DecisionNonce: "n-cancel", DecisionDigest: "d-cancel", Kind: intentflow.DecisionCancel,
})
if err == nil {
okCancel.Add(1)
}
}()
wg.Wait()
if okConfirm.Load()+okCancel.Load() != 1 {
t.Fatalf("exactly one CAS winner, confirm=%d cancel=%d", okConfirm.Load(), okCancel.Load())
}
}
func TestTwoWorkersOneOwnerAndLeaseTakeover(t *testing.T) {
p, clock, owner := testPlans()
ctx := context.Background()
created, err := p.CreatePlan(ctx, draft("digest-a"))
if err != nil {
t.Fatal(err)
}
confirmed, err := p.DecidePlan(ctx, intentflow.DecisionCommand{
Principal: principal(), PlanID: created.PlanID, ExpectedRevision: created.Revision,
DecisionNonce: "n-c", DecisionDigest: "d-c", Kind: intentflow.DecisionConfirm,
})
if err != nil {
t.Fatal(err)
}
stepID := confirmed.Steps[0].StepID
var gotA, gotB atomic.Int32
var first *intentflow.ClaimedStep
var mu sync.Mutex
var wg sync.WaitGroup
wg.Add(2)
claim := func(worker string, slot *atomic.Int32) {
defer wg.Done()
c, err := p.ClaimStep(ctx, worker, created.PlanID, stepID)
if err != nil {
t.Errorf("claim %s: %v", worker, err)
return
}
if c != nil {
slot.Add(1)
mu.Lock()
if first == nil {
first = c
}
mu.Unlock()
}
}
go claim("worker-a", &gotA)
go claim("worker-b", &gotB)
wg.Wait()
if gotA.Load()+gotB.Load() != 1 {
t.Fatalf("two workers must not both own, a=%d b=%d", gotA.Load(), gotB.Load())
}
stale := first
*clock = clock.Add(time.Minute)
takeover, err := p.ClaimStep(ctx, "worker-c", created.PlanID, stepID)
if err != nil || takeover == nil {
t.Fatalf("expired lease must be taken over: %v %+v", err, takeover)
}
if takeover.FencingToken <= stale.FencingToken {
t.Fatalf("fencing must be monotonic: old=%d new=%d", stale.FencingToken, takeover.FencingToken)
}
if _, err := p.ExecuteClaimed(ctx, stale); !errors.Is(err, intentflow.ErrPlanActionVersionConflict) {
t.Fatalf("old fence must be rejected by owner, got %v", err)
}
if _, err := p.ExecuteClaimed(ctx, takeover); err != nil {
t.Fatal(err)
}
if owner.ExecCount(takeover.IdempotencyKey) != 1 {
t.Fatalf("execute count = %d", owner.ExecCount(takeover.IdempotencyKey))
}
}
func TestReceiptWritebackFailureDoesNotRerun(t *testing.T) {
p, _, owner := testPlans()
ctx := context.Background()
created, err := p.CreatePlan(ctx, draft("digest-a"))
if err != nil {
t.Fatal(err)
}
confirmed, err := p.DecidePlan(ctx, intentflow.DecisionCommand{
Principal: principal(), PlanID: created.PlanID, ExpectedRevision: created.Revision,
DecisionNonce: "n-c", DecisionDigest: "d-c", Kind: intentflow.DecisionConfirm,
})
if err != nil {
t.Fatal(err)
}
claimed, err := p.ClaimStep(ctx, "w1", created.PlanID, confirmed.Steps[0].StepID)
if err != nil || claimed == nil {
t.Fatalf("claim: %v %+v", err, claimed)
}
if _, err := owner.Execute(ctx, intentflow.StepExec{
Principal: principal(), PlanID: claimed.Plan.PlanID, StepID: claimed.Step.StepID,
ActionKey: claimed.Step.ActionKey, IdempotencyKey: claimed.IdempotencyKey,
ActionDigest: claimed.Step.ActionDigest, FencingToken: claimed.FencingToken,
}); err != nil {
t.Fatal(err)
}
if _, err := p.RecoverReceipt(ctx, claimed); err != nil {
t.Fatal(err)
}
if owner.ExecCount(claimed.IdempotencyKey) != 1 {
t.Fatalf("recover must not re-execute, count=%d", owner.ExecCount(claimed.IdempotencyKey))
}
got, err := p.GetPlan(ctx, principal(), created.PlanID)
if err != nil {
t.Fatal(err)
}
if got.Steps[0].Status != intentflow.StepSucceeded {
t.Fatalf("step status %s", got.Steps[0].Status)
}
}
func TestExpireAndEnvironmentIsolationAndRetention(t *testing.T) {
p, clock, _ := testPlans()
ctx := context.Background()
created, err := p.CreatePlan(ctx, draft("digest-a"))
if err != nil {
t.Fatal(err)
}
other := principal()
other.Environment = "test"
if _, err := p.GetPlan(ctx, other, created.PlanID); !errors.Is(err, intentflow.ErrPlanNotFoundOrHidden) {
t.Fatalf("cross-env must hide, got %v", err)
}
*clock = clock.Add(time.Hour)
n, err := p.ExpireDue(ctx)
if err != nil || n != 1 {
t.Fatalf("expire n=%d err=%v", n, err)
}
if _, err := p.DecidePlan(ctx, intentflow.DecisionCommand{
Principal: principal(), PlanID: created.PlanID, ExpectedRevision: 1,
DecisionNonce: "late", DecisionDigest: "late", Kind: intentflow.DecisionConfirm,
}); !errors.Is(err, intentflow.ErrPlanExpired) && !errors.Is(err, intentflow.ErrPlanStaleRevision) {
got, _ := p.GetPlan(ctx, principal(), created.PlanID)
if got == nil || got.Status != intentflow.PlanExpired {
t.Fatalf("expired plan confirm: %v view=%+v", err, got)
}
}
purged, err := p.PurgeClosed(ctx, clock.Add(time.Second))
if err != nil || purged != 1 {
t.Fatalf("purge n=%d err=%v", purged, err)
}
if _, err := p.GetPlan(ctx, principal(), created.PlanID); !errors.Is(err, intentflow.ErrPlanNotFoundOrHidden) {
t.Fatalf("purged plan must be hidden, got %v", err)
}
}
func TestUnauthenticatedRejected(t *testing.T) {
p, _, _ := testPlans()
_, err := p.CreatePlan(context.Background(), intentflow.CreateCommand{
Principal: intentflow.Principal{}, ClientTurnID: "t", RequestDigest: "d",
Steps: []intentflow.StepDraft{{ActionKey: "regulation.query"}},
})
if !errors.Is(err, intentflow.ErrUnauthenticated) {
t.Fatalf("got %v", err)
}
}
+486
View File
@@ -0,0 +1,486 @@
package intentflow
import (
"context"
"encoding/json"
"errors"
"time"
"chat-agent-system/internal/app/uow"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
// Repository is the PostgreSQL owner of intent_plans*.
type Repository struct{}
func NewRepository() Repository { return Repository{} }
func txFrom(scope uow.TxScope) (pgx.Tx, error) {
return uow.AsPG(scope)
}
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
func uniqueConstraint(err error) string {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
return pgErr.ConstraintName
}
return ""
}
func (Repository) InsertPlan(ctx context.Context, scope uow.TxScope, plan *Plan) error {
tx, err := txFrom(scope)
if err != nil {
return err
}
snap := plan.DisplaySnapshot
if len(snap) == 0 {
snap = nil
}
_, err = tx.Exec(ctx, `
INSERT INTO intent_plans (
plan_id, environment, project_id, session_id, user_id, client_turn_id, request_digest,
segmenter_version, policy_version, status, revision, expires_at, last_fencing_token,
write_plan, execution_mode, safe_summary, display_snapshot, created_at, updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)`,
plan.PlanID, plan.Principal.Environment, plan.Principal.ProjectID, plan.Principal.SessionID,
plan.Principal.UserID, plan.ClientTurnID, plan.RequestDigest, plan.SegmenterVersion,
plan.PolicyVersion, string(plan.Status), plan.Revision, plan.ExpiresAt, plan.LastFencingToken,
plan.WritePlan, string(plan.ExecutionMode), plan.SafeSummary, jsonbOrNil(snap),
plan.CreatedAt, plan.UpdatedAt,
)
if err != nil {
if isUniqueViolation(err) {
if uniqueConstraint(err) == "uq_intent_plans_one_active_write" {
return uow.ErrBusy
}
return errTurnExists
}
return err
}
for _, s := range plan.Steps {
if err := insertStep(ctx, tx, s); err != nil {
return err
}
}
return nil
}
func insertStep(ctx context.Context, tx pgx.Tx, s Step) error {
slots := s.Slots
if len(slots) == 0 {
slots = []byte("{}")
}
_, err := tx.Exec(ctx, `
INSERT INTO intent_plan_steps (
step_id, plan_id, ordinal, action_key, slots, depends_on, status, expected_resource_version,
action_digest, idempotency_key, attempt, result_ref, result_digest, safe_summary,
lease_owner, lease_until, fencing_token, version, execution_mode, created_at, updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)`,
s.StepID, s.PlanID, s.Ordinal, s.ActionKey, slots, s.DependsOn, string(s.Status),
s.ExpectedResourceVersion, s.ActionDigest, s.IdempotencyKey, s.Attempt, s.ResultRef,
s.ResultDigest, s.SafeSummary, s.LeaseOwner, s.LeaseUntil, s.FencingToken, s.Version,
string(s.ExecutionMode), s.CreatedAt, s.UpdatedAt,
)
return err
}
func (r Repository) GetPlan(ctx context.Context, scope uow.TxScope, planID string) (*Plan, error) {
return r.loadPlan(ctx, scope, planID, false)
}
func (r Repository) GetPlanForUpdate(ctx context.Context, scope uow.TxScope, planID string) (*Plan, error) {
return r.loadPlan(ctx, scope, planID, true)
}
func (Repository) loadPlan(ctx context.Context, scope uow.TxScope, planID string, forUpdate bool) (*Plan, error) {
tx, err := txFrom(scope)
if err != nil {
return nil, err
}
q := planSelect + " WHERE plan_id=$1"
if forUpdate {
q += " FOR UPDATE"
}
plan, err := scanPlan(tx.QueryRow(ctx, q, planID))
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
steps, err := loadSteps(ctx, tx, plan.PlanID)
if err != nil {
return nil, err
}
plan.Steps = steps
return plan, nil
}
func (Repository) GetByTurn(ctx context.Context, scope uow.TxScope, env, sessionID, clientTurnID string) (*Plan, error) {
tx, err := txFrom(scope)
if err != nil {
return nil, err
}
plan, err := scanPlan(tx.QueryRow(ctx, planSelect+`
WHERE environment=$1 AND session_id=$2 AND client_turn_id=$3`, env, sessionID, clientTurnID))
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
steps, err := loadSteps(ctx, tx, plan.PlanID)
if err != nil {
return nil, err
}
plan.Steps = steps
return plan, nil
}
func (Repository) GetActiveWrite(ctx context.Context, scope uow.TxScope, p Principal) (*Plan, error) {
return loadActive(ctx, scope, p, true)
}
func (Repository) GetActive(ctx context.Context, scope uow.TxScope, p Principal) (*Plan, error) {
return loadActive(ctx, scope, p, false)
}
func loadActive(ctx context.Context, scope uow.TxScope, p Principal, writeOnly bool) (*Plan, error) {
tx, err := txFrom(scope)
if err != nil {
return nil, err
}
q := planSelect + `
WHERE environment=$1 AND project_id=$2 AND session_id=$3 AND user_id=$4
AND status = ANY($5)`
args := []any{p.Environment, p.ProjectID, p.SessionID, p.UserID, activeStatusStrings()}
if writeOnly {
q += ` AND write_plan`
}
q += ` ORDER BY updated_at DESC LIMIT 1`
plan, err := scanPlan(tx.QueryRow(ctx, q, args...))
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
steps, err := loadSteps(ctx, tx, plan.PlanID)
if err != nil {
return nil, err
}
plan.Steps = steps
return plan, nil
}
func (Repository) CASPlan(ctx context.Context, scope uow.TxScope, planID string, expectedRev int64, from []PlanStatus, to PlanStatus, now time.Time) (int64, error) {
tx, err := txFrom(scope)
if err != nil {
return 0, err
}
var newRev int64
err = tx.QueryRow(ctx, `
UPDATE intent_plans
SET status=$1, revision=revision+1, updated_at=$2
WHERE plan_id=$3 AND revision=$4 AND status = ANY($5)
RETURNING revision`, string(to), now, planID, expectedRev, statusStrings(from)).Scan(&newRev)
if errors.Is(err, pgx.ErrNoRows) {
return 0, nil
}
return newRev, err
}
func (Repository) InsertDecision(ctx context.Context, scope uow.TxScope, d *Decision) error {
tx, err := txFrom(scope)
if err != nil {
return err
}
_, err = tx.Exec(ctx, `
INSERT INTO intent_plan_decisions (
decision_id, plan_id, nonce, plan_revision, actor_user_id, kind, step_ids,
exclude_unselected, decision_digest, outcome, created_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`,
d.DecisionID, d.PlanID, d.Nonce, d.PlanRevision, d.ActorUserID, string(d.Kind),
d.StepIDs, d.ExcludeUnselected, d.DecisionDigest, d.Outcome, d.CreatedAt,
)
if isUniqueViolation(err) {
return uow.ErrDigestConflict
}
return err
}
func (Repository) GetDecisionByNonce(ctx context.Context, scope uow.TxScope, planID, nonce string) (*Decision, error) {
tx, err := txFrom(scope)
if err != nil {
return nil, err
}
var d Decision
var kind string
err = tx.QueryRow(ctx, `
SELECT decision_id, plan_id, nonce, plan_revision, actor_user_id, kind, step_ids,
exclude_unselected, decision_digest, outcome, created_at
FROM intent_plan_decisions WHERE plan_id=$1 AND nonce=$2`, planID, nonce).Scan(
&d.DecisionID, &d.PlanID, &d.Nonce, &d.PlanRevision, &d.ActorUserID, &kind,
&d.StepIDs, &d.ExcludeUnselected, &d.DecisionDigest, &d.Outcome, &d.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
d.Kind = DecisionKind(kind)
return &d, nil
}
func (Repository) ReplaceSteps(ctx context.Context, scope uow.TxScope, planID string, steps []Step) error {
tx, err := txFrom(scope)
if err != nil {
return err
}
for _, s := range steps {
if s.PlanID == "" {
s.PlanID = planID
}
_, err := tx.Exec(ctx, `
UPDATE intent_plan_steps
SET status=$1, depends_on=$2, expected_resource_version=$3, action_digest=$4,
idempotency_key=$5, attempt=$6, result_ref=$7, result_digest=$8, safe_summary=$9,
lease_owner=$10, lease_until=$11, fencing_token=$12, version=$13, execution_mode=$14,
updated_at=$15, slots=$16
WHERE step_id=$17 AND plan_id=$18`,
string(s.Status), s.DependsOn, s.ExpectedResourceVersion, s.ActionDigest,
s.IdempotencyKey, s.Attempt, s.ResultRef, s.ResultDigest, s.SafeSummary,
s.LeaseOwner, s.LeaseUntil, s.FencingToken, s.Version, string(s.ExecutionMode),
s.UpdatedAt, jsonbOrNil(s.Slots), s.StepID, planID,
)
if err != nil {
return err
}
}
return nil
}
func (Repository) ClaimStep(ctx context.Context, scope uow.TxScope, planID, stepID, worker string, until, now time.Time, idempotencyKey string) (*Step, int64, error) {
tx, err := txFrom(scope)
if err != nil {
return nil, 0, err
}
var token int64
err = tx.QueryRow(ctx, `
UPDATE intent_plans
SET last_fencing_token = last_fencing_token + 1, updated_at = $2
WHERE plan_id = $1
AND EXISTS (
SELECT 1 FROM intent_plan_steps s
WHERE s.plan_id = $1 AND s.step_id = $3
AND (
s.status = 'eligible'
OR (s.status = 'running' AND (s.lease_until IS NULL OR s.lease_until < $2))
)
)
RETURNING last_fencing_token`, planID, now, stepID).Scan(&token)
if errors.Is(err, pgx.ErrNoRows) {
return nil, 0, nil
}
if err != nil {
return nil, 0, err
}
row := tx.QueryRow(ctx, `
UPDATE intent_plan_steps
SET status='running',
lease_owner=$1,
lease_until=$2,
attempt=attempt+1,
version=version+1,
fencing_token=$3,
idempotency_key=CASE WHEN idempotency_key='' THEN $4 ELSE idempotency_key END,
updated_at=$5
WHERE step_id=$6 AND plan_id=$7
AND (
status='eligible'
OR (status='running' AND (lease_until IS NULL OR lease_until < $5))
)
RETURNING `+stepColumns, worker, until, token, idempotencyKey, now, stepID, planID)
step, err := scanStep(row)
if errors.Is(err, pgx.ErrNoRows) {
return nil, 0, nil
}
return step, token, err
}
func (Repository) WriteStepReceipt(ctx context.Context, scope uow.TxScope, worker string, token int64, step Step, now time.Time) (bool, error) {
tx, err := txFrom(scope)
if err != nil {
return false, err
}
tag, err := tx.Exec(ctx, `
UPDATE intent_plan_steps
SET status='succeeded', result_ref=$1, result_digest=$2, safe_summary=$3,
lease_owner='', lease_until=NULL, version=version+1, updated_at=$4
WHERE step_id=$5 AND plan_id=$6 AND fencing_token=$7 AND lease_owner=$8 AND status='running'`,
step.ResultRef, step.ResultDigest, step.SafeSummary, now, step.StepID, step.PlanID, token, worker)
if err != nil {
return false, err
}
if tag.RowsAffected() == 1 {
return true, nil
}
var status, digest string
err = tx.QueryRow(ctx, `SELECT status, result_digest FROM intent_plan_steps WHERE step_id=$1 AND plan_id=$2`,
step.StepID, step.PlanID).Scan(&status, &digest)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
return status == string(StepSucceeded) && digest == step.ResultDigest, nil
}
func (Repository) ExpireWaiting(ctx context.Context, scope uow.TxScope, now time.Time) (int, error) {
tx, err := txFrom(scope)
if err != nil {
return 0, err
}
rows, err := tx.Query(ctx, `
UPDATE intent_plans
SET status='expired', revision=revision+1, updated_at=$1
WHERE status = ANY($2) AND expires_at <= $1
RETURNING plan_id`, now, waitingStatusStrings())
if err != nil {
return 0, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return 0, err
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return 0, err
}
if len(ids) == 0 {
return 0, nil
}
_, err = tx.Exec(ctx, `
UPDATE intent_plan_steps
SET status='cancelled', updated_at=$1
WHERE plan_id = ANY($2) AND status IN ('pending','eligible')`, now, ids)
return len(ids), err
}
func (Repository) PurgeClosed(ctx context.Context, scope uow.TxScope, before time.Time) (int, error) {
tx, err := txFrom(scope)
if err != nil {
return 0, err
}
tag, err := tx.Exec(ctx, `
DELETE FROM intent_plans
WHERE updated_at < $1
AND status IN ('completed','failed','cancelled','expired','superseded')`, before)
if err != nil {
return 0, err
}
return int(tag.RowsAffected()), nil
}
const planSelect = `
SELECT plan_id, environment, project_id, session_id, user_id, client_turn_id, request_digest,
segmenter_version, policy_version, status, revision, expires_at, last_fencing_token,
write_plan, execution_mode, safe_summary, display_snapshot, created_at, updated_at
FROM intent_plans`
const stepColumns = `
step_id, plan_id, ordinal, action_key, slots, depends_on, status, expected_resource_version,
action_digest, idempotency_key, attempt, result_ref, result_digest, safe_summary,
lease_owner, lease_until, fencing_token, version, execution_mode, created_at, updated_at`
func loadSteps(ctx context.Context, tx pgx.Tx, planID string) ([]Step, error) {
rows, err := tx.Query(ctx, `SELECT `+stepColumns+` FROM intent_plan_steps WHERE plan_id=$1 ORDER BY ordinal`, planID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Step
for rows.Next() {
s, err := scanStep(rows)
if err != nil {
return nil, err
}
out = append(out, *s)
}
return out, rows.Err()
}
type scannable interface {
Scan(dest ...any) error
}
func scanPlan(row scannable) (*Plan, error) {
var p Plan
var status, mode string
var snap []byte
err := row.Scan(
&p.PlanID, &p.Principal.Environment, &p.Principal.ProjectID, &p.Principal.SessionID,
&p.Principal.UserID, &p.ClientTurnID, &p.RequestDigest, &p.SegmenterVersion,
&p.PolicyVersion, &status, &p.Revision, &p.ExpiresAt, &p.LastFencingToken,
&p.WritePlan, &mode, &p.SafeSummary, &snap, &p.CreatedAt, &p.UpdatedAt,
)
if err != nil {
return nil, err
}
p.Status = PlanStatus(status)
p.ExecutionMode = ExecutionMode(mode)
p.DisplaySnapshot = snap
return &p, nil
}
func scanStep(row scannable) (*Step, error) {
var s Step
var status, mode string
err := row.Scan(
&s.StepID, &s.PlanID, &s.Ordinal, &s.ActionKey, &s.Slots, &s.DependsOn, &status,
&s.ExpectedResourceVersion, &s.ActionDigest, &s.IdempotencyKey, &s.Attempt,
&s.ResultRef, &s.ResultDigest, &s.SafeSummary, &s.LeaseOwner, &s.LeaseUntil,
&s.FencingToken, &s.Version, &mode, &s.CreatedAt, &s.UpdatedAt,
)
if err != nil {
return nil, err
}
s.Status = StepStatus(status)
s.ExecutionMode = ExecutionMode(mode)
return &s, nil
}
func jsonbOrNil(raw []byte) []byte {
if len(raw) == 0 {
return []byte("{}")
}
if !json.Valid(raw) {
return []byte("{}")
}
return raw
}
func statusStrings(in []PlanStatus) []string {
out := make([]string, len(in))
for i, s := range in {
out[i] = string(s)
}
return out
}
func activeStatusStrings() []string { return statusStrings(activeStatuses()) }
func waitingStatusStrings() []string { return statusStrings(waitingStatuses()) }
+55
View File
@@ -0,0 +1,55 @@
package intentflow
import (
"context"
"time"
"chat-agent-system/internal/app/uow"
)
// Store is the intent_plans* owner port. Callers pass the command TxScope.
type Store interface {
InsertPlan(ctx context.Context, scope uow.TxScope, plan *Plan) error
GetPlan(ctx context.Context, scope uow.TxScope, planID string) (*Plan, error)
GetPlanForUpdate(ctx context.Context, scope uow.TxScope, planID string) (*Plan, error)
GetByTurn(ctx context.Context, scope uow.TxScope, env, sessionID, clientTurnID string) (*Plan, error)
GetActiveWrite(ctx context.Context, scope uow.TxScope, p Principal) (*Plan, error)
GetActive(ctx context.Context, scope uow.TxScope, p Principal) (*Plan, error)
CASPlan(ctx context.Context, scope uow.TxScope, planID string, expectedRev int64, from []PlanStatus, to PlanStatus, now time.Time) (newRevision int64, err error)
InsertDecision(ctx context.Context, scope uow.TxScope, d *Decision) error
GetDecisionByNonce(ctx context.Context, scope uow.TxScope, planID, nonce string) (*Decision, error)
ReplaceSteps(ctx context.Context, scope uow.TxScope, planID string, steps []Step) error
ClaimStep(ctx context.Context, scope uow.TxScope, planID, stepID, worker string, until, now time.Time, idempotencyKey string) (*Step, int64, error)
WriteStepReceipt(ctx context.Context, scope uow.TxScope, worker string, token int64, step Step, now time.Time) (bool, error)
ExpireWaiting(ctx context.Context, scope uow.TxScope, now time.Time) (int, error)
PurgeClosed(ctx context.Context, scope uow.TxScope, before time.Time) (int, error)
}
func waitingStatuses() []PlanStatus {
return []PlanStatus{PlanProposed, PlanAwaitingClarification, PlanAwaitingConfirmation}
}
func activeStatuses() []PlanStatus {
return []PlanStatus{
PlanProposed, PlanAwaitingClarification, PlanAwaitingConfirmation,
PlanExecuting, PlanPartiallyCompleted,
}
}
func isActiveStatus(s PlanStatus) bool {
for _, x := range activeStatuses() {
if x == s {
return true
}
}
return false
}
func isWaitingStatus(s PlanStatus) bool {
for _, x := range waitingStatuses() {
if x == s {
return true
}
}
return false
}
+120
View File
@@ -119,3 +119,123 @@ const (
PathPlanByID = "/api/v1/intent-plans/{plan_id}"
PathPlanDecisions = "/api/v1/intent-plans/{plan_id}/decisions"
)
const SegmenterVersionUnknown = "u3-explicit"
// CreateCommand persists a plan without going through chat. U7 will map HandleTurn
// onto this command; U3 keeps HandleTurn a no-op so production routing stays legacy.
type CreateCommand struct {
Principal Principal
ClientTurnID string
RequestDigest string
SegmenterVersion string
PolicyVersion string
Status PlanStatus
ExecutionMode ExecutionMode
WritePlan bool
ExpiresAt time.Time
SafeSummary string
Steps []StepDraft
}
// StepDraft is one proposed step at create time.
type StepDraft struct {
ActionKey string
DependsOn []string
Slots []byte
ExpectedResourceVersion string
ActionDigest string
SafeSummary string
ExecutionMode ExecutionMode
}
// Plan is the owner-side aggregate. It is not returned over HTTP.
type Plan struct {
PlanID string
Principal Principal
ClientTurnID string
RequestDigest string
SegmenterVersion string
PolicyVersion string
Status PlanStatus
Revision int64
ExpiresAt time.Time
LastFencingToken int64
WritePlan bool
ExecutionMode ExecutionMode
SafeSummary string
DisplaySnapshot []byte
CreatedAt time.Time
UpdatedAt time.Time
Steps []Step
}
// Step is one row of the plan ledger.
type Step struct {
PlanID string
StepID string
Ordinal int
ActionKey string
Slots []byte
DependsOn []string
Status StepStatus
ExpectedResourceVersion string
ActionDigest string
IdempotencyKey string
Attempt int
ResultRef string
ResultDigest string
SafeSummary string
LeaseOwner string
LeaseUntil *time.Time
FencingToken int64
Version int64
ExecutionMode ExecutionMode
CreatedAt time.Time
UpdatedAt time.Time
}
// Decision is an append-only command receipt.
type Decision struct {
DecisionID string
PlanID string
Nonce string
PlanRevision int64
ActorUserID int64
Kind DecisionKind
StepIDs []string
ExcludeUnselected bool
DecisionDigest string
Outcome string
CreatedAt time.Time
}
// ClaimedStep is a worker lease plus the fence the action owner must see.
type ClaimedStep struct {
Plan Plan
Step Step
WorkerID string
FencingToken int64
IdempotencyKey string
}
// Receipt is the action-owner result. Intermediate payloads stay behind ResultRef.
type Receipt struct {
IdempotencyKey string
ActionDigest string
ResultRef string
ResultDigest string
SafeSummary string
}
// StepExec is the port payload for an action owner. Confirmation is not authorization.
type StepExec struct {
Principal Principal
PlanID string
StepID string
ActionKey string
IdempotencyKey string
ActionDigest string
ExpectedResourceVersion string
FencingToken int64
}
+36
View File
@@ -111,6 +111,41 @@ func TestGooseU9_00038_HasUpAndDown(t *testing.T) {
}
}
func TestGooseU3_00044_HasUpAndDown(t *testing.T) {
files, err := ListMigrationFiles(ResolveMigrationsDir())
if err != nil {
t.Fatal(err)
}
var found string
for _, name := range files {
if strings.HasPrefix(name, "00044_") && strings.HasSuffix(name, ".sql") {
found = name
break
}
}
if found == "" {
t.Fatal("expected goose 00044 for intent plans")
}
raw, err := os.ReadFile(filepath.Join(ResolveMigrationsDir(), found))
if err != nil {
t.Fatal(err)
}
text := string(raw)
if !strings.Contains(text, "-- +goose Up") || !strings.Contains(text, "-- +goose Down") {
t.Fatal("00044 must have Up and Down")
}
for _, table := range []string{"intent_plans", "intent_plan_steps", "intent_plan_decisions"} {
if !strings.Contains(text, table) {
t.Fatalf("00044 must mention %s", table)
}
}
for _, idx := range []string{"uq_intent_plans_turn", "uq_intent_plans_one_active_write", "uq_intent_plan_decisions_nonce"} {
if !strings.Contains(text, idx) {
t.Fatalf("00044 must mention %s", idx)
}
}
}
func TestGooseM2_00034to00036_DownThenUp(t *testing.T) {
if testing.Short() {
t.Skip("goose down is not part of -short")
@@ -132,6 +167,7 @@ func TestGooseM2_00034to00036_DownThenUp(t *testing.T) {
"00036_2026-09-14-final-artifact-vault.sql",
"00037_2026-09-15-task-run-manifests.sql",
"00038_2026-09-15-dataset-lifecycle.sql",
"00044_2026-09-15-intent-plans.sql",
}
joined := strings.Join(files, "\n")
for _, name := range want {