From bf3136490d9df59d8a76a46657e3f2c7fa17b312 Mon Sep 17 00:00:00 2001 From: weichen Date: Sat, 26 Sep 2026 00:15:56 +0000 Subject: [PATCH] fix(docgov): load review_snapshot_id when reading document records from PG loadDocumentFromDBLocked selected every column except review_snapshot_id, so any document loaded after a restart or cache miss lost its frozen review snapshot and ApproverApprove failed with "approved requires frozen review_snapshot_id". PG regression test seeds a snapshot and asserts a fresh engine carries the id through load. --- internal/docgov/engine_read.go | 4 +- internal/docgov/engine_read_snapshot_test.go | 74 ++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 internal/docgov/engine_read_snapshot_test.go diff --git a/internal/docgov/engine_read.go b/internal/docgov/engine_read.go index 79781c76..cb9f0000 100644 --- a/internal/docgov/engine_read.go +++ b/internal/docgov/engine_read.go @@ -119,7 +119,8 @@ func (e *GovernanceEngine) loadDocumentFromDBLocked(ctx context.Context, docID s approved_at, config, created_at, updated_at, COALESCE(lease_holder_type, ''), COALESCE(lease_holder_id, ''), COALESCE(lease_work_dir, ''), lease_expires_at, lease_heartbeat_at, COALESCE(lease_fencing_token, ''), - COALESCE(duty_locked, false), COALESCE(duty_policy, '') + COALESCE(duty_locked, false), COALESCE(duty_policy, ''), + COALESCE(review_snapshot_id, 0) FROM document_records WHERE doc_id = $1 `, docID) @@ -139,6 +140,7 @@ func (e *GovernanceEngine) loadDocumentFromDBLocked(ctx context.Context, docID s &doc.ApprovedAt, &cfgJSON, &doc.CreatedAt, &doc.UpdatedAt, &leaseType, &leaseID, &leaseWD, &doc.LeaseExpiresAt, &doc.LeaseHeartbeatAt, &doc.LeaseFencingToken, &doc.DutyLocked, &doc.DutyPolicy, + &doc.ReviewSnapshotID, ) if err != nil { return nil, err diff --git a/internal/docgov/engine_read_snapshot_test.go b/internal/docgov/engine_read_snapshot_test.go new file mode 100644 index 00000000..d29dbb3f --- /dev/null +++ b/internal/docgov/engine_read_snapshot_test.go @@ -0,0 +1,74 @@ +package docgov_test + +import ( + "context" + "fmt" + "testing" + "time" + + "chat-agent-system/internal/docgov" +) + +// Regression (2026-09-26): loadDocumentFromDBLocked did not select +// review_snapshot_id, so any doc loaded from PG after a restart (or cache +// miss) lost the frozen review snapshot and ApproverApprove failed with +// "approved requires frozen review_snapshot_id". Freeze via SubmitForReview, +// then re-load through a fresh engine and assert the snapshot id survives. +func TestDocGov_LoadDocumentCarriesReviewSnapshotID(t *testing.T) { + pool := openIsolatedPool(t) + defer pool.Close() + projectID := seedTestProject(t, pool) + + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + + engine := docgov.NewGovernanceEngine(pool) + testDocID := fmt.Sprintf("doc_test_snap_%d", time.Now().UnixNano()) + + doc, err := engine.CreateOrGetDocument(ctx, testDocID, projectID, "feasibility_reg", + "快照装载回归", "V0.5_feasibility", "data/dev/workspaces/test_snap", + 2, "张工", 3, "李主管", 1, "Seed Admin") + if err != nil { + t.Fatalf("create doc: %v", err) + } + if _, err := engine.RecordDocumentViewed(ctx, testDocID, 2, docgov.RoleAuthor, "张工"); err != nil { + t.Fatalf("claim author: %v", err) + } + if err := engine.MarkDocumentContentUpdated(ctx, testDocID, "snap_sha256", false); err != nil { + t.Fatalf("mark content: %v", err) + } + if _, err := engine.SubmitForReview(ctx, testDocID, 2, docgov.RoleAuthor, "张工"); err != nil { + t.Fatalf("submit review: %v", err) + } + // Seed a frozen snapshot id directly: freezeReviewSnapshotLocked copies + // head_snapshot_id which only exists once the ledger sealed a snapshot; + // the regression under test is the load path, not the freeze path. + var want int64 + err = pool.QueryRow(ctx, ` + WITH blob AS ( + INSERT INTO document_content_blobs (content_sha256, content_markdown, byte_size, created_at) + VALUES ('snap_load_regression_sha', '# regression', 12, now()) + RETURNING content_sha256 + ) + INSERT INTO document_snapshots (doc_id, content_sha256, actor_user_id, actor_snapshot, source_kind, created_at) + SELECT $1::text, blob.content_sha256, 2, '"张工"'::jsonb, 'human', now() FROM blob + RETURNING id + `, testDocID).Scan(&want) + if err != nil || want <= 0 { + t.Fatalf("seed snapshot: id=%d err=%v", want, err) + } + if _, err := pool.Exec(ctx, `UPDATE document_records SET review_snapshot_id=$1 WHERE doc_id=$2`, want, testDocID); err != nil { + t.Fatalf("set snapshot id: %v", err) + } + + // Fresh engine simulates a restart: the doc must load with its snapshot. + fresh := docgov.NewGovernanceEngine(pool) + loaded, err := fresh.GetDocument(ctx, testDocID) + if err != nil { + t.Fatalf("fresh engine load: %v", err) + } + if loaded.ReviewSnapshotID != want { + t.Fatalf("ReviewSnapshotID lost on load: got %d want %d", loaded.ReviewSnapshotID, want) + } + _ = doc +}