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:
weichen
2026-09-13 18:42:44 +00:00
co-authored by Cursor
parent 56e052f3cb
commit 9f6401847d
2 changed files with 82 additions and 0 deletions
@@ -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)
}
}