From 9c8a41982c9cef2405e9005ccca6c5ffcac476c5 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 10:09:18 +0000 Subject: [PATCH] fix(api): guide idempotent decision retry after governance mark failure Surface a same-decision retry hint and cover commit-then-MarkProposalApplied compensation via PutChangeProposal when the proposal index was missing. Co-authored-by: Cursor --- .../server/document_revision_authz_test.go | 46 +++++++++++++++---- internal/server/document_revision_handlers.go | 2 +- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/internal/server/document_revision_authz_test.go b/internal/server/document_revision_authz_test.go index 39b18ef1..e2be585b 100644 --- a/internal/server/document_revision_authz_test.go +++ b/internal/server/document_revision_authz_test.go @@ -8,7 +8,9 @@ import ( "net/http/httptest" "strconv" "testing" + "time" + "chat-agent-system/internal/docgov" "chat-agent-system/internal/docrevision" ) @@ -198,8 +200,9 @@ func TestDocRevision_CommitWithMissingProposalReturnsGovernanceError(t *testing. if err != nil { t.Fatal(err) } + ghostProp := "prop_does_not_exist" cand, _, err := srv.svc.DocRevision().CreateAgentCandidate(ctx, docrevision.AgentCandidateRequest{ - DocID: docID, RunID: "gov-miss-1", ProposalID: "prop_does_not_exist", + DocID: docID, RunID: "gov-miss-1", ProposalID: ghostProp, InputSnapshotID: st.HeadSnapshotID, OutputMarkdown: "# base\n\n", ActorKind: docrevision.ActorAgentSquad, }) @@ -211,9 +214,10 @@ func TestDocRevision_CommitWithMissingProposalReturnsGovernanceError(t *testing. } token := loginTestAdmin(t, srv) + bodyJSON := []byte(`{"decision":"commit","confirmed":true,"expected_input_sha":"` + st.HeadSHA256 + `"}`) decOK := httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+docID+"/agent-candidates/"+strconv.FormatInt(cand.ID, 10)+"/decision", - bytes.NewReader([]byte(`{"decision":"commit","confirmed":true,"expected_input_sha":"`+st.HeadSHA256+`"}`))) + bytes.NewReader(bodyJSON)) withBearerAuth(decOK, token) wOK := httptest.NewRecorder() srv.Routes().ServeHTTP(wOK, decOK) @@ -227,17 +231,41 @@ func TestDocRevision_CommitWithMissingProposalReturnsGovernanceError(t *testing. if body["code"] != "DOCUMENT_GOVERNANCE_FAILED" { t.Fatalf("code=%v body=%#v", body["code"], body) } - // Retryable: ledger already committed — second call should still surface governance error - // until proposal exists (or mark becomes no-op when already applied). + + gotCand, err := srv.svc.DocRevision().GetCandidate(ctx, docID, cand.ID) + if err != nil || gotCand.Status != docrevision.CandidateCommitted { + t.Fatalf("ledger must stay committed after governance failure: %+v err=%v", gotCand, err) + } + + // Idempotent retry while proposal still missing → same governance error (not "already decided"). wRetry := httptest.NewRecorder() decRetry := httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+docID+"/agent-candidates/"+strconv.FormatInt(cand.ID, 10)+"/decision", - bytes.NewReader([]byte(`{"decision":"commit","confirmed":true,"expected_input_sha":"`+st.HeadSHA256+`"}`))) + bytes.NewReader(bodyJSON)) withBearerAuth(decRetry, token) srv.Routes().ServeHTTP(wRetry, decRetry) - // Candidate already committed → decision may be conflict/not pending; either governance - // retry path or explicit conflict is acceptable as long as we did not silently 200. - if wRetry.Code == http.StatusOK { - t.Fatalf("retry after governance failure must not silently succeed: %s", wRetry.Body.String()) + if wRetry.Code != http.StatusInternalServerError { + t.Fatalf("idempotent retry want 500 got %d %s", wRetry.Code, wRetry.Body.String()) + } + var body2 map[string]any + _ = json.NewDecoder(wRetry.Body).Decode(&body2) + if body2["code"] != "DOCUMENT_GOVERNANCE_FAILED" { + t.Fatalf("retry code=%v body=%#v", body2["code"], body2) + } + + // Compensate: seed approved proposal with the same ID, then same decision succeeds. + now := time.Now() + srv.svc.DocGov().PutChangeProposal(&docgov.ChangeProposal{ + ProposalID: ghostProp, DocID: docID, ProjectID: 1, + Status: docgov.ProposalApprovedByAuthor, CreatedAt: now, + }) + wFix := httptest.NewRecorder() + decFix := httptest.NewRequest(http.MethodPost, + "/api/v1/documents/"+docID+"/agent-candidates/"+strconv.FormatInt(cand.ID, 10)+"/decision", + bytes.NewReader(bodyJSON)) + withBearerAuth(decFix, token) + srv.Routes().ServeHTTP(wFix, decFix) + if wFix.Code != http.StatusOK { + t.Fatalf("compensating retry want 200 got %d %s", wFix.Code, wFix.Body.String()) } } diff --git a/internal/server/document_revision_handlers.go b/internal/server/document_revision_handlers.go index 20843eac..916cb9e5 100644 --- a/internal/server/document_revision_handlers.go +++ b/internal/server/document_revision_handlers.go @@ -369,7 +369,7 @@ func (s *Server) handleDocRevisionCandidateDecision(w http.ResponseWriter, r *ht writeJSONResponse(w, http.StatusInternalServerError, map[string]any{ "code": "DOCUMENT_GOVERNANCE_FAILED", "error": err.Error(), - "hint": "候选已提交且事实源已投影,但变更提案标记为已应用失败;请用同一决策重试以完成治理收口。", + "hint": "候选账本已提交且事实源已投影,但变更提案标记失败。请用同一 decision(相同 expected_*_sha)重试:已 committed 候选幂等,仅补 MarkProposalApplied;勿换候选或改正文。", "state": st, }) return