merge: fix/document-revision-pg-id-remap

This commit is contained in:
weichen
2026-09-13 23:48:18 +00:00
15 changed files with 626 additions and 91 deletions
@@ -0,0 +1,43 @@
---
title: "首次生成 PG ID 未同步计数器导致 baseline 被误删"
status: in_progress
created: 2026-09-13
updated: 2026-09-13
severity: critical
---
# 首次生成 PG ID 未同步计数器导致 baseline 被误删
## 现象
第19轮真实 UI(schema 31,项目 `6158` / `doc_proj6158_intake`):小队输出、baseline snapshot=249、version=217、**candidate id=175**、**output snapshot id=250**、**report id=175** 均已落库,candidate 保持 `ready`,任务失败 `docrevision: not found`(`CommitAgentVersion` 找不到 input snapshot)。不要把 output snapshot 250 误写成 candidate 250。
## 根因分析
`openTemplatePGLocked` 把 PostgreSQL `RETURNING id` 写入 `s.snapshots[249]` / `s.versions[217]`,但**不推进** `nextSnapID` / `nextVerID` / `nextEvtID`。
当进程内计数器已因其它文档 hydration 停在 248 时:
1. `putSnapshotLocked` 执行 `nextSnapID++` 得到临时 ID **249**,覆盖真实 baseline;
2. `persistAgentCandidateLocked` 把输出重映射到 250 后 `delete(s.snapshots, 249)`,误删 baseline;
3. `CommitAgentVersion` 按 `InputSnapshotID=249` 查找 → `ErrNotFound`;
4. 任务失败,candidate 仍 `ready`(不得伪装成功)。
同类无条件 `delete(map, oldID)` 也存在于 version / event / report / candidate 持久化路径。
## 严重度
`critical`
## 关联整改
- Remediation: [../remediation/2026-09-13-document-revision-pg-id-remap.md](../remediation/2026-09-13-document-revision-pg-id-remap.md)
## 证据
- 失败归档(只读,不改不删):`.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/schema31-round19-ui03-advisor-briefing-docrevision-not-found/`
- Playwright:`squad observation rejected: error_terminal:failed`
## 开放问题
- 本轮不重跑真实 UI-03;不得标 E2E 通过或任务 `done`
+6
View File
@@ -31,6 +31,12 @@
### 最新专项审计(2026-09-13)
**文件**: [2026-09-13-document-revision-pg-id-remap.md](./2026-09-13-document-revision-pg-id-remap.md)
**结论**: 项目 6158 首次生成 PG 已写入 baseline snapshot=249、candidate id=175、output snapshot id=250、report id=175,但内存计数器未同步,临时 ID 覆盖并删除 baseline,commit `not found`。
**整改**: [short-term](../ops/short-term/2026-09-13-document-revision-pg-id-remap.md)
**文件**: [2026-09-13-document-revision-post-restart-final-snippet.md](./2026-09-13-document-revision-post-restart-final-snippet.md)
**结论**: 第二次 post-restart 已恢复 V3/r2 与正文;失败只因 phase-state 仍断言 UI-06 marker,未写入 UI-13b A1 最终持久正文。
+6 -4
View File
@@ -1,12 +1,12 @@
# 当前焦点
**更新**: 2026-09-13
**production_ready**: no(DOCREV:第二次 post-restart 恢复链已见 V3/r2,bodySnippet 过期未重跑;不执行 env-service 重建 / 不声称 E2E 通过)
**production_ready**: no(DOCREV:6158 首次生成 PG 已落库但 commit not found;不执行 env-service 重建 / 不声称 E2E 通过)
## 焦点
1. **DOCREV-RT**: [post-restart 最终持久正文](short-term/2026-09-13-document-revision-post-restart-final-snippet.md)(`fix/document-revision-post-restart-final-snippet`;编码已提交,暂不合入;不声称 post-restart 通过)
2. **DOCREV-RT**: [post-restart 项目名 selector](short-term/2026-09-13-document-revision-post-restart-selector.md)(已合入 `dev@eb85b8a`)
1. **DOCREV-RT**: [文档账本 PG ID 重映射](short-term/2026-09-13-document-revision-pg-id-remap.md)(`fix/document-revision-pg-id-remap`;工程修复已提交,暂不合入;不声称 UI-03 通过)
2. **DOCREV-RT**: [post-restart 最终持久正文](short-term/2026-09-13-document-revision-post-restart-final-snippet.md)(已合入 `dev@fa6a033`)
3. **DOCREV-RT**: [质量修复子工作区契约](short-term/2026-09-13-document-revision-quality-repair-workspace-contract.md)(已合入 `dev@339e871`;仍 `in_progress`,待占槽重跑 UI-03)
## 并行认领
@@ -35,7 +35,8 @@
| Cursor-Auto | document-revision-duty-cell-assertion | fix/document-revision-duty-cell-assertion | - | document-revision-duty-cell-assertion | in_progress |
| Cursor-Auto | document-revision-reviewer-duty-sync | fix/document-revision-reviewer-duty-sync | - | document-revision-reviewer-duty-sync | done |
| Cursor-Auto | document-revision-post-restart-selector | fix/document-revision-post-restart-selector | - | document-revision-post-restart-selector | done |
| Cursor-Auto | document-revision-post-restart-final-snippet | fix/document-revision-post-restart-final-snippet | - | document-revision-post-restart-final-snippet | in_progress |
| Cursor-Auto | document-revision-post-restart-final-snippet | fix/document-revision-post-restart-final-snippet | - | document-revision-post-restart-final-snippet | done |
| Cursor-Auto | document-revision-pg-id-remap | fix/document-revision-pg-id-remap | - | document-revision | in_progress |
> 新任务开始前登记。`branch` 必须为 `feature/<slug>` 或 `fix/<slug>`,**禁止** `dev` / `test` / `main`。同一 `mutex` 同时仅一个 `in_progress`。
@@ -87,3 +88,4 @@
| UI-13a 私密分类导航失败审计 | [../audit/2026-09-13-document-revision-workflow-category-navigation.md](../audit/2026-09-13-document-revision-workflow-category-navigation.md) |
| UI-13a 职责单元格误判审计 | [../audit/2026-09-13-document-revision-duty-cell-assertion.md](../audit/2026-09-13-document-revision-duty-cell-assertion.md) |
| UI-13a reviewer 职责未同步审计 | [../audit/2026-09-13-document-revision-reviewer-duty-sync.md](../audit/2026-09-13-document-revision-reviewer-duty-sync.md) |
| UI-03 首次生成 PG ID 误删 baseline | [../audit/2026-09-13-document-revision-pg-id-remap.md](../audit/2026-09-13-document-revision-pg-id-remap.md) |
@@ -0,0 +1,23 @@
---
title: "PG ID 重映射收紧"
status: in_progress
created: 2026-09-13
updated: 2026-09-13
related_task: "2026-09-13-document-revision-pg-id-remap.md"
---
# PG ID 重映射收紧
## Wave 0
- [x] `adopt*ID` 推进 counter,仅删同一对象
- [x] 临时 ID 跳过已占用键(snapshot/version/event/report/candidate)
- [x] openTemplate / human / candidate / commit / noop event 全走 adopt
- [x] 纯函数 + 高序列首次生成 PG 回归
- [x] `gofmt` / `go vet` / `go test -short ./...`
- [ ] 真实 UI-03(本轮不做)
## 完成定义
- [x] 上述实现项有测试证据
- [ ] 现场 UI-03 仍开放,不得标 done
@@ -0,0 +1,59 @@
---
title: "PG ID 重映射:工程修复后交接"
created: 2026-09-13
from: Cursor-Auto
to: next-agent
status: open
---
# 交接:PG ID 重映射(暂不合入)
## 目标(下一位 Agent 应完成什么)
- 合入前占 CURRENT 集成槽位,再 `--no-ff` 合 `dev`;本轮禁止 merge / 重建容器 / 声称 E2E
- 真实 UI-03(项目 6158 同类首次生成)待占槽重跑
## 已完成
- 统一 `advanceID` / `nextFreeID` / `adopt*ID`
- 首次生成高序列碰撞:自动提交成功,V1 baseline 未丢,candidate committed,新 Service 可 hydration
## 未完成
- 真实 UI-03
- 合入 `dev`
## 认领 / 槽位状态
| 项 | 值 |
|----|-----|
| mutex 已释放 | no(`document-revision` 仍 `in_progress`) |
| 集成槽位 holder | 他人 e2e;本任务未占槽 |
| branch | `fix/document-revision-pg-id-remap` |
| worktree | - |
## 关键文件
| 路径 | 说明 |
|------|------|
| `internal/docrevision/ids.go` | counter / adopt |
| `internal/docrevision/pg_mutate.go` | PG 写入后 adopt |
| `internal/docrevision/pg_id_remap_test.go` | 隔离 PG 碰撞 |
## 如何验证
```bash
go test -short ./internal/docrevision ./...
go vet ./...
# 隔离库(可选):
TEST_DATABASE_URL='...' go test ./internal/docrevision -count=1 -run 'HighSequenceFirstGen|AdoptSnapshot|PutSnapshotLocked'
```
## 风险 / Blocker
- 不得把 `ErrNotFound` 改成忽略;失败任务必须保持失败
## 相关链接
- CURRENT: [../CURRENT.md](../CURRENT.md)
- Task: [../short-term/2026-09-13-document-revision-pg-id-remap.md](../short-term/2026-09-13-document-revision-pg-id-remap.md)
@@ -0,0 +1,56 @@
---
title: "文档账本 PG ID 与计数器重映射"
status: in_progress
created: 2026-09-13
updated: 2026-09-13
owner: Cursor-Auto
branch: fix/document-revision-pg-id-remap
worktree: "-"
mutex: document-revision
---
# 文档账本 PG ID 与计数器重映射
## 目标
堵住 schema31-round19:首次生成 PG 已写入 snapshot/version/candidate,但 `CommitAgentVersion` 因内存 map 误删 baseline 而 `not found`。
## 范围
- `internal/docrevision` ID 分配、adopt、PG persist
- 确定性单测 + 隔离 PG 回归
- audit / remediation / CURRENT / FEATURE-MATRIX / gap-matrix
## 非目标
- 不 merge、不启停/重建容器、不跑/声称真实 UI E2E、不 push
- 不删不改证据目录
- 不忽略 `ErrNotFound`,不伪造基线/报告
## 步骤
1. [x] 从 `dev@fa6a033` 开分支并登记 mutex
2. [x] 独立验证 openTemplate 不推进 counter + 无条件 delete 误删
3. [x] 统一 helper + 全路径 adopt
4. [x] `gofmt` / `go vet` / `go test -short` / 相关包测试
5. [x] 原子提交
## 验收标准
- [x] 占用 ID 不覆盖;adopt 只删同一对象
- [x] 高序列首次生成:自动提交、V2、candidate committed、baseline 未丢、重启 hydration
- [ ] 现场 UI-03(未跑)
## 相关链接
- Audit: [../../audit/2026-09-13-document-revision-pg-id-remap.md](../../audit/2026-09-13-document-revision-pg-id-remap.md)
- Remediation: [../../remediation/2026-09-13-document-revision-pg-id-remap.md](../../remediation/2026-09-13-document-revision-pg-id-remap.md)
- Checklist: [../checklists/2026-09-13-document-revision-pg-id-remap.md](../checklists/2026-09-13-document-revision-pg-id-remap.md)
- Handoff: [../handoffs/2026-09-13-document-revision-pg-id-remap.md](../handoffs/2026-09-13-document-revision-pg-id-remap.md)
## 证据 / 备注
- 基线:`dev@fa6a033`
- 验证:`gofmt`/`go vet` PASS;`go test -short ./...` PASS;隔离临时库 `HighSequenceFirstGen` PASS(拒绝 `agent_system_dev`,测完 DROP)
- 证据(只读):`.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/schema31-round19-ui03-advisor-briefing-docrevision-not-found/`
- **未跑真实 UI-03,不得标 done**;**不得 merge / 启停容器 / E2E / push**
@@ -0,0 +1,37 @@
---
title: "同步 PG 返回 ID 与进程内计数器"
status: in_progress
created: 2026-09-13
updated: 2026-09-13
---
# 同步 PG 返回 ID 与进程内计数器
## 背景
链到 [../audit/2026-09-13-document-revision-pg-id-remap.md](../audit/2026-09-13-document-revision-pg-id-remap.md)。
## 目标
- 所有 PG 写入后统一 `adopt*ID`:推进 counter,且仅当 map 旧键仍指向**同一对象**才删除
- 临时 ID 分配跳过已占用键,避免覆盖其它文档的真实对象
- `MaterializeAgentOutput` 在「baseline DB ID == 下一临时 ID」序列下自动提交成功;不得忽略 `ErrNotFound`、不得伪造基线/报告、失败任务不得显示成功
## 分波次计划
| 波次 | 内容 | 验收 |
|------|------|------|
| Wave 0 | 统一 ID helper + 首次生成/多文档/重启 hydration 回归 | `gofmt` / `go vet` / `go test -short`;隔离 PG 碰撞用例 |
| Wave 1 | 占槽重跑真实 UI-03(本轮不做) | 任务成功且 V2/head 可见 |
## 验收标准
- [x] 纯函数:占用 ID 跳过;adopt 不删其它对象
- [x] PG:高序列首次生成自动提交、V2/head、candidate committed、seed baseline 未丢、新 Service hydration
- [ ] 真实 UI-03(本轮不做)
## 相关链接
- Audit: [../audit/2026-09-13-document-revision-pg-id-remap.md](../audit/2026-09-13-document-revision-pg-id-remap.md)
- Short-term: [../ops/short-term/2026-09-13-document-revision-pg-id-remap.md](../ops/short-term/2026-09-13-document-revision-pg-id-remap.md)
- gap-matrix 行: DOCREV-PG-ID-REMAP
+1
View File
@@ -61,6 +61,7 @@
| CHAT-INTENT-STATE | 真实会话被诊断探针污染;只读查询误启动文档写任务;错误动作与编辑状态刷新后丢失 | **FAIL** | [audit](../audit/2026-09-13-conversation-intent-runtime-state.md);dev `sess-p143-u9` / task run / lease 现场 | [remediation](./2026-09-13-conversation-intent-runtime-state-remediation.md) |
| ENV-RESTART-SOURCE | `docker compose restart` 只重启旧镜像,健康检查无法证明合入代码已部署 | **PASS** | `env-service.sh rebuild` + 真实摘要重测 | 集成冒烟统一使用 `dev rebuild` |
| DOCREV-RUNTIME-404 | revision-state 404;账本内存丢失;块保存/overlay 无效;agent 假报告 | **FAIL** | [audit](../audit/2026-09-13-document-revision-runtime-404.md);`internal/docrevision/service.go` 仅 map | [remediation](./2026-09-13-document-revision-runtime-remediation.md) |
| DOCREV-PG-ID-REMAP | 首次生成 PG 已写 snapshot/candidate,commit 因内存误删 baseline 而 not found | **FIX-LANDED**(工程) | [audit](../audit/2026-09-13-document-revision-pg-id-remap.md);证据 `schema31-round19-ui03-advisor-briefing-docrevision-not-found`;`fix/document-revision-pg-id-remap` | 占槽真实 UI-03 重跑;不忽略 ErrNotFound;不删证据 |
| DOCREV-UI03-CLIENT-SYNC | 小队完成后右栏仍模板;UI-03 选择器误命中 Banner;`pending_author_confirmation` 撞 chk;重试重复建 run | **FIX-LANDED**(工程) | [audit](../audit/2026-09-13-controlled-doc-client-sync-ui03.md);分支 `fix/document-revision-runtime-ui03`:post-task sync、templateMatch intake 优先、goose 00030、Claim+idempotency | 合入前:schema30 **真实现卷**迁移独立验证;占槽真实 UI-03 重跑;勿把 stub 当验收 |
| DOCREV-SSE-IDEM-BUSY | SSE WriteTimeout 断线后同 turn 再 POST 误 409 DOC_BUSY + 重复聊天 | **FIX-LANDED**(工程) | [audit](../audit/2026-09-13-document-revision-sse-idempotency.md);证据 [assets/2026-09-13-document-revision-runtime-ui](../test-reports/assets/2026-09-13-document-revision-runtime-ui/);`fix/document-revision-sse-idempotency` | 占槽真实 UI-03 重跑;不删证据 |
| DOCREV-QUALITY-SELF-POLLUTE | 自检表引用禁词致 score=40 quality_failed;auto_repair 未启用;UI 映射 completed | **FIX-LANDED**(工程) | [audit](../audit/2026-09-13-document-revision-quality-gate-self-pollution.md);workspace `intake_1789308825666278807` output.md:204;同分支 | 占槽真实重跑;不删证据 |
+1 -1
View File
@@ -21,7 +21,7 @@
| 工作流治理 | 三正式工作流 + 阶段门禁 | done | [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) | Phase2 设计器 partial | `workflow` |
| IAM / 鉴权 | 项目级 API 鉴权 + RBAC | partial | [audit/authorization](../audit/authorization-matrix-v2.md);[刷新恢复专项审计](../audit/2026-09-13-chat-history-refresh-disappears.md) | 历史读取端点补统一会话 owner + 阶段授权;旧 API 410 标注(BUG-11 partial) | `authz` |
| 文档治理 | 法定文档生命周期 + 租约 | done | [test docgov](../test-reports/2026-09-08-docgov-lifecycle-and-claiming-framework-test-report.md) | — | `docgov` |
| 文档版本与修改认知 | 智能体版本边界、人工修订标记、任意版本比较、变更报告与负责人通知 | partial | [test 2026-09-13](../test-reports/2026-09-13-document-revision-management-test-report.md);[运行时审计](../audit/2026-09-13-document-revision-runtime-404.md);[当前文档路由](../audit/2026-09-13-document-revision-active-doc-routing.md);[行动表状态假阳性](../audit/2026-09-13-document-revision-quality-status-false-positive.md);[ADR](../adr/2026-09-13-document-version-boundary.md) | **运行时 blocker**:PG 未接线→404/重启丢失;overlay/freeform 保存缺口;PI 叙事报告待集成;见 [runtime remediation](../remediation/2026-09-13-document-revision-runtime-remediation.md)。行动表状态「待续」窄豁免已落地,待占槽重跑 UI-03 | `docgov` |
| 文档版本与修改认知 | 智能体版本边界、人工修订标记、任意版本比较、变更报告与负责人通知 | partial | [test 2026-09-13](../test-reports/2026-09-13-document-revision-management-test-report.md);[运行时审计](../audit/2026-09-13-document-revision-runtime-404.md);[PG ID 重映射](../audit/2026-09-13-document-revision-pg-id-remap.md);[ADR](../adr/2026-09-13-document-version-boundary.md) | **运行时 blocker**:首次生成 PG ID 未同步计数器会误删 baseline(工程修复在 `fix/document-revision-pg-id-remap`,未 UI 验收);overlay/freeform 保存缺口;见 [runtime remediation](../remediation/2026-09-13-document-revision-runtime-remediation.md) | `docgov` |
| 前端工作台 | 三栏 + 块文档 + SSE | partial | [test workbench](../test-reports/2026-09-08-three-column-workbench-and-block-document-test-report.md);[现场专项审计](../audit/2026-09-13-conversation-intent-runtime-state.md) | 错误卡片/任务进度/文档 busy 状态刷新恢复与人工抽检待完成 | `web` |
| PI Runner / 配置链 | pi-agent + MiniMax 真实链路 | done | [test piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md) | — | `pi-agent` |
| 三环境运维 | dev/test/main + 共享 KB | done | [PROJECT.md](../../PROJECT.md) §4;四环境 healthy | — | `infra` |
+103
View File
@@ -0,0 +1,103 @@
package docrevision
// advanceID 把进程内分配计数推进到不少于已落库 ID,避免下一临时 ID 撞上真实对象。
func advanceID(counter *int64, id int64) {
if counter == nil || id <= 0 {
return
}
if id > *counter {
*counter = id
}
}
// nextFreeID 分配一个当前 map 中未被占用的临时 ID。
func nextFreeID(counter *int64, occupied func(int64) bool) int64 {
if counter == nil {
return 0
}
for {
*counter++
id := *counter
if occupied == nil || !occupied(id) {
return id
}
}
}
func (s *Service) adoptSnapshotID(snap *Snapshot, dbID int64) {
if s == nil || snap == nil || dbID <= 0 {
return
}
oldID := snap.ID
if oldID != 0 && oldID != dbID {
if existing := s.snapshots[oldID]; existing == snap {
delete(s.snapshots, oldID)
}
}
snap.ID = dbID
s.snapshots[dbID] = snap
advanceID(&s.nextSnapID, dbID)
}
func (s *Service) adoptVersionID(ver *Version, dbID int64) {
if s == nil || ver == nil || dbID <= 0 {
return
}
oldID := ver.ID
if oldID != 0 && oldID != dbID {
if existing := s.versions[oldID]; existing == ver {
delete(s.versions, oldID)
}
}
ver.ID = dbID
s.versions[dbID] = ver
advanceID(&s.nextVerID, dbID)
}
func (s *Service) adoptEventID(ev *ChangeEvent, dbID int64) {
if s == nil || ev == nil || dbID <= 0 {
return
}
oldID := ev.ID
if oldID != 0 && oldID != dbID {
if existing := s.events[oldID]; existing == ev {
delete(s.events, oldID)
}
}
ev.ID = dbID
s.events[dbID] = ev
if ev.IdempotencyKey != "" {
s.idemp[ev.IdempotencyKey] = dbID
}
advanceID(&s.nextEvtID, dbID)
}
func (s *Service) adoptReportID(rep *ChangeReport, dbID int64) {
if s == nil || rep == nil || dbID <= 0 {
return
}
oldID := rep.ID
if oldID != 0 && oldID != dbID {
if existing := s.reports[oldID]; existing == rep {
delete(s.reports, oldID)
}
}
rep.ID = dbID
s.reports[dbID] = rep
advanceID(&s.nextRepID, dbID)
}
func (s *Service) adoptCandidateID(c *AgentCandidate, dbID int64) {
if s == nil || c == nil || dbID <= 0 {
return
}
oldID := c.ID
if oldID != 0 && oldID != dbID {
if existing := s.candidates[oldID]; existing == c {
delete(s.candidates, oldID)
}
}
c.ID = dbID
s.candidates[dbID] = c
advanceID(&s.nextCandID, dbID)
}
+70
View File
@@ -0,0 +1,70 @@
package docrevision
import (
"testing"
"time"
)
func TestNextFreeID_SkipsOccupied(t *testing.T) {
var counter int64 = 248
occupied := map[int64]bool{249: true}
got := nextFreeID(&counter, func(id int64) bool { return occupied[id] })
if got != 250 {
t.Fatalf("next free id=%d want 250", got)
}
}
func TestAdoptSnapshotID_DoesNotDeleteOtherObject(t *testing.T) {
s := NewService(nil)
baseline := &Snapshot{ID: 249, DocID: "doc_a", ContentSHA256: "aa", CreatedAt: time.Now().UTC()}
temp := &Snapshot{ID: 249, DocID: "doc_b", ContentSHA256: "bb", CreatedAt: time.Now().UTC()}
s.snapshots[249] = baseline
s.nextSnapID = 248
s.adoptSnapshotID(temp, 250)
if s.snapshots[249] != baseline {
t.Fatal("adopting a different object must not delete the real baseline")
}
if s.snapshots[250] != temp || temp.ID != 250 {
t.Fatalf("temp must live at 250, id=%d", temp.ID)
}
if s.nextSnapID != 250 {
t.Fatalf("counter=%d want 250", s.nextSnapID)
}
}
func TestAdoptSnapshotID_RemovesOnlySameObject(t *testing.T) {
s := NewService(nil)
snap := &Snapshot{ID: 1, DocID: "doc_a", ContentSHA256: "aa"}
s.snapshots[1] = snap
s.adoptSnapshotID(snap, 249)
if _, ok := s.snapshots[1]; ok {
t.Fatal("same-object remap must drop the old temp key")
}
if s.snapshots[249] != snap || snap.ID != 249 {
t.Fatal("same object must occupy the database id")
}
if s.nextSnapID < 249 {
t.Fatalf("counter must advance to db id, got %d", s.nextSnapID)
}
}
func TestPutSnapshotLocked_DoesNotClobberExistingBaseline(t *testing.T) {
s := NewService(nil)
s.mu.Lock()
defer s.mu.Unlock()
baseline := &Snapshot{ID: 249, DocID: "doc_seed", ContentSHA256: "base"}
s.snapshots[249] = baseline
s.nextSnapID = 248
s.blobs["out"] = &ContentBlob{ContentSHA256: "out", ContentMarkdown: "# out\n"}
got := s.putSnapshotLocked("doc_new", "# out\n", "out", nil, SourceAgent, nil, "run", ActorSnapshot{}, "run", nil)
if got.ID == 249 {
t.Fatal("temporary snapshot must not reuse the occupied baseline id")
}
if s.snapshots[249] != baseline {
t.Fatal("baseline snapshot must remain")
}
if s.snapshots[got.ID] != got {
t.Fatal("new snapshot must be stored under its own id")
}
}
+198
View File
@@ -0,0 +1,198 @@
package docrevision_test
import (
"context"
"fmt"
"testing"
"time"
"chat-agent-system/internal/db"
"chat-agent-system/internal/docrevision"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestMaterializeAgentOutput_HighSequenceFirstGenKeepsBaseline_PG(t *testing.T) {
if testing.Short() {
t.Skip("skipping PostgreSQL id-remap integration in -short")
}
dsn := db.SkipIfNoTestDatabaseURL(t)
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Skipf("postgres unavailable: %v", err)
}
defer pool.Close()
if err := pool.Ping(ctx); err != nil {
t.Skipf("postgres ping failed: %v", err)
}
if err := db.RunMigrationsOnceForTest(ctx, pool, dsn); err != nil {
t.Fatalf("migration failed: %v", err)
}
var n int
if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM information_schema.tables WHERE table_name='document_snapshots'`).Scan(&n); err != nil || n == 0 {
t.Skip("document_snapshots table missing")
}
suffix := time.Now().UnixNano()
seedDoc := fmt.Sprintf("doc_idremap_seed_%d", suffix)
genDoc := fmt.Sprintf("doc_idremap_gen_%d", suffix)
t.Cleanup(func() {
cleanupRevisionDocs(context.Background(), pool, seedDoc, genDoc)
})
cleanupRevisionDocs(ctx, pool, seedDoc, genDoc)
seedSnap := nextExplicitID(t, ctx, pool, "document_snapshots")
seedVer := nextExplicitID(t, ctx, pool, "document_versions")
seedEvt := nextExplicitID(t, ctx, pool, "document_change_events")
seedHighIDs(t, ctx, pool, seedDoc, seedSnap, seedVer, seedEvt)
svc := docrevision.NewService(pool)
seedState, err := svc.GetRevisionState(ctx, seedDoc)
if err != nil {
t.Fatalf("hydrate seed: %v", err)
}
if seedState.BaselineSnapshotID != seedSnap {
t.Fatalf("seed baseline=%d want %d", seedState.BaselineSnapshotID, seedSnap)
}
outMD := "# 首轮交付\n\n## 范围\n\nPG 序列碰撞回归正文\n"
res, err := svc.MaterializeAgentOutput(ctx, docrevision.AgentMaterializeRequest{
DocID: genDoc, RunID: fmt.Sprintf("run-idremap-%d", suffix), OutputMarkdown: outMD,
ProjectID: 1, WorkDir: "ws/idremap",
ActorKind: docrevision.ActorAgentSquad,
ActorSnapshot: docrevision.ActorSnapshot{SquadName: "bizdev-squad", Model: "test"},
AutoCommitUserID: 42,
})
if err != nil {
t.Fatalf("materialize first gen: %v", err)
}
if res.State == nil || res.State.CurrentVersionNo != 2 {
t.Fatalf("want V2 after auto-commit, got %+v", res.State)
}
if res.Candidate == nil || res.Candidate.Status != docrevision.CandidateCommitted {
t.Fatalf("candidate must be committed, got %+v", res.Candidate)
}
if res.State.BaselineSnapshotID == 0 {
t.Fatal("baseline snapshot id must remain")
}
versions, err := svc.ListVersions(ctx, genDoc)
if err != nil {
t.Fatal(err)
}
var v1, v2 *docrevision.Version
for i := range versions {
switch versions[i].VersionNo {
case 1:
v1 = &versions[i]
case 2:
v2 = &versions[i]
}
}
if v1 == nil || v2 == nil {
t.Fatalf("want V1+V2, got %+v", versions)
}
if v1.BaselineSnapshotID != seedSnap+1 {
t.Fatalf("first-gen template snapshot want %d (next after seed %d), got %d", seedSnap+1, seedSnap, v1.BaselineSnapshotID)
}
if v2.BaselineSnapshotID == v1.BaselineSnapshotID || v2.HeadSnapshotID == v1.BaselineSnapshotID {
t.Fatalf("V2 must not reuse/drop V1 baseline: v1=%+v v2=%+v", v1, v2)
}
if res.Candidate.InputSnapshotID != v1.BaselineSnapshotID {
t.Fatalf("candidate input=%d want V1 baseline %d", res.Candidate.InputSnapshotID, v1.BaselineSnapshotID)
}
seedAfter, err := svc.GetRevisionState(ctx, seedDoc)
if err != nil {
t.Fatal(err)
}
if seedAfter.BaselineSnapshotID != seedSnap || seedAfter.HeadSnapshotID != seedSnap {
t.Fatalf("seed doc must keep snapshot %d, got %+v", seedSnap, seedAfter)
}
restart := docrevision.NewService(pool)
st2, err := restart.GetRevisionState(ctx, genDoc)
if err != nil {
t.Fatalf("hydrate gen after new Service: %v", err)
}
if st2.CurrentVersionNo != 2 || st2.HeadSnapshotID == 0 {
t.Fatalf("restart hydrate want V2/head, got %+v", st2)
}
cand, err := restart.GetCandidate(ctx, genDoc, res.Candidate.ID)
if err != nil || cand == nil || cand.Status != docrevision.CandidateCommitted {
t.Fatalf("restart candidate: %+v err=%v", cand, err)
}
if st2.BaselineSnapshotID == 0 {
t.Fatal("restart must keep baseline")
}
seedRestart, err := restart.GetRevisionState(ctx, seedDoc)
if err != nil {
t.Fatal(err)
}
if seedRestart.BaselineSnapshotID != seedSnap {
t.Fatalf("restart seed baseline=%d", seedRestart.BaselineSnapshotID)
}
}
func nextExplicitID(t *testing.T, ctx context.Context, pool *pgxpool.Pool, table string) int64 {
t.Helper()
var maxID int64
q := fmt.Sprintf(`SELECT COALESCE(MAX(id), 0) FROM %s`, table)
if err := pool.QueryRow(ctx, q).Scan(&maxID); err != nil {
t.Fatalf("max id %s: %v", table, err)
}
return maxID + 50
}
func seedHighIDs(t *testing.T, ctx context.Context, pool *pgxpool.Pool, docID string, snapID, verID, evtID int64) {
t.Helper()
sha := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
if _, err := pool.Exec(ctx, `
INSERT INTO document_content_blobs (content_sha256, content_markdown, byte_size)
VALUES ($1, $2, 8)
ON CONFLICT (content_sha256) DO NOTHING
`, sha, "# seed\n"); err != nil {
t.Fatalf("seed blob: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO document_snapshots (id, doc_id, content_sha256, source_kind)
VALUES ($1, $2, $3, 'template')
`, snapID, docID, sha); err != nil {
t.Fatalf("seed snapshot: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO document_versions (id, doc_id, version_no, status, origin_kind, baseline_snapshot_id, head_snapshot_id)
VALUES ($1, $2, 1, 'open', 'template', $3, $3)
`, verID, docID, snapID); err != nil {
t.Fatalf("seed version: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO document_change_events (
id, doc_id, version_id, event_no, to_snapshot_id, change_kind, actor_kind, idempotency_key
) VALUES ($1, $2, $3, 1, $4, 'import', 'template', $5)
`, evtID, docID, verID, snapID, "seed:"+docID); err != nil {
t.Fatalf("seed event: %v", err)
}
if _, err := pool.Exec(ctx, `SELECT setval('document_snapshots_id_seq', $1, true)`, snapID); err != nil {
t.Fatalf("setval snapshots: %v", err)
}
if _, err := pool.Exec(ctx, `SELECT setval('document_versions_id_seq', $1, true)`, verID); err != nil {
t.Fatalf("setval versions: %v", err)
}
if _, err := pool.Exec(ctx, `SELECT setval('document_change_events_id_seq', $1, true)`, evtID); err != nil {
t.Fatalf("setval events: %v", err)
}
}
func cleanupRevisionDocs(ctx context.Context, pool *pgxpool.Pool, docIDs ...string) {
for _, docID := range docIDs {
_, _ = pool.Exec(ctx, `DELETE FROM document_agent_candidates WHERE doc_id=$1`, docID)
_, _ = pool.Exec(ctx, `UPDATE document_versions SET change_report_id=NULL WHERE doc_id=$1`, docID)
_, _ = pool.Exec(ctx, `DELETE FROM document_change_reports WHERE doc_id=$1`, docID)
_, _ = pool.Exec(ctx, `DELETE FROM document_change_events WHERE doc_id=$1`, docID)
_, _ = pool.Exec(ctx, `DELETE FROM document_versions WHERE doc_id=$1`, docID)
_, _ = pool.Exec(ctx, `DELETE FROM document_snapshots WHERE doc_id=$1`, docID)
}
}
+14 -61
View File
@@ -26,8 +26,7 @@ func (s *Service) openTemplatePGLocked(ctx context.Context, docID, canon, sha st
if err != nil {
return err
}
sn.ID = id
s.snapshots[id] = sn
s.adoptSnapshotID(sn, id)
ver := &Version{
DocID: docID, VersionNo: 1, Status: VersionOpen, OriginKind: OriginTemplate,
@@ -37,8 +36,7 @@ func (s *Service) openTemplatePGLocked(ctx context.Context, docID, canon, sha st
if err != nil {
return err
}
ver.ID = vid
s.versions[vid] = ver
s.adoptVersionID(ver, vid)
ev := &ChangeEvent{
DocID: docID, VersionID: vid, EventNo: 1, ToSnapshotID: id,
@@ -50,9 +48,7 @@ func (s *Service) openTemplatePGLocked(ctx context.Context, docID, canon, sha st
if err != nil {
return err
}
ev.ID = eid
s.events[eid] = ev
s.idemp[ev.IdempotencyKey] = eid
s.adoptEventID(ev, eid)
root := &DocRootPointers{
DocID: docID, Kind: "controlled", ProjectID: projectID, WorkDir: workDir,
@@ -84,30 +80,20 @@ func (s *Service) persistHumanRevisionLocked(ctx context.Context, root *DocRootP
if err := s.ensureBlobTX(ctx, tx, blob); err != nil {
return err
}
oldSnapID := snap.ID
id, err := s.insertSnapshotTX(ctx, tx, snap)
if err != nil {
return err
}
if oldSnapID != 0 && oldSnapID != id {
delete(s.snapshots, oldSnapID)
}
snap.ID = id
s.snapshots[id] = snap
s.adoptSnapshotID(snap, id)
if isBaseline {
ver.BaselineSnapshotID = id
ver.HeadSnapshotID = id
oldVerID := ver.ID
vid, err := s.insertVersionTX(ctx, tx, ver)
if err != nil {
return err
}
if oldVerID != 0 && oldVerID != vid {
delete(s.versions, oldVerID)
}
ver.ID = vid
s.versions[vid] = ver
s.adoptVersionID(ver, vid)
root.LedgerVersionID = &vid
root.HeadSnapshotID = &id
ev.VersionID = vid
@@ -122,19 +108,11 @@ func (s *Service) persistHumanRevisionLocked(ctx context.Context, root *DocRootP
ev.VersionID = ver.ID
}
oldEv := ev.ID
eid, err := s.insertEventTX(ctx, tx, ev)
if err != nil {
return err
}
if oldEv != 0 && oldEv != eid {
delete(s.events, oldEv)
}
ev.ID = eid
s.events[eid] = ev
if ev.IdempotencyKey != "" {
s.idemp[ev.IdempotencyKey] = eid
}
s.adoptEventID(ev, eid)
return s.projectRootPointersTX(ctx, tx, root)
})
}
@@ -151,41 +129,26 @@ func (s *Service) persistAgentCandidateLocked(ctx context.Context, outSnap *Snap
if err := s.ensureBlobTX(ctx, tx, blob); err != nil {
return err
}
oldSnap := outSnap.ID
sid, err := s.insertSnapshotTX(ctx, tx, outSnap)
if err != nil {
return err
}
if oldSnap != 0 && oldSnap != sid {
delete(s.snapshots, oldSnap)
}
outSnap.ID = sid
s.snapshots[sid] = outSnap
c.OutputSnapshotID = &sid
s.adoptSnapshotID(outSnap, sid)
c.OutputSnapshotID = &outSnap.ID
rep.ToSnapshotID = sid
oldRep := rep.ID
rid, err := s.insertReportTX(ctx, tx, rep)
if err != nil {
return err
}
if oldRep != 0 && oldRep != rid {
delete(s.reports, oldRep)
}
rep.ID = rid
s.reports[rid] = rep
c.ChangeReportID = &rid
s.adoptReportID(rep, rid)
c.ChangeReportID = &rep.ID
oldC := c.ID
cid, err := s.insertCandidateTX(ctx, tx, c)
if err != nil {
return err
}
if oldC != 0 && oldC != cid {
delete(s.candidates, oldC)
}
c.ID = cid
s.candidates[cid] = c
s.adoptCandidateID(c, cid)
return nil
})
}
@@ -201,32 +164,22 @@ func (s *Service) persistAgentCommitLocked(ctx context.Context, root *DocRootPoi
if err := s.updateVersionTX(ctx, tx, oldVer); err != nil {
return err
}
oldVerID := newVer.ID
vid, err := s.insertVersionTX(ctx, tx, newVer)
if err != nil {
return err
}
if oldVerID != 0 && oldVerID != vid {
delete(s.versions, oldVerID)
}
newVer.ID = vid
s.versions[vid] = newVer
root.LedgerVersionID = &vid
s.adoptVersionID(newVer, vid)
root.LedgerVersionID = &newVer.ID
hid := outSnap.ID
root.HeadSnapshotID = &hid
ev.VersionID = vid
ev.ToSnapshotID = outSnap.ID
oldEv := ev.ID
eid, err := s.insertEventTX(ctx, tx, ev)
if err != nil {
return err
}
if oldEv != 0 {
delete(s.events, oldEv)
}
ev.ID = eid
s.events[eid] = ev
s.adoptEventID(ev, eid)
if err := s.updateCandidateTX(ctx, tx, c); err != nil {
return err
-12
View File
@@ -457,15 +457,3 @@ func (s *Service) withTx(ctx context.Context, fn func(pgx.Tx) error) error {
// Pool exposes the underlying pool for integration tests.
func (s *Service) Pool() *pgxpool.Pool { return s.pool }
// remapSnapshotID updates parent pointers after a snapshot receives its real PG id.
func (s *Service) remapSnapshotID(oldID, newID int64) {
if oldID == 0 || oldID == newID {
return
}
if sn := s.snapshots[oldID]; sn != nil {
delete(s.snapshots, oldID)
sn.ID = newID
s.snapshots[newID] = sn
}
}
+9 -13
View File
@@ -499,17 +499,15 @@ func (s *Service) CreateAgentCandidate(ctx context.Context, req AgentCandidateRe
if *root.HeadSnapshotID != req.InputSnapshotID {
status = CandidateStale
}
s.nextRepID++
rep.ID = s.nextRepID
rep.ID = nextFreeID(&s.nextRepID, func(id int64) bool { return s.reports[id] != nil })
rep.DocID = req.DocID
rep.FromSnapshotID = req.InputSnapshotID
rep.ToSnapshotID = outSnap.ID
rep.CreatedAt = time.Now().UTC()
s.reports[rep.ID] = rep
s.nextCandID++
c := &AgentCandidate{
ID: s.nextCandID, DocID: req.DocID, InputSnapshotID: req.InputSnapshotID,
ID: nextFreeID(&s.nextCandID, func(id int64) bool { return s.candidates[id] != nil }), DocID: req.DocID, InputSnapshotID: req.InputSnapshotID,
OutputSnapshotID: &outSnap.ID, RunID: req.RunID, ProposalID: req.ProposalID,
Status: status, AuthorizedScope: req.AuthorizedScope, ChangeReportID: &rep.ID,
ActorKind: req.ActorKind, ActorSnapshot: req.ActorSnapshot, CreatedAt: time.Now().UTC(),
@@ -621,9 +619,11 @@ func (s *Service) CommitAgentVersion(ctx context.Context, docID string, candidat
c.DecidedAt = &now
if s.usingPG() {
if err := s.withTx(ctx, func(tx pgx.Tx) error {
if _, err := s.insertEventTX(ctx, tx, ev); err != nil {
eid, err := s.insertEventTX(ctx, tx, ev)
if err != nil {
return err
}
s.adoptEventID(ev, eid)
return s.updateCandidateTX(ctx, tx, c)
}); err != nil {
s.forgetDocLocked(docID)
@@ -860,8 +860,7 @@ func (s *Service) ImportFreeformLegacy(ctx context.Context, docID string, ownerI
ID: 0, DocID: docID, VersionNo: startNo, Status: VersionOpen, OriginKind: OriginMigration,
BaselineSnapshotID: snap.ID, HeadSnapshotID: snap.ID, OpenedAt: time.Now().UTC(),
}
s.nextVerID++
ver.ID = s.nextVerID
ver.ID = nextFreeID(&s.nextVerID, func(id int64) bool { return s.versions[id] != nil })
s.versions[ver.ID] = ver
root := &DocRootPointers{
DocID: docID, Kind: "freeform", ProjectID: projectID,
@@ -939,9 +938,8 @@ func (s *Service) putSnapshotLocked(docID, canon, sha string, parent *int64, sou
} else {
blocks = BuildBlockMap(canon, clientBlocks)
}
s.nextSnapID++
snap := &Snapshot{
ID: s.nextSnapID, DocID: docID, ContentSHA256: sha, ParentSnapshotID: parent,
ID: nextFreeID(&s.nextSnapID, func(id int64) bool { return s.snapshots[id] != nil }), DocID: docID, ContentSHA256: sha, ParentSnapshotID: parent,
BlockMap: blocks, SourceKind: source, ActorUserID: userID, ActorRef: actorRef,
ActorSnapshot: actor, SourceRunID: runID, CreatedAt: time.Now().UTC(),
}
@@ -961,9 +959,8 @@ func (s *Service) openVersionLocked(docID, origin string, baselineID, headID int
v.SealedAt = &now
}
}
s.nextVerID++
ver := &Version{
ID: s.nextVerID, DocID: docID, VersionNo: maxNo + 1, Status: VersionOpen,
ID: nextFreeID(&s.nextVerID, func(id int64) bool { return s.versions[id] != nil }), DocID: docID, VersionNo: maxNo + 1, Status: VersionOpen,
OriginKind: origin, BaselineSnapshotID: baselineID, HeadSnapshotID: headID,
SourceRunID: runID, OpenedAt: time.Now().UTC(),
}
@@ -972,9 +969,8 @@ func (s *Service) openVersionLocked(docID, origin string, baselineID, headID int
}
func (s *Service) putEventLocked(docID string, versionID int64, eventNo int, from *int64, to int64, kind, actorKind string, userID *int64, actorRef string, actor ActorSnapshot, hunks []DiffHunk, editSession, requestID, idemp string) *ChangeEvent {
s.nextEvtID++
ev := &ChangeEvent{
ID: s.nextEvtID, DocID: docID, VersionID: versionID, EventNo: eventNo,
ID: nextFreeID(&s.nextEvtID, func(id int64) bool { return s.events[id] != nil }), DocID: docID, VersionID: versionID, EventNo: eventNo,
FromSnapshotID: from, ToSnapshotID: to, ChangeKind: kind, ActorKind: actorKind,
ActorUserID: userID, ActorRef: actorRef, ActorSnapshot: actor, ChangeSet: hunks,
EditSessionID: editSession, RequestID: requestID, IdempotencyKey: idemp, CreatedAt: time.Now().UTC(),