merge: fix/document-revision-second-doc-routing-lease

This commit is contained in:
weichen
2026-09-13 18:47:14 +00:00
10 changed files with 316 additions and 9 deletions
@@ -0,0 +1,46 @@
---
title: "UI-11 第二份文档路由到 intake 且 human lease 残留"
status: in_progress
created: 2026-09-13
updated: 2026-09-13
severity: high
---
# UI-11 第二份文档路由到 intake 且 human lease 残留
## 现象
第十轮真实 UI(schema 30):
- UI-00~UI-10 **PASS**
- UI-11 页面输入:请另行生成一份《系统设计输入》受控文档(第二份),不要覆盖上一份需求文档,并给出可打开的交付物。
- 页面回复《用户初始需求收集表》正由其他用户编辑/生成中,**未启动任务**
## 根因分析
| 层 | 问题 |
|---|---|
| 路由 | `resolveTaskKeyFromMessage` 写意图后先扫「需求\|摸底\|用户\|首轮」,否定句「不要覆盖上一份**需求**文档」压过正向点名「系统设计输入」 |
| 别名 | `NAMED_DELIVERABLE_ALIASES.prelim_design` 只有「初步设计说明书 / 系统设计规格」,缺「系统设计输入」(模板正式标题是「系统初步设计方案」) |
| 租约 | PATCH 在 `AssertHumanCanEdit` 后持 15min human lease;本 UI 无 heartbeat/token。成功 / 409 / 投影失败都不释放,intake 被锁,小队再跑 intake 得 DOC_BUSY |
| E2E | UI-11 `sendChat` 后直接 `waitForSquadCompletion`,没有 `tasks/runs` 202 门闩;同步 busy completed 可能被当成成功 |
## 严重度
`high`
## 关联整改
- Remediation: [../remediation/2026-09-13-document-revision-second-doc-routing-lease.md](../remediation/2026-09-13-document-revision-second-doc-routing-lease.md)
- Short-term: [../ops/short-term/2026-09-13-document-revision-second-doc-routing-lease.md](../ops/short-term/2026-09-13-document-revision-second-doc-routing-lease.md)
- 分支: `fix/document-revision-second-doc-routing-lease`
## 证据
- Evidence root(已归档、本轮勿提交):`.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui11-second-doc-routing-human-lease-failure/`
## 开放问题
- 占槽后真实 UI-11 重跑;本任务保持 `in_progress`,不得标 `done`
- 请求级租约:同用户双窗 ETag 冲突靠 If-Match,不靠租约互斥(同 `holderID` 会续租)
- 卡片展示标题是「系统初步设计方案」(非 prompt 别名「系统设计输入」);E2E 必须按 `doc_proj{n}_prelim_design` 打开
+4 -2
View File
@@ -1,11 +1,11 @@
# 当前焦点
**更新**: 2026-09-13
**production_ready**: no(DOCREV:schema **30**;第九轮 UI-07 transition 中间帧取样;不操作容器)
**production_ready**: no(DOCREV:schema **30**;第十轮 UI-11 路由+lease;不操作容器)
## 焦点
1. **DOCREV-RT**: [E2E 等 overlay CSS transition 结束后再取样](short-term/2026-09-13-document-revision-e2e-overlay-transition.md)(`fix/document-revision-e2e-overlay-transition`:禁止短 sleep / 禁 transition;不重跑 E2E / 不操作容器;仍 `in_progress`)
1. **DOCREV-RT**: [UI-11 点名优先路由 + PATCH 请求级释租](short-term/2026-09-13-document-revision-second-doc-routing-lease.md)(`fix/document-revision-second-doc-routing-lease`:不改 prompt / 不重跑 E2E / 不操作容器;仍 `in_progress`)
2. **CHAT**: [刷新恢复整改已实施](short-term/2026-09-13-chat-history-refresh-remediation.md)(待合 dev + PI E2E 补录)
3. **CI**: `TEST_DATABASE_URL` + 真实 PI E2E 实录;INTENT 发布 blocker 见 conversation-intent short-term
@@ -26,6 +26,7 @@
| Cursor-Auto | document-revision-e2e-save-selector | fix/document-revision-e2e-save-selector | - | document-revision-save-selector | in_progress |
| Cursor-Auto | document-revision-e2e-overlay-selector | fix/document-revision-e2e-overlay-selector | - | document-revision-overlay-selector | in_progress |
| Cursor-Auto | document-revision-e2e-overlay-transition | fix/document-revision-e2e-overlay-transition | - | document-revision-overlay-transition | in_progress |
| Cursor-Auto | document-revision-second-doc-routing-lease | fix/document-revision-second-doc-routing-lease | - | document-revision-second-doc-routing-lease | in_progress |
> 新任务开始前登记。`branch` 必须为 `feature/<slug>` 或 `fix/<slug>`,**禁止** `dev` / `test` / `main`。同一 `mutex` 同时仅一个 `in_progress`。
@@ -69,3 +70,4 @@
| UI-06 保存选择器失败审计 | [../audit/2026-09-13-document-revision-save-selector.md](../audit/2026-09-13-document-revision-save-selector.md) |
| UI-07 纯净阅读取样失败审计 | [../audit/2026-09-13-document-revision-overlay-selector.md](../audit/2026-09-13-document-revision-overlay-selector.md) |
| UI-07 transition 中间帧取样审计 | [../audit/2026-09-13-document-revision-overlay-transition.md](../audit/2026-09-13-document-revision-overlay-transition.md) |
| UI-11 第二份文档路由与 lease 审计 | [../audit/2026-09-13-document-revision-second-doc-routing-lease.md](../audit/2026-09-13-document-revision-second-doc-routing-lease.md) |
@@ -0,0 +1,55 @@
---
title: "UI-11 点名优先路由 + PATCH 请求级释租"
status: in_progress
created: 2026-09-13
updated: 2026-09-13
owner: Cursor-Auto
branch: fix/document-revision-second-doc-routing-lease
worktree: "-"
mutex: document-revision-second-doc-routing-lease
---
# UI-11 点名优先路由 + PATCH 请求级释租
## 目标
第十轮 UI-11 首错:否定句「需求」压过「系统设计输入」,且 PATCH 残留 human lease 使小队 DOC_BUSY。点名优先 + 请求级 defer 释租 + E2E 202 门闩。不得将本任务标 `done`。
## 范围
- `web/src/engine/templateMatch.ts` + 单测
- `internal/server/document_revision_handlers.go` + `document_revision_patch_order_test.go`
- `web/e2e/document-revision-runtime.spec.ts` UI-11 waiter
- 文档:audit / remediation / CURRENT
## 非目标
- 不改 E2E prompt;不改 `SaveDocumentContent` 内部释租
- 不触碰/提交 `.doc/test-reports/assets`;不操作容器;本轮不重跑 E2E
- 不改 FEATURE-MATRIX;不合 `dev`;不 push
## 步骤
1. [x] 认领 CURRENT(mutex `document-revision-second-doc-routing-lease`)
2. [x] 点名优先 +「系统设计输入」别名 + 单测
3. [x] PATCH 已授权后 defer 释租(成功 / 409 / 投影失败)+ 服务端测试
4. [x] UI-11 send 前 `tasks/runs` waiter,断言 202 后再 wait completion
5. [ ] 占槽后真实 UI 重跑(本轮不做)
## 验收标准
- [x] UI-11 原文 / 混合句 → `prelim_design`;当前文档钉住、UI-05 pin、首轮摸底仍 intake
- [x] PATCH 后无 human lease,squad 可获取
- [x] UI-11 非 202 即 fail;`requireInflight` 拒绝同步 busy
- [ ] 真实 UI-11 PASS 后才可将本任务标 `done`
## 相关链接
- Audit: [../../audit/2026-09-13-document-revision-second-doc-routing-lease.md](../../audit/2026-09-13-document-revision-second-doc-routing-lease.md)
- Remediation: [../../remediation/2026-09-13-document-revision-second-doc-routing-lease.md](../../remediation/2026-09-13-document-revision-second-doc-routing-lease.md)
- Evidence: [../../test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui11-second-doc-routing-human-lease-failure](../../test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui11-second-doc-routing-human-lease-failure)
## 证据 / 备注
- Prompt 别名「系统设计输入」≠ 卡片标题。真实 UI / 模板 / squad 标题均为「系统初步设计方案」。
- UI-11 打开第二份文档必须用已注册 `doc_proj{n}_prelim_design`(`data-doc-id`),禁止 `/设计输入|系统设计/`:既匹配不到真实标题,也容易误命中其他设计类文案或同标题模板槽。
@@ -0,0 +1,43 @@
---
title: "UI-11 点名优先路由 + PATCH 请求级释租"
status: in_progress
created: 2026-09-13
updated: 2026-09-13
---
# UI-11 点名优先路由 + PATCH 请求级释租
## 背景
链到 [../audit/2026-09-13-document-revision-second-doc-routing-lease.md](../audit/2026-09-13-document-revision-second-doc-routing-lease.md)。第十轮 UI-11 首错:否定句里的「需求」压过「系统设计输入」,且 PATCH 残留 15min human lease 使小队 DOC_BUSY。
## 目标
1. 明确点名交付物优先于否定背景关键词;`prelim_design` 增加「系统设计输入」别名。
2. PATCH 在已授权获取 human lease 后 `defer` 释放(成功 / 409 / 投影失败)。
3. UI-11 E2E 在 send 前建立 `tasks/runs` waiter,断言 HTTP 202 后再等 completion。
## 分波次计划
| 波次 | 内容 | 验收 |
|------|------|------|
| Wave 0 | `NAMED_DELIVERABLE_ALIASES` + `resolveTaskKeyFromMessage` / `resolveTaskTemplateForMessage` 点名优先 | UI-11 原文 → `prelim_design`;pin / UI-05 / 首轮摸底仍 intake |
| Wave 0 | PATCH handler `defer ReleaseLease`(不持 `e.mu`) | 成功 / 409 / 投影失败后无 human lease,squad 可 `AcquireLease` |
| Wave 0 | UI-11 send 前 waiter + 断言 202 | DOC_BUSY 不是 202;`requireInflight` 拒绝同步 busy completed |
## 取舍
- **请求级租约**:本 UI 无长会话 heartbeat。同用户双窗 ETag 冲突靠 If-Match,不靠租约互斥(同 `holderID` 会续租)。持有期间他人/squad 仍 busy。`AssertHumanCanEdit` 失败路径本来就不持 lease。
- 本轮不改 `SaveDocumentContent` 内部释租;不改 E2E prompt。
## 验收标准
- [x] 点名优先 +「系统设计输入」别名 + `templateMatch` 单测
- [x] PATCH defer 释租 + 服务端测试(成功 / 409 / 投影失败)
- [x] UI-11 202 门闩;不改 prompt
- [ ] 占槽后真实 UI-11 PASS 才可将本任务标 `done`
## 相关链接
- Audit: [../audit/2026-09-13-document-revision-second-doc-routing-lease.md](../audit/2026-09-13-document-revision-second-doc-routing-lease.md)
- Short-term: [../ops/short-term/2026-09-13-document-revision-second-doc-routing-lease.md](../ops/short-term/2026-09-13-document-revision-second-doc-routing-lease.md)
@@ -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)
}
}
+38 -1
View File
@@ -316,6 +316,30 @@ async function openWorkflowDocByLabel(
return { title: title || docId, docId };
}
/** 按已注册 doc_proj{n}_{taskKey} 打开,避开同标题模板槽与宽泛文案误命中。 */
async function openWorkflowDocByControlledTaskKey(
page: Page,
taskKey: string,
): Promise<{ title: string; docId: string }> {
if (!/^[a-z][a-z0-9_]*$/.test(taskKey)) fail(`invalid controlled taskKey: ${taskKey}`);
const trigger = page.getByTestId('doc-selector-trigger');
await expect(trigger).toBeVisible({ timeout: 15_000 });
await trigger.click();
const item = page
.locator(`.dropdown-menu button[data-doc-id^="doc_proj"][data-doc-id$="_${taskKey}"]`)
.first();
if ((await item.count()) === 0) {
fail(`workflow menu missing registered doc_proj*_${taskKey}`);
}
const docId = (await item.getAttribute('data-doc-id')) || '';
const title = (await item.getAttribute('data-doc-title')) || '';
if (!new RegExp(`^doc_proj\\d+_${taskKey}$`).test(docId)) {
fail(`expected doc_proj{n}_${taskKey}, got ${JSON.stringify(docId)}`);
}
await item.click();
return { title: title || docId, docId };
}
async function openWorkflowDocById(page: Page, docId: string): Promise<void> {
if (!docId) fail('openWorkflowDocById requires docId');
const trigger = page.getByTestId('doc-selector-trigger');
@@ -1042,9 +1066,22 @@ test.describe.serial('document-revision-runtime UI acceptance', () => {
logs = [];
const prompt =
'请另行生成一份《系统设计输入》受控文档(第二份),不要覆盖上一份需求文档,并给出可打开的交付物。';
const taskRunWait = waitForTasksRunsPost(page);
await sendChat(page, prompt);
const taskRes = await taskRunWait;
if (taskRes.status() !== 202) {
fail(`UI-11 tasks/runs must be HTTP 202, got ${taskRes.status()}`);
}
const taskBody = taskRes.request().postDataJSON() as { task_key?: string };
if (taskBody.task_key !== 'prelim_design') {
fail(`UI-11 task_key must be prelim_design, got ${JSON.stringify(taskBody.task_key)}`);
}
await waitForSquadCompletion(page, '系统设计输入');
const second = await openWorkflowDocByLabel(page, /设计输入|系统设计/);
// Prompt alias is 系统设计输入; card title is 系统初步设计方案; open by registered doc_proj id.
const second = await openWorkflowDocByControlledTaskKey(page, 'prelim_design');
if (second.title !== '系统初步设计方案') {
fail(`UI-11 prelim_design card title must be 系统初步设计方案, got ${JSON.stringify(second.title)}`);
}
secondDocTitle = second.title;
secondDocId = second.docId;
await expect(page.getByTestId('active-document-pane')).toHaveAttribute('data-doc-id', secondDocId);
+8
View File
@@ -32,6 +32,14 @@ test('createProjectTemplateDeliverables: 必须精确生成 7 份全生命周期
const actualTaskKeys = result.deliverables.map((d) => d.taskKey);
assert.deepStrictEqual(actualTaskKeys, expectedTaskKeys);
const prelim = result.deliverables.find((d) => d.taskKey === 'prelim_design');
assert.ok(prelim, 'prelim_design template must exist');
assert.strictEqual(prelim.title, '系统初步设计方案');
assert.strictEqual(
STANDARD_WORKFLOW_TEMPLATES.find((t) => t.taskKey === 'prelim_design')?.title,
'系统初步设计方案',
);
// 2. 验证阶段覆盖 (V0.1, V0.5, V1.0)
const stages = new Set(result.deliverables.map((d) => d.stage));
assert.ok(stages.has('V0.1'));
+20
View File
@@ -203,3 +203,23 @@ test('no active doc does not invent a pin', () => {
assert.equal(resolved?.docId, undefined);
assert.equal(resolved?.pinnedToActiveDoc, undefined);
});
const UI11_SECOND_DOC_PROMPT =
'请另行生成一份《系统设计输入》受控文档(第二份),不要覆盖上一份需求文档,并给出可打开的交付物。';
test('UI-11 named 系统设计输入 beats negated 需求 background', () => {
assert.equal(resolveTaskKeyFromMessage(UI11_SECOND_DOC_PROMPT), 'prelim_design');
assert.equal(resolveTaskTemplateForMessage(UI11_SECOND_DOC_PROMPT, bothPool, activeIntake)?.taskKey, 'prelim_design');
});
test('mixed generate-design + do-not-cover-requirements stays prelim_design', () => {
const text = '请生成《系统设计输入》,不要覆盖上一份需求文档';
assert.equal(resolveTaskKeyFromMessage(text), 'prelim_design');
});
test('current-doc pin still wins when no other deliverable is named', () => {
const text = '请改写当前受控文档中的风险管理章节,不要覆盖上一份需求文档';
const resolved = resolveTaskTemplateForMessage(text, bothPool, activeIntake);
assert.equal(resolved?.taskKey, 'intake');
assert.equal(resolved?.pinnedToActiveDoc, true);
});
+20 -6
View File
@@ -12,7 +12,7 @@ const NAMED_DELIVERABLE_ALIASES: { taskKey: string; patterns: RegExp[] }[] = [
{ taskKey: 'research', patterns: [/调研报告/, /竞品调研/] },
{ taskKey: 'feasibility_reg', patterns: [/注册路径可行性/, /法规可行性评估/, /注册可行性评估/] },
{ taskKey: 'feasibility_dev', patterns: [/设计开发可行性/, /研发可行性评估/] },
{ taskKey: 'prelim_design', patterns: [/初步设计说明书/, /系统设计规格/] },
{ taskKey: 'prelim_design', patterns: [/系统设计输入/, /初步设计说明书/, /系统设计规格/] },
{ taskKey: 'cdo_quote', patterns: [/CDO报价/, /报价核算表/] },
];
@@ -66,11 +66,7 @@ function explicitNamedDeliverableTaskKey(
for (const d of titled) {
if (text.includes(d.title)) return d.taskKey;
}
for (const alias of NAMED_DELIVERABLE_ALIASES) {
if (alias.taskKey === excludeTaskKey) continue;
if (alias.patterns.some((p) => p.test(text))) return alias.taskKey;
}
return undefined;
return namedAliasTaskKey(text, excludeTaskKey);
}
/**
@@ -118,12 +114,24 @@ function pickRequirementsTask(text: string, pool: DeliverableItem[]): Deliverabl
return undefined;
}
function namedAliasTaskKey(text: string, excludeTaskKey?: string): string | undefined {
for (const alias of NAMED_DELIVERABLE_ALIASES) {
if (alias.taskKey === excludeTaskKey) continue;
if (alias.patterns.some((p) => p.test(text))) return alias.taskKey;
}
return undefined;
}
/** 根据用户输入解析应触发的文档任务键(不依赖交付物池顺序)。 */
export function resolveTaskKeyFromMessage(text: string): string | undefined {
const normalized = text.trim();
// 模板名只是操作对象,不代表用户授权写入。只读动词优先;只有明确写动作才走任务 API。
if (!hasDocumentWriteIntent(normalized)) return undefined;
// 明确点名的交付物优先于否定背景/旧文档里的「需求」等关键词。
const named = namedAliasTaskKey(normalized);
if (named) return named;
if (normalized.includes('需求') || normalized.includes('摸底') || normalized.includes('用户') || normalized.includes('首轮')) {
return resolveRequirementsTaskKey(normalized);
}
@@ -185,6 +193,12 @@ export function resolveTaskTemplateForMessage(
}
}
const named = explicitNamedDeliverableTaskKey(text, deliverables);
if (named) {
const template = matchTemplateDeliverableByKey(named, deliverables, text);
return { taskKey: named, template };
}
const taskKey = resolveTaskKeyFromMessage(text);
if (!taskKey) return undefined;
const template = matchTemplateDeliverable(text, deliverables);