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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user