fix(docgov): release human lease after PATCH request
Request-scoped defer release after authorized acquire so success/409/projection failure cannot leave a 15m residual that blocks squad runs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -198,11 +200,20 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
|
||||
// 1) Controlled: author duty + lease WITHOUT writing body (fail-closed before ledger).
|
||||
// This UI has no edit-session heartbeat: release the request-scoped human lease on every exit
|
||||
// (success, 409, projection failure) so a 15m residual cannot block a later squad run.
|
||||
if kind == "controlled" && s.svc != nil && s.svc.DocGov() != nil {
|
||||
if err := s.svc.DocGov().AssertHumanCanEdit(r.Context(), docID, user.ID, docgov.RoleAuthor); err != nil {
|
||||
writeDocGovError(w, err)
|
||||
return
|
||||
}
|
||||
holderID := fmt.Sprintf("%d", user.ID)
|
||||
if user.ID <= 0 {
|
||||
holderID = "human_anon"
|
||||
}
|
||||
defer func() {
|
||||
_, _ = s.svc.DocGov().ReleaseLease(context.Background(), docID, string(docgov.LeaseHolderHuman), holderID)
|
||||
}()
|
||||
}
|
||||
|
||||
// 2) Bootstrap if needed, then commit ledger. No fact-source mutation yet.
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"chat-agent-system/internal/docgov"
|
||||
"chat-agent-system/internal/docrevision"
|
||||
)
|
||||
|
||||
@@ -64,6 +65,7 @@ func TestDocRevision_PatchOrder_AssertBeforeLedger_ProjectionFailureKeepsLedger(
|
||||
if after.HeadRevisionNo <= beforeRev && after.HeadSnapshotID == beforeSnap {
|
||||
t.Fatalf("ledger should commit before projection; before rev=%d snap=%d after=%+v", beforeRev, beforeSnap, after)
|
||||
}
|
||||
assertHumanLeaseReleasedSquadCanAcquire(t, srv, docID)
|
||||
}
|
||||
|
||||
func TestDocRevision_PatchOrder_LeaseDeniedDoesNotAdvanceLedger(t *testing.T) {
|
||||
@@ -150,4 +152,73 @@ func TestDocRevision_PatchOrder_SaveDocumentContentSetsAuthorAudit(t *testing.T)
|
||||
if _, err := os.Stat(filepath.Join(workDir, "output.md")); err != nil {
|
||||
t.Fatalf("output.md should exist after SaveDocumentContent: %v", err)
|
||||
}
|
||||
assertHumanLeaseReleasedSquadCanAcquire(t, srv, docID)
|
||||
}
|
||||
|
||||
func TestDocRevision_PatchReleasesHumanLeaseOnHeadConflict(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
ctx := context.Background()
|
||||
workDir := filepath.Join(srv.dataRoot(), "ws-conflict-lease")
|
||||
_ = os.MkdirAll(workDir, 0o755)
|
||||
docID := "doc_proj1_patch_conflict_lease"
|
||||
|
||||
if _, err := srv.svc.DocGov().CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "admin", 2, "r", 3, "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := srv.svc.DocRevision().OpenTemplateVersion(ctx, docID, "# base\n\nbody\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := loginTestAdmin(t, srv)
|
||||
etag := `"` + strconv.FormatInt(st.HeadSnapshotID, 10) + ":" + strconv.Itoa(st.HeadRevisionNo) + `"`
|
||||
|
||||
first := httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+docID+"/content",
|
||||
bytes.NewReader([]byte(`{"markdown":"# base\n\nbody\n\nA1\n"}`)))
|
||||
withBearerAuth(first, token)
|
||||
first.Header.Set("Idempotency-Key", "conflict-lease-a1")
|
||||
first.Header.Set("If-Match", etag)
|
||||
w1 := httptest.NewRecorder()
|
||||
srv.Routes().ServeHTTP(w1, first)
|
||||
if w1.Code != http.StatusOK {
|
||||
t.Fatalf("first save want 200 got %d %s", w1.Code, w1.Body.String())
|
||||
}
|
||||
|
||||
second := httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+docID+"/content",
|
||||
bytes.NewReader([]byte(`{"markdown":"# base\n\nbody\n\nA2\n"}`)))
|
||||
withBearerAuth(second, token)
|
||||
second.Header.Set("Idempotency-Key", "conflict-lease-a2")
|
||||
second.Header.Set("If-Match", etag)
|
||||
w2 := httptest.NewRecorder()
|
||||
srv.Routes().ServeHTTP(w2, second)
|
||||
if w2.Code != http.StatusConflict {
|
||||
t.Fatalf("stale etag want 409 got %d %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
assertHumanLeaseReleasedSquadCanAcquire(t, srv, docID)
|
||||
}
|
||||
|
||||
func assertHumanLeaseReleasedSquadCanAcquire(t *testing.T, srv *Server, docID string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
doc, err := srv.svc.DocGov().GetDocument(ctx, docID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if doc.HasActiveLease(time.Now()) {
|
||||
t.Fatalf("human lease must be released after PATCH, holder=%s/%s", doc.LeaseHolderType, doc.LeaseHolderID)
|
||||
}
|
||||
leased, err := srv.svc.DocGov().AcquireLease(ctx, docgov.LeaseAcquireRequest{
|
||||
DocID: docID,
|
||||
HolderType: docgov.LeaseHolderSquad,
|
||||
HolderID: "run_after_human_save",
|
||||
TTL: time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("squad must acquire after human PATCH released lease: %v", err)
|
||||
}
|
||||
if leased.LeaseHolderType != docgov.LeaseHolderSquad {
|
||||
t.Fatalf("want squad holder, got %s", leased.LeaseHolderType)
|
||||
}
|
||||
if _, err := srv.svc.DocGov().ReleaseLease(ctx, docID, string(docgov.LeaseHolderSquad), "run_after_human_save"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user