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 <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 10:09:18 +00:00
co-authored by Cursor
parent 9a9ffa48cc
commit 9c8a41982c
2 changed files with 38 additions and 10 deletions
@@ -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())
}
}
@@ -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