feat(docrevision): keep candidate estimates separate from committed reports
A candidate used the same report later attached to the version, so a preview could be treated as the formal result. Proposed reports now close on replace or reject, and only the commit writes an authoritative successor.
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
|
||||
| owner | task | branch | worktree | mutex | status |
|
||||
|-------|------|--------|----------|-------|--------|
|
||||
| Cursor-Auto | 2026-09-20-proposed-authoritative-reports | feature/capability-enablement-impact-plan | - | docgov | done |
|
||||
| Cursor-Auto | 2026-09-20-pi-narrative-clipped-diff | feature/capability-enablement-impact-plan | - | docgov | done |
|
||||
| Cursor-Auto | 2026-09-20-pi-reasoning-profile | feature/capability-enablement-impact-plan | - | pi-agent | done |
|
||||
| Cursor-Auto | 2026-09-20-semantic-summary-persistence | feature/capability-enablement-impact-plan | - | chat+lineage-version | done |
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
title: "候选变更报告与正式提交报告分开"
|
||||
status: done
|
||||
created: 2026-09-20
|
||||
updated: 2026-09-20
|
||||
owner: Cursor-Auto
|
||||
branch: feature/capability-enablement-impact-plan
|
||||
worktree: "-"
|
||||
mutex: docgov
|
||||
---
|
||||
|
||||
# 候选变更报告与正式提交报告分开
|
||||
|
||||
## 目标
|
||||
|
||||
Agent 候选只持久化 `proposed` 预估报告,不发下游影响或正式提交事件。正式提交才生成 authoritative 后继报告,并让版本指向后继而不是预估。
|
||||
|
||||
## 范围
|
||||
|
||||
- 报告增加 `authority`、`lifecycle`、`successor_report_id`,并写入已有 `deterministic_facts`,避免在未占集成槽时改表。
|
||||
- 新的有效候选把上一份仍 active 的预估标为 superseded。
|
||||
- 拒绝标 rejected;提交时基线已变则标 expired。这两种以及 superseded、已提交的预估都不能再出建议。
|
||||
- 正式提交复制一份 authoritative 报告;下游影响通知使用这份报告。`document.agent_version_committed` 仍只由 docflow 的 EventSink 在提交命令里发出。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不把人工修订、恢复、导入改成先预估再提交。
|
||||
- 不打开 `PI_CHANGE_REPORT_NARRATIVE`,不实现 trace_reviewer,不做 T2 物理删除。
|
||||
- 不新增 `document.committed` 事件名,避免和现有投影消费者分叉。
|
||||
- 不重启容器,不占集成槽位。
|
||||
|
||||
## 步骤
|
||||
|
||||
1. [x] 候选写入 proposed,提交写入 authoritative 后继
|
||||
2. [x] 单测覆盖替代、拒绝、版本不指向预估、候选阶段不发下游事件
|
||||
3. [x] 同步 FEATURE-MATRIX 与 CURRENT
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [x] `go test ./internal/docrevision/` 通过
|
||||
- [x] 预估报告在替代或拒绝后 `AllowsSuggestedActions` 为 false
|
||||
|
||||
## 相关链接
|
||||
|
||||
- Checklist:
|
||||
- Handoff:
|
||||
- Test report: 本切片为账本单测,未做真实聊天或浏览器验收
|
||||
|
||||
## 证据 / 备注
|
||||
|
||||
- `internal/docrevision/report_disposition.go`
|
||||
- `TestProposedReportStaysInactiveUntilCommit`
|
||||
- `TestRejectedProposedReportCannotSuggest`
|
||||
@@ -61,7 +61,7 @@
|
||||
| 纯软件项目自举 | 软件项目创建、Bootstrap、Delivery、受控工程交接 | partial | deferred_enhancement(明确不挡首发) | [设计](../system-design/2026-09-14-software-project-bootstrap-workflow.md);[ADR](../adr/2026-09-16-software-project-bootstrap-keys.md);[L1 报告](../test-reports/2026-09-16-software-project-bootstrap-test-report.md);[W5a](../test-reports/2026-09-17-w5a-software-bootstrap-ui-pi-test-report.md) | **before**:L1 + UI 仍克隆医疗模板。**after W5a**:catalog 驱动创建、软件不挂医疗 key、docs_only 真 PI 建议 delivery 且不自动激活。**SWBOOT-10 仍 stub**(W5b);本行不升 done | `workflow-design` |
|
||||
| IAM / 鉴权 | 项目级 API 鉴权 + RBAC;会话/阶段授权 | done | 加固 | [v3 full inventory](../audit/authorization-matrix-v3-full.md);[session 中断](../test-reports/2026-09-16-iam-route-inventory-test-report.md);[fail-closed](../test-reports/2026-09-16-wave1-iam-fail-closed-test-report.md);[410 策略](../audit/2026-09-16-bug11-410-retire-decision.md);[R13](../audit/2026-09-16-r13-data-safety-checklist.md) | 全路由 inventory + session 中断负例 + 410 冻结 + R13 基线已签 | `authz` |
|
||||
| 文档治理 | 法定文档生命周期 + 租约 + 受控正文读取 | done | non_blocker | [docgov](../test-reports/2026-09-08-docgov-lifecycle-and-claiming-framework-test-report.md);[正文 UI](../test-reports/2026-09-14-controlled-content-ui-acceptance.md);[canonical hash](../remediation/2026-09-14-controlled-content-canonical-hash-remediation.md) | 项目 143 只读;不终止 | `docgov` |
|
||||
| 文档版本 baseline | 账本、人工修订、确定性 diff+lineage、运行时 UI、负责人通知 | done | non_blocker | [管理](../test-reports/2026-09-13-document-revision-management-test-report.md);[Round20 UI](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[ADR](../adr/2026-09-13-document-version-boundary.md) | **before**:完整 PI narrative 混入 → **after**:迁「PI change-report 叙事」。fallback 报告是本行内显式行为,不是缺口。lineage GET 已按 URL 项目和文档可见性裁剪。test_manager 血缘的下游/上游版本改读文档版本,不再写死 1。项目追踪索引和影响报告仍见整改方案 Track T | `docgov` |
|
||||
| 文档版本 baseline | 账本、人工修订、确定性 diff+lineage、运行时 UI、负责人通知 | done | non_blocker | [管理](../test-reports/2026-09-13-document-revision-management-test-report.md);[Round20 UI](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[ADR](../adr/2026-09-13-document-version-boundary.md);[proposed/authoritative](../ops/short-term/2026-09-20-proposed-authoritative-reports.md) | **before**:完整 PI narrative 混入 → **after**:迁「PI change-report 叙事」。fallback 报告是本行内显式行为,不是缺口。lineage GET 已按 URL 项目和文档可见性裁剪。test_manager 血缘的下游/上游版本改读文档版本,不再写死 1。Agent 候选报告为 `proposed`,正式提交才写 authoritative 后继并挂到版本;拒绝、替代后旧报告不能再出建议。人工修订、导入和聊天卡仍未分开预估与正式报告。项目追踪索引见整改方案 Track T | `docgov` |
|
||||
| PI change-report 叙事 | 只读消费 diff+lineage 的 PI 叙事;失败全量降级 fallback | partial | deferred_enhancement | [Round20](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[go/no-go GO](../ops/research/2026-09-16-wave3-pu6-pu8-go-no-go.md);[pu7](../ops/long-term/2026-09-16-pu7-pi-narrative-contract.md);[脚手架](../test-reports/2026-09-16-pu7-pi-narrative-test-report.md);[W3 真 PI](../test-reports/2026-09-17-w3-pi-change-report-narrative-test-report.md);[2026-09-20 复核](../ops/research/2026-09-20-capability-enablement-impact-research.md);[最小化输入](../ops/short-term/2026-09-20-pi-narrative-clipped-diff.md) | flag 默认 off。`facts.json` 含截断后的 hunk 前后文和影响说明,以及完整 hunk/impact 计数与省略数;摘要 digest 仍不含正文。叙事请求已带 `tool_profile=reasoning`。运行中的 runner 尚未切到 `--no-tools`。不打开开关 | `docgov` |
|
||||
| 前端工作台 | 三栏 + 块文档 + SSE;错误卡/进度/busy;版本冲突信封;demo seed 默认关;终止 preview | done | non_blocker | [workbench](../test-reports/2026-09-08-three-column-workbench-and-block-document-test-report.md);[双标签](../test-reports/assets/2026-09-15-conversation-intent-dual-tab/screenshots/tab-b-busy.png);[demo](../test-reports/2026-09-16-demo-seed-pollution-test-report.md);[B4](../test-reports/2026-09-16-frontend-gate-b4-test-report.md)(项目 6363) | **before**:空「缺口」却 partial → **after**:本行 done。其他 409 消费方信封复用另列 hardening backlog | `web` |
|
||||
| PI Runner / 配置链 | pi-agent + MiniMax + typed-result-v1 | done | non_blocker | [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);[typed-result](../test-reports/2026-09-15-pi-typed-result-v1-test-report.md) | 文档生成仍使用读写和搜索工具。`tool_profile=reasoning` 的 argv 是 `--no-tools` 且不加载搜索扩展;未知 profile 拒绝启动。dev 挂载了 `pi-args.mjs`,但本轮未重启容器 | `pi-agent` |
|
||||
|
||||
@@ -143,7 +143,7 @@ func (s *Service) insertContentProjectionTX(ctx context.Context, tx pgx.Tx, docI
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) persistAgentCandidateLocked(ctx context.Context, outSnap *Snapshot, rep *ChangeReport, c *AgentCandidate) error {
|
||||
func (s *Service) persistAgentCandidateLocked(ctx context.Context, outSnap *Snapshot, rep *ChangeReport, c *AgentCandidate, superseded []*ChangeReport) error {
|
||||
if !s.usingPG() {
|
||||
return nil
|
||||
}
|
||||
@@ -175,6 +175,11 @@ func (s *Service) persistAgentCandidateLocked(ctx context.Context, outSnap *Snap
|
||||
return err
|
||||
}
|
||||
s.adoptCandidateID(c, cid)
|
||||
for _, old := range superseded {
|
||||
if err := s.updateReportFactsTX(ctx, tx, old); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -190,6 +195,29 @@ func (s *Service) persistAgentCommitLocked(ctx context.Context, root *DocRootPoi
|
||||
if err := s.updateVersionTX(ctx, tx, oldVer); err != nil {
|
||||
return err
|
||||
}
|
||||
if newVer.ChangeReportID != nil {
|
||||
if auth := s.reports[*newVer.ChangeReportID]; auth != nil && auth.Authority == ReportAuthoritative {
|
||||
rid, err := s.insertReportTX(ctx, tx, auth)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.adoptReportID(auth, rid)
|
||||
newVer.ChangeReportID = &auth.ID
|
||||
if c.ChangeReportID != nil {
|
||||
if proposed := s.reports[*c.ChangeReportID]; proposed != nil {
|
||||
proposed.SuccessorReportID = &auth.ID
|
||||
}
|
||||
}
|
||||
for _, other := range s.reports {
|
||||
if other == nil || other.DocID != root.DocID || other.Authority != ReportProposed {
|
||||
continue
|
||||
}
|
||||
if err := s.updateReportFactsTX(ctx, tx, other); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
vid, err := s.insertVersionTX(ctx, tx, newVer)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -200,6 +200,7 @@ func (s *Service) hydrateDocLocked(ctx context.Context, docID string) error {
|
||||
if err := json.Unmarshal(factRaw, &rep.DeterministicFacts); err != nil {
|
||||
return fmt.Errorf("hydrateDocLocked: unmarshal deterministic_facts for doc %s report %d: %w", docID, rep.ID, err)
|
||||
}
|
||||
liftReportDisposition(&rep)
|
||||
cp := rep
|
||||
s.reports[cp.ID] = &cp
|
||||
if cp.ID > s.nextRepID {
|
||||
@@ -415,6 +416,19 @@ func (s *Service) insertReportTX(ctx context.Context, tx pgx.Tx, rep *ChangeRepo
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (s *Service) updateReportFactsTX(ctx context.Context, tx pgx.Tx, rep *ChangeReport) error {
|
||||
if rep == nil || rep.ID == 0 {
|
||||
return nil
|
||||
}
|
||||
stampReportDisposition(rep)
|
||||
factRaw, _ := json.Marshal(rep.DeterministicFacts)
|
||||
if factRaw == nil {
|
||||
factRaw = []byte("{}")
|
||||
}
|
||||
_, err := tx.Exec(ctx, `UPDATE document_change_reports SET deterministic_facts=$2::jsonb WHERE id=$1`, rep.ID, factRaw)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) insertCandidateTX(ctx context.Context, tx pgx.Tx, c *AgentCandidate) (int64, error) {
|
||||
actorRaw, _ := json.Marshal(c.ActorSnapshot)
|
||||
if actorRaw == nil {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package docrevision
|
||||
|
||||
// AllowsSuggestedActions is true only for the report a user may still act on.
|
||||
// A proposed estimate stops being actionable once it is superseded, rejected,
|
||||
// expired, or replaced by an authoritative successor. The successor itself remains actionable.
|
||||
func (r *ChangeReport) AllowsSuggestedActions() bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
switch {
|
||||
case r.Authority == ReportProposed && r.Lifecycle == ReportActive:
|
||||
return true
|
||||
case r.Authority == ReportAuthoritative && r.Lifecycle == ReportCommitted:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func stampReportDisposition(rep *ChangeReport) {
|
||||
if rep == nil {
|
||||
return
|
||||
}
|
||||
if rep.DeterministicFacts == nil {
|
||||
rep.DeterministicFacts = map[string]any{}
|
||||
}
|
||||
if rep.Authority != "" {
|
||||
rep.DeterministicFacts["report_authority"] = rep.Authority
|
||||
}
|
||||
if rep.Lifecycle != "" {
|
||||
rep.DeterministicFacts["report_lifecycle"] = rep.Lifecycle
|
||||
}
|
||||
if rep.SuccessorReportID != nil {
|
||||
rep.DeterministicFacts["successor_report_id"] = *rep.SuccessorReportID
|
||||
}
|
||||
}
|
||||
|
||||
func liftReportDisposition(rep *ChangeReport) {
|
||||
if rep == nil || rep.DeterministicFacts == nil {
|
||||
return
|
||||
}
|
||||
if rep.Authority == "" {
|
||||
if a, ok := rep.DeterministicFacts["report_authority"].(string); ok {
|
||||
rep.Authority = a
|
||||
}
|
||||
}
|
||||
if rep.Lifecycle == "" {
|
||||
if life, ok := rep.DeterministicFacts["report_lifecycle"].(string); ok {
|
||||
rep.Lifecycle = life
|
||||
}
|
||||
}
|
||||
if rep.SuccessorReportID == nil {
|
||||
if id, ok := factInt64(rep.DeterministicFacts["successor_report_id"]); ok {
|
||||
rep.SuccessorReportID = &id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func factInt64(v any) (int64, bool) {
|
||||
switch n := v.(type) {
|
||||
case int64:
|
||||
return n, true
|
||||
case int:
|
||||
return int64(n), true
|
||||
case float64:
|
||||
return int64(n), true
|
||||
case jsonNumber:
|
||||
i, err := n.Int64()
|
||||
return i, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// jsonNumber is the subset of encoding/json.Number used after a UseNumber decode.
|
||||
type jsonNumber interface {
|
||||
Int64() (int64, error)
|
||||
}
|
||||
|
||||
func cloneChangeReport(src *ChangeReport) *ChangeReport {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *src
|
||||
cp.ID = 0
|
||||
cp.SuccessorReportID = nil
|
||||
cp.Summary = append([]string(nil), src.Summary...)
|
||||
cp.RiskItems = append([]RiskItem(nil), src.RiskItems...)
|
||||
cp.DocumentImpacts = append([]DocImpact(nil), src.DocumentImpacts...)
|
||||
cp.OpenQuestions = append([]string(nil), src.OpenQuestions...)
|
||||
cp.DeterministicFacts = map[string]any{}
|
||||
for k, v := range src.DeterministicFacts {
|
||||
cp.DeterministicFacts[k] = v
|
||||
}
|
||||
if src.ProjectImpacts != nil {
|
||||
cp.ProjectImpacts = map[string]any{}
|
||||
for k, v := range src.ProjectImpacts {
|
||||
cp.ProjectImpacts[k] = v
|
||||
}
|
||||
}
|
||||
return &cp
|
||||
}
|
||||
|
||||
func (s *Service) assignReportIDLocked(rep *ChangeReport) {
|
||||
rep.ID = nextFreeID(&s.nextRepID, func(id int64) bool { return s.reports[id] != nil })
|
||||
s.reports[rep.ID] = rep
|
||||
}
|
||||
|
||||
// supersedeActiveProposedLocked closes earlier estimates for the same document.
|
||||
// The new candidate is the only active proposed report.
|
||||
func (s *Service) supersedeActiveProposedLocked(docID string) []*ChangeReport {
|
||||
var out []*ChangeReport
|
||||
for _, rep := range s.reports {
|
||||
if rep == nil || rep.DocID != docID || rep.Authority != ReportProposed || rep.Lifecycle != ReportActive {
|
||||
continue
|
||||
}
|
||||
rep.Lifecycle = ReportSuperseded
|
||||
stampReportDisposition(rep)
|
||||
out = append(out, rep)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Service) sealProposedWithSuccessorLocked(proposed *ChangeReport) *ChangeReport {
|
||||
auth := cloneChangeReport(proposed)
|
||||
auth.Authority = ReportAuthoritative
|
||||
auth.Lifecycle = ReportCommitted
|
||||
auth.SuccessorReportID = nil
|
||||
auth.CreatedAt = proposed.CreatedAt
|
||||
s.assignReportIDLocked(auth)
|
||||
proposed.Lifecycle = ReportCommitted
|
||||
proposed.SuccessorReportID = &auth.ID
|
||||
stampReportDisposition(proposed)
|
||||
stampReportDisposition(auth)
|
||||
return auth
|
||||
}
|
||||
|
||||
func (s *Service) closeProposedLocked(rep *ChangeReport, lifecycle string) {
|
||||
if rep == nil || rep.Authority != ReportProposed || rep.Lifecycle != ReportActive {
|
||||
return
|
||||
}
|
||||
rep.Lifecycle = lifecycle
|
||||
stampReportDisposition(rep)
|
||||
}
|
||||
|
||||
func (s *Service) expireOtherProposedLocked(docID string, keep int64) {
|
||||
for _, rep := range s.reports {
|
||||
if rep == nil || rep.ID == keep || rep.DocID != docID {
|
||||
continue
|
||||
}
|
||||
if rep.Authority == ReportProposed && rep.Lifecycle == ReportActive {
|
||||
rep.Lifecycle = ReportExpired
|
||||
stampReportDisposition(rep)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package docrevision
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLiftReportDisposition_ReadsPersistedFacts(t *testing.T) {
|
||||
id := int64(42)
|
||||
rep := &ChangeReport{DeterministicFacts: map[string]any{
|
||||
"report_authority": ReportProposed,
|
||||
"report_lifecycle": ReportCommitted,
|
||||
"successor_report_id": float64(id),
|
||||
}}
|
||||
liftReportDisposition(rep)
|
||||
if rep.Authority != ReportProposed || rep.Lifecycle != ReportCommitted || rep.SuccessorReportID == nil || *rep.SuccessorReportID != id {
|
||||
t.Fatalf("lifted = %+v", rep)
|
||||
}
|
||||
if rep.AllowsSuggestedActions() {
|
||||
t.Fatal("committed proposed report must not accept new actions")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package docrevision_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/docrevision"
|
||||
)
|
||||
|
||||
func TestProposedReportStaysInactiveUntilCommit(t *testing.T) {
|
||||
svc := docrevision.NewService(nil)
|
||||
svc.Notify = true
|
||||
var events []string
|
||||
committed := false
|
||||
svc.OnOutbox = func(_ context.Context, eventType string, payload map[string]any) error {
|
||||
events = append(events, eventType)
|
||||
if !committed && (eventType == "document.downstream_impact_detected" || eventType == "document.agent_version_committed") {
|
||||
t.Fatalf("candidate path emitted %s payload=%v", eventType, payload)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
ctx := context.Background()
|
||||
docID := "doc_proposed_split"
|
||||
st, err := svc.OpenTemplateVersion(ctx, docID, "# base\n\nbody\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, firstRep, err := svc.CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{
|
||||
DocID: docID, RunID: "run-a", InputSnapshotID: st.HeadSnapshotID,
|
||||
OutputMarkdown: "# base\n\nbody changed\n",
|
||||
ActorKind: docrevision.ActorAgent,
|
||||
})
|
||||
if err != nil || first == nil || firstRep == nil {
|
||||
t.Fatalf("first candidate: cand=%v rep=%v err=%v", first, firstRep, err)
|
||||
}
|
||||
if firstRep.Authority != docrevision.ReportProposed || firstRep.Lifecycle != docrevision.ReportActive || !firstRep.AllowsSuggestedActions() {
|
||||
t.Fatalf("first report = %+v", firstRep)
|
||||
}
|
||||
for _, ev := range events {
|
||||
if ev != "document.agent_candidate_ready" {
|
||||
t.Fatalf("unexpected event before commit: %s", ev)
|
||||
}
|
||||
}
|
||||
|
||||
second, secondRep, err := svc.CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{
|
||||
DocID: docID, RunID: "run-b", InputSnapshotID: st.HeadSnapshotID,
|
||||
OutputMarkdown: "# base\n\nbody changed again\n",
|
||||
ActorKind: docrevision.ActorAgent,
|
||||
})
|
||||
if err != nil || second == nil {
|
||||
t.Fatalf("second candidate: %v err=%v", second, err)
|
||||
}
|
||||
again, err := svc.GetChangeReport(ctx, docID, firstRep.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again.Lifecycle != docrevision.ReportSuperseded || again.AllowsSuggestedActions() {
|
||||
t.Fatalf("older estimate must be closed: %+v", again)
|
||||
}
|
||||
if secondRep.Lifecycle != docrevision.ReportActive || !secondRep.AllowsSuggestedActions() {
|
||||
t.Fatalf("newer estimate must stay active: %+v", secondRep)
|
||||
}
|
||||
|
||||
events = nil
|
||||
committed = true
|
||||
st2, err := svc.CommitAgentVersion(ctx, docID, second.ID, docrevision.CommitAgentDecision{
|
||||
Decision: "commit", ActorUserID: 1, ExpectedInputSHA: st.HeadSHA256,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st2.CurrentVersionNo != 2 {
|
||||
t.Fatalf("version = %+v", st2)
|
||||
}
|
||||
committedProposed, err := svc.GetChangeReport(ctx, docID, secondRep.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if committedProposed.Lifecycle != docrevision.ReportCommitted || committedProposed.AllowsSuggestedActions() {
|
||||
t.Fatalf("proposed report must stop accepting actions: %+v", committedProposed)
|
||||
}
|
||||
if committedProposed.SuccessorReportID == nil || *committedProposed.SuccessorReportID == secondRep.ID {
|
||||
t.Fatalf("missing distinct successor: %+v", committedProposed)
|
||||
}
|
||||
auth, err := svc.GetChangeReport(ctx, docID, *committedProposed.SuccessorReportID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if auth.Authority != docrevision.ReportAuthoritative || !auth.AllowsSuggestedActions() {
|
||||
t.Fatalf("authoritative report = %+v", auth)
|
||||
}
|
||||
versions, err := svc.ListVersions(ctx, docID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var sawAuth bool
|
||||
for _, v := range versions {
|
||||
if v.ChangeReportID == nil {
|
||||
continue
|
||||
}
|
||||
if *v.ChangeReportID == secondRep.ID {
|
||||
t.Fatal("version must not point at the proposed estimate")
|
||||
}
|
||||
if *v.ChangeReportID == auth.ID {
|
||||
sawAuth = true
|
||||
}
|
||||
}
|
||||
if !sawAuth {
|
||||
t.Fatalf("version missing authoritative report id %d in %+v", auth.ID, versions)
|
||||
}
|
||||
for _, ev := range events {
|
||||
if ev == "document.agent_version_committed" {
|
||||
t.Fatal("docrevision must not emit the formal commit event itself")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectedProposedReportCannotSuggest(t *testing.T) {
|
||||
svc := docrevision.NewService(nil)
|
||||
ctx := context.Background()
|
||||
docID := "doc_proposed_reject"
|
||||
st, err := svc.OpenTemplateVersion(ctx, docID, "# base\n\n法规引用 GB9706\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cand, rep, err := svc.CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{
|
||||
DocID: docID, RunID: "run-reject", InputSnapshotID: st.HeadSnapshotID,
|
||||
OutputMarkdown: "# base\n\n",
|
||||
ActorKind: docrevision.ActorAgent,
|
||||
})
|
||||
if cand == nil || rep == nil {
|
||||
t.Fatalf("cand=%v rep=%v err=%v", cand, rep, err)
|
||||
}
|
||||
if _, err := svc.CommitAgentVersion(ctx, docID, cand.ID, docrevision.CommitAgentDecision{Decision: "reject", ActorUserID: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := svc.GetChangeReport(ctx, docID, rep.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Lifecycle != docrevision.ReportRejected || got.AllowsSuggestedActions() {
|
||||
t.Fatalf("rejected report = %+v", got)
|
||||
}
|
||||
versions, _ := svc.ListVersions(ctx, docID)
|
||||
for _, v := range versions {
|
||||
if v.VersionNo > 1 {
|
||||
t.Fatalf("reject must not open a new version: %+v", versions)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -522,14 +522,21 @@ func (s *Service) CreateAgentCandidate(ctx context.Context, req AgentCandidateRe
|
||||
if NeedsConfirmation(rep.RiskLevel) {
|
||||
status = CandidateNeedsConfirmation
|
||||
}
|
||||
if *root.HeadSnapshotID != req.InputSnapshotID {
|
||||
status = CandidateStale
|
||||
}
|
||||
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()
|
||||
rep.Authority = ReportProposed
|
||||
rep.Lifecycle = ReportActive
|
||||
var superseded []*ChangeReport
|
||||
if *root.HeadSnapshotID != req.InputSnapshotID {
|
||||
status = CandidateStale
|
||||
rep.Lifecycle = ReportExpired
|
||||
} else {
|
||||
superseded = s.supersedeActiveProposedLocked(req.DocID)
|
||||
}
|
||||
stampReportDisposition(rep)
|
||||
s.reports[rep.ID] = rep
|
||||
|
||||
c := &AgentCandidate{
|
||||
@@ -539,7 +546,7 @@ func (s *Service) CreateAgentCandidate(ctx context.Context, req AgentCandidateRe
|
||||
ActorKind: req.ActorKind, ActorSnapshot: req.ActorSnapshot, CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
s.candidates[c.ID] = c
|
||||
if err := s.persistAgentCandidateLocked(ctx, outSnap, rep, c); err != nil {
|
||||
if err := s.persistAgentCandidateLocked(ctx, outSnap, rep, c, superseded); err != nil {
|
||||
s.forgetDocLocked(req.DocID)
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -601,6 +608,23 @@ func (s *Service) CommitAgentVersion(ctx context.Context, docID string, candidat
|
||||
}
|
||||
if *root.HeadSnapshotID != c.InputSnapshotID {
|
||||
c.Status = CandidateStale
|
||||
if c.ChangeReportID != nil {
|
||||
s.closeProposedLocked(s.reports[*c.ChangeReportID], ReportExpired)
|
||||
}
|
||||
if s.usingPG() {
|
||||
if err := s.withTx(ctx, func(tx pgx.Tx) error {
|
||||
if err := s.updateCandidateTX(ctx, tx, c); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.ChangeReportID == nil {
|
||||
return nil
|
||||
}
|
||||
return s.updateReportFactsTX(ctx, tx, s.reports[*c.ChangeReportID])
|
||||
}); err != nil {
|
||||
s.forgetDocLocked(docID)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return nil, ErrCandidateStale
|
||||
}
|
||||
if root.LifecycleState != "" && root.LifecycleState != "drafting" {
|
||||
@@ -625,8 +649,19 @@ func (s *Service) CommitAgentVersion(ctx context.Context, docID string, candidat
|
||||
now := time.Now().UTC()
|
||||
c.Status = CandidateRejected
|
||||
c.DecidedAt = &now
|
||||
if c.ChangeReportID != nil {
|
||||
s.closeProposedLocked(s.reports[*c.ChangeReportID], ReportRejected)
|
||||
}
|
||||
if s.usingPG() {
|
||||
if err := s.withTx(ctx, func(tx pgx.Tx) error { return s.updateCandidateTX(ctx, tx, c) }); err != nil {
|
||||
if err := s.withTx(ctx, func(tx pgx.Tx) error {
|
||||
if err := s.updateCandidateTX(ctx, tx, c); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.ChangeReportID == nil {
|
||||
return nil
|
||||
}
|
||||
return s.updateReportFactsTX(ctx, tx, s.reports[*c.ChangeReportID])
|
||||
}); err != nil {
|
||||
s.forgetDocLocked(docID)
|
||||
return nil, err
|
||||
}
|
||||
@@ -643,6 +678,9 @@ func (s *Service) CommitAgentVersion(ctx context.Context, docID string, candidat
|
||||
now := time.Now().UTC()
|
||||
c.Status = CandidateCommitted
|
||||
c.DecidedAt = &now
|
||||
if c.ChangeReportID != nil {
|
||||
s.closeProposedLocked(s.reports[*c.ChangeReportID], ReportCommitted)
|
||||
}
|
||||
if s.usingPG() {
|
||||
if err := s.withTx(ctx, func(tx pgx.Tx) error {
|
||||
eid, err := s.insertEventTX(ctx, tx, ev)
|
||||
@@ -650,7 +688,13 @@ func (s *Service) CommitAgentVersion(ctx context.Context, docID string, candidat
|
||||
return err
|
||||
}
|
||||
s.adoptEventID(ev, eid)
|
||||
return s.updateCandidateTX(ctx, tx, c)
|
||||
if err := s.updateCandidateTX(ctx, tx, c); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.ChangeReportID == nil {
|
||||
return nil
|
||||
}
|
||||
return s.updateReportFactsTX(ctx, tx, s.reports[*c.ChangeReportID])
|
||||
}); err != nil {
|
||||
s.forgetDocLocked(docID)
|
||||
return nil, err
|
||||
@@ -670,7 +714,9 @@ func (s *Service) CommitAgentVersion(ctx context.Context, docID string, candidat
|
||||
origin = OriginAgentSquad
|
||||
}
|
||||
newVer := s.openVersionLocked(c.DocID, origin, outSnap.ID, outSnap.ID, c.RunID)
|
||||
newVer.ChangeReportID = c.ChangeReportID
|
||||
auth := s.sealProposedWithSuccessorLocked(rep)
|
||||
s.expireOtherProposedLocked(c.DocID, rep.ID)
|
||||
newVer.ChangeReportID = &auth.ID
|
||||
root.LedgerVersionID = &newVer.ID
|
||||
root.HeadSnapshotID = &outSnap.ID
|
||||
root.HeadRevisionNo = 0
|
||||
@@ -690,16 +736,17 @@ func (s *Service) CommitAgentVersion(ctx context.Context, docID string, candidat
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, joined := uow.ScopeFrom(ctx); !joined && s.Notify && s.OnOutbox != nil {
|
||||
// document.agent_version_committed is owned by events.EventSink (U6).
|
||||
for _, imp := range rep.DocumentImpacts {
|
||||
if _, joined := uow.ScopeFrom(ctx); !joined && s.Notify && s.OnOutbox != nil && auth.ID != 0 {
|
||||
// document.agent_version_committed is owned by events.EventSink (U6) and is not emitted here.
|
||||
// Downstream notices belong to the authoritative report, never to the proposed estimate.
|
||||
for _, imp := range auth.DocumentImpacts {
|
||||
_ = s.OnOutbox(ctx, "document.downstream_impact_detected", map[string]any{
|
||||
"doc_id": c.DocID, "impacted_doc_id": imp.DocID, "version_no": newVer.VersionNo,
|
||||
})
|
||||
}
|
||||
if rep.AnalysisStatus == AnalysisUnassessed || rep.AnalysisStatus == AnalysisFallback {
|
||||
if auth.AnalysisStatus == AnalysisUnassessed || auth.AnalysisStatus == AnalysisFallback {
|
||||
_ = s.OnOutbox(ctx, "document.change_report_degraded", map[string]any{
|
||||
"doc_id": c.DocID, "report_id": rep.ID,
|
||||
"doc_id": c.DocID, "report_id": auth.ID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,17 @@ const (
|
||||
AnalysisUnassessed = "unassessed"
|
||||
AnalysisCorrected = "corrected"
|
||||
|
||||
// Report authority separates a candidate estimate from the report written at formal commit.
|
||||
ReportProposed = "proposed"
|
||||
ReportAuthoritative = "authoritative"
|
||||
|
||||
// Proposed reports stay actionable only while active.
|
||||
ReportActive = "active"
|
||||
ReportSuperseded = "superseded"
|
||||
ReportRejected = "rejected"
|
||||
ReportExpired = "expired"
|
||||
ReportCommitted = "committed"
|
||||
|
||||
ErrCodeHeadConflict = "DOCUMENT_HEAD_CONFLICT"
|
||||
ErrCodeEditForbidden = "DOCUMENT_EDIT_FORBIDDEN"
|
||||
ErrCodeBusy = "DOCUMENT_BUSY"
|
||||
@@ -196,8 +207,14 @@ type ChangeReport struct {
|
||||
PromptVersion string `json:"prompt_version,omitempty"`
|
||||
DeterministicFacts map[string]any `json:"deterministic_facts"`
|
||||
CorrectsReportID *int64 `json:"corrects_report_id,omitempty"`
|
||||
RawJSON string `json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
// Authority is proposed on a candidate estimate and authoritative on the formal-commit successor.
|
||||
Authority string `json:"authority,omitempty"`
|
||||
// Lifecycle is active, superseded, rejected, expired, or committed.
|
||||
Lifecycle string `json:"lifecycle,omitempty"`
|
||||
// SuccessorReportID points from a committed proposed report to its authoritative report.
|
||||
SuccessorReportID *int64 `json:"successor_report_id,omitempty"`
|
||||
RawJSON string `json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type RiskItem struct {
|
||||
|
||||
Reference in New Issue
Block a user