merge: dev (08bf615 cache race fix)

This commit is contained in:
weichen
2026-09-14 03:36:17 +00:00
8 changed files with 340 additions and 49 deletions
@@ -25,6 +25,7 @@ status: done
- 终审:`--live --environment dev` 必须同时识别 DSN `:5433` 与库名 `agent_system_dev`,且 `--host-data-root` 为 `data/dev`;空 dsnEnv/pathEnv 不放行;stored work_dir 必须在 `--stored-data-root` 下并映射,禁止沿用其他绝对路径
- 工程门禁(worktree):`go vet`、`go test -short`、`web npm test`、`web npm run build` PASS
- UI follow-up:`shouldLoadControlledContent` / `hydrateControlledDocumentContent` 与 `revisionToolbarMountMode` 的 server-backed 资格对齐;`doc-001` 等本地 legacy/mock 不再请求 content 与 revision-state。`doc_proj*` 仍 hydrate;已注册 `doc-free`/freeform 与 template preview 不走受控 content 请求
- UI 竞态 follow-up:`planControlledContentInvalidation` 统一失效。`list_sync` 不再清空 cache/ready(硬刷新后晚到列表不能把刚成功的 hydrate 打回 loading);`post_task` 清空活动文档时必须 bump `hydrateResumeKey`;编辑中保护草稿并 `requestHydrationResume`。`setDeliverables` updater 不再嵌套 setCache/setReady
## 未完成
@@ -53,6 +54,7 @@ status: done
| `internal/docrevision/reconcile.go` | 唯一备份目录;回报 output/head/meta 三条路径 |
| `cmd/document-content-reconcile/safety.go` | live 正向确认 dev DSN 与 `data/dev` |
| `web/src/lib/controlledDocHydrate.ts` | 本地 legacy/mock 不 hydrate;与 toolbar 资格一致 |
| `web/src/lib/controlledContentInvalidation.ts` | 受控正文失效/重水合规划;list_sync 保 hydrate,post_task 必重拉 |
| `internal/docgov/engine.go` | Mark 预加载/幂等补偿 |
| `cmd/document-content-reconcile/` | 默认拒绝共享;live 硬门闩 + 路径映射 |
| `internal/server/project_doc_handlers.go` | drift 返回 `DOCUMENT_CONTENT_PROJECTION_DRIFT` |
@@ -66,6 +68,7 @@ go test ./internal/docrevision/ ./internal/docgov/ ./internal/server/ ./internal
-run 'TestCanonicalize|TestClassify|TestReconcile|TestCommitMigrationRepair|TestMapStored|TestInspectProjection|TestMigration032|TestReadDocumentContent_Typed|TestWriteControlledContentError|TestServeLedgerHead|TestValidateCLI'
cd web && npm test && npm run build
# 重点:controlledDocHydrate 拒绝 doc-001 / template / registered freeform 发 content+revision
# 重点:controlledContentInvalidation 覆盖 hydrate→晚到sync、post_task 重水合、编辑/PATCH 防旧 GET
```
隔离 PG(禁止 5433/5434/5435 与 `agent_system_dev|test|main`):
@@ -41,6 +41,7 @@ mutex: docgov
7. [x] follow-up:旧 head 正文备份、备份不可覆盖、原子写目录 fsync;单提交后 done
8. [x] 终审 3 项:live 正向确认 :5433+agent_system_dev+data/dev;备份三件套+唯一目录/O_EXCL+全路径;rename 后目录 Sync 兼容处理
9. [x] UI clean run:doc-001 等本地 legacy 不发 content/revision;hydrate 资格对齐 toolbar server-backed
10. [x] UI 竞态:晚到 list sync 不得擦掉成功 hydrate;清空 ready/cache 必须重水合;编辑/PATCH 基线受保护
## 验收标准
@@ -73,7 +74,7 @@ mutex: docgov
- 审查修补后再次 `go vet ./...` PASS、`go test -short ./...` PASS
- focused:canonical/classify/reconcile/CLI live 门闩/path map/typed content/drift 冲突 PASS
- PG: 本机 `TEST_DATABASE_URL` 未设置;`TestReconcile_LegacyRepair_PG` skip。已去掉 `-short` 无条件跳过,隔离库就绪后即使 `-short` 也会跑。禁止 5433/5434/5435
- `web npm test` PASS(tsx 337 + vitest 30)
- `web npm test` PASS(tsx 349 + vitest 30;含 controlledContentInvalidation 时序)
- `web npm run build` PASS
- follow-up:`*.head.md` 保存旧 head 正文;已有备份拒绝覆盖;`WriteFileAtomic` rename/link 后 `SyncDir`
- 未执行:`env-service.sh`、共享 Docker、`data/dev` apply、真实 UI、隔离 PG apply 验证
+1 -1
View File
@@ -21,7 +21,7 @@
| 工作流治理 | 三正式工作流 + 阶段门禁 | done | [remediation/three-workflows](../remediation/2026-09-12-three-workflows-strict-governance-remediation.md);[test 2026-09-12](../test-reports/2026-09-12-three-workflows-governance-remediation-test-report.md) | Phase2 设计器 partial | `workflow` |
| 纯软件项目自举 | 软件项目创建、Bootstrap 基线、Delivery 迭代与受控工程交接 | planned | [详细设计](../system-design/2026-09-14-software-project-bootstrap-workflow.md);[现状调研](../ops/research/2026-09-14-software-project-bootstrap-current-state.md);[验收计划](../ops/checklists/2026-09-14-software-project-bootstrap-acceptance.md) | 当前仅完成方案;目录/模板/执行器/创建向导/真实 PI UI 均未实施。受控代码执行单列后续 Epic | `workflow-design` |
| IAM / 鉴权 | 项目级 API 鉴权 + RBAC | partial | [audit/authorization](../audit/authorization-matrix-v2.md);[刷新恢复专项审计](../audit/2026-09-13-chat-history-refresh-disappears.md) | 历史读取端点补统一会话 owner + 阶段授权;旧 API 410 标注(BUG-11 partial) | `authz` |
| 文档治理 | 法定文档生命周期 + 租约 + 受控正文读取 | partial | [test docgov](../test-reports/2026-09-08-docgov-lifecycle-and-claiming-framework-test-report.md);[项目 143 正文 404 审计](../audit/2026-09-14-project143-controlled-content-404.md);[受控正文真实 UI 验收报告](../test-reports/2026-09-14-controlled-content-ui-acceptance.md);[UI-06 缓存竞争审计](../audit/2026-09-14-document-revision-refresh-cache-race.md) | 生命周期/租约保留。项目 143 纯 UI 回归(clean-run3)已全部 PASS;新项目主阶段执行至 UI-05b PASS,受阻于 UI-06 前端缓存竞争死锁(FAIL),待 Cursor 修复。仍标 partial | `docgov` |
| 文档治理 | 法定文档生命周期 + 租约 + 受控正文读取 | partial | [test docgov](../test-reports/2026-09-08-docgov-lifecycle-and-claiming-framework-test-report.md);[项目 143 正文 404 审计](../audit/2026-09-14-project143-controlled-content-404.md);[受控正文真实 UI 验收报告](../test-reports/2026-09-14-controlled-content-ui-acceptance.md);[UI-06 缓存竞争审计](../audit/2026-09-14-document-revision-refresh-cache-race.md);[整改](../remediation/2026-09-14-controlled-content-canonical-hash-remediation.md) | 生命周期/租约保留。W1–W6 + 审查修补 + UI hydrate 资格 + 晚到 list sync 不再擦掉成功 hydrate 已合入 dev。项目 143 纯 UI 回归(clean-run3)已全 PASS;新项目主阶段正待验证修复后的全链路。仍标 partial | `docgov` |
| 文档版本与修改认知 | 智能体版本边界、人工修订标记、任意版本比较、变更报告与负责人通知 | partial | [管理测试](../test-reports/2026-09-13-document-revision-management-test-report.md);[运行时 UI Round20](../test-reports/2026-09-14-document-revision-runtime-ui-test-report.md);[运行时审计](../audit/2026-09-13-document-revision-runtime-404.md);[ADR](../adr/2026-09-13-document-version-boundary.md) | **运行时 UI PASS**(6159 / V3/r2)。仍 `partial`:change report=`fallback`(真实 diff+lineage,非完整 PI 叙事)。系统级 `CHAT-INTENT` 见 gap-matrix | `docgov` |
| 前端工作台 | 三栏 + 块文档 + SSE | partial | [test workbench](../test-reports/2026-09-08-three-column-workbench-and-block-document-test-report.md);[现场专项审计](../audit/2026-09-13-conversation-intent-runtime-state.md) | 错误卡片/任务进度/文档 busy 状态刷新恢复与人工抽检待完成 | `web` |
| PI Runner / 配置链 | pi-agent + MiniMax 真实链路 | done | [test piagent](../test-reports/2026-09-13-codex-audit-p0-remediation-test-report.md);[container-env](../test-reports/2026-09-13-container-env-configuration-test-report.md) | — | `pi-agent` |
+51 -47
View File
@@ -68,6 +68,12 @@ import {
resolveCanonicalDocId,
shouldLoadControlledContent,
} from './lib/controlledDocHydrate';
import {
controlledHydrateCandidateIds,
dropRecordKeys,
planControlledContentInvalidation,
type ControlledInvalidationPlan,
} from './lib/controlledContentInvalidation';
import { applyLeaseUpdates, sameLease } from './lib/deliverableLeaseSync';
import { ControlledContentRequestError } from './api/projectDocuments';
import {
@@ -726,6 +732,8 @@ export default function App() {
if (targetProjId === 3) return 'doc-cusa-feasibility-reg';
return deliverables[0]?.docId || 'doc-001';
});
const activeDocIdRef = useRef(activeDocId);
activeDocIdRef.current = activeDocId;
const [docRevOverlayMode, setDocRevOverlayMode] = useState<OverlayMode>('manual');
const [docRevOverlay, setDocRevOverlay] = useState<DiffResult | null>(null);
const [docRevConflict, setDocRevConflict] = useState<string | null>(null);
@@ -1082,6 +1090,18 @@ export default function App() {
});
const [controlledContentReady, setControlledContentReady] = useState<Record<string, boolean>>({});
const applyControlledInvalidationPlan = useCallback((plan: ControlledInvalidationPlan) => {
if (plan.dropIds.length > 0) {
setDocCache((cache) => dropRecordKeys(cache, plan.dropIds));
setControlledContentReady((ready) => dropRecordKeys(ready, plan.dropIds));
}
if (plan.requestResume) {
editorHydrationGateRef.current.requestHydrationResume();
}
if (plan.rehydrateActive) {
setHydrateResumeKey((k) => k + 1);
}
}, []);
const [docLoadErrors, setDocLoadErrors] = useState<Record<string, boolean>>({});
const [docLoadErrorInfo, setDocLoadErrorInfo] = useState<
Record<string, { code?: string; message: string; correlationId?: string }>
@@ -1736,34 +1756,20 @@ export default function App() {
try {
const workflowDocs = await listProjectWorkflowDocuments(currentProjectId).catch(() => []);
if (!active || workflowDocs.length === 0) return;
setDeliverables((prev) => {
const merged = mergeWorkflowDocumentsFromServer(
prev,
workflowDocs,
currentProjectId,
currentUser,
);
const serverBackedIds = merged
.filter((d) => d.projectId === currentProjectId && shouldLoadControlledContent(d))
.map((d) => d.docId);
if (serverBackedIds.length > 0) {
setDocCache((cache) => {
const next = { ...cache };
serverBackedIds.forEach((docId) => {
delete next[docId];
});
return next;
});
setControlledContentReady((ready) => {
const next = { ...ready };
serverBackedIds.forEach((docId) => {
delete next[docId];
});
return next;
});
}
return merged;
});
setDeliverables((prev) =>
mergeWorkflowDocumentsFromServer(prev, workflowDocs, currentProjectId, currentUser),
);
applyControlledInvalidationPlan(
planControlledContentInvalidation({
reason: 'list_sync',
candidateIds: controlledHydrateCandidateIds(
deliverablesRef.current,
currentProjectId,
),
activeDocId: activeDocIdRef.current,
editorOpen: editorHydrationGateRef.current.isEditorOpen(),
}),
);
} catch (e) {
console.warn('[deliverables] 工作流受控文档同步失败:', e);
}
@@ -1772,7 +1778,15 @@ export default function App() {
return () => {
active = false;
};
}, [session, sessionValidated, currentProjectId, currentUser.id, currentUser.name, workflowDocsEpoch]);
}, [
session,
sessionValidated,
currentProjectId,
currentUser.id,
currentUser.name,
workflowDocsEpoch,
applyControlledInvalidationPlan,
]);
// 文档占用是多人共享运行态:定期从服务端同步,避免其他标签页/成员开始编辑后本页无感知。
useEffect(() => {
@@ -2623,24 +2637,14 @@ export default function App() {
onMerged: (next) => {
deliverablesRef.current = next;
setDeliverables(next);
const serverBackedIds = next
.filter((d) => d.projectId === task.projectId && shouldLoadControlledContent(d))
.map((d) => d.docId);
if (serverBackedIds.length === 0) return;
setDocCache((cache) => {
const cleared = { ...cache };
serverBackedIds.forEach((docId) => {
delete cleared[docId];
});
return cleared;
});
setControlledContentReady((ready) => {
const cleared = { ...ready };
serverBackedIds.forEach((docId) => {
delete cleared[docId];
});
return cleared;
});
applyControlledInvalidationPlan(
planControlledContentInvalidation({
reason: 'post_task',
candidateIds: controlledHydrateCandidateIds(next, task.projectId),
activeDocId: activeDocIdRef.current,
editorOpen: editorHydrationGateRef.current.isEditorOpen(),
}),
);
},
});
if (
@@ -0,0 +1,180 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
applyControlledContentInvalidation,
controlledHydrateCandidateIds,
dropRecordKeys,
planControlledContentInvalidation,
resolveControlledContentView,
} from './controlledContentInvalidation';
import { shouldLoadControlledContent } from './controlledDocHydrate';
import { createEditorHydrationGate } from './editorHydrationGate';
import type { DeliverableItem } from '../types/workbench';
const DOC = 'doc_proj6172_intake';
const serverDoc: DeliverableItem = {
docId: DOC,
projectId: 6172,
taskKey: 'intake',
title: '用户初始需求收集表',
stage: 'V0.5',
category: 'workflow',
creatorId: 1,
creatorName: '张工',
isFixedWorkflow: true,
isTemplateOnly: false,
lifecycleState: 'drafting',
isClaimed: true,
author: '张工',
reviewer: '李主管',
approver: '王总监',
updatedAt: '2026-09-14 11:20',
governanceMode: 'controlled',
};
function viewOf(cache: Record<string, string>, ready: Record<string, boolean>) {
return resolveControlledContentView({
needsServerContent: true,
hasCache: Boolean(cache[DOC]),
ready: Boolean(ready[DOC]),
});
}
describe('controlledContentInvalidation', () => {
it('hydrate success then late list_sync keeps cache/ready and does not bump resume', () => {
let cache: Record<string, string> = { [DOC]: 'UI06-mu0o5hbq' };
let ready: Record<string, boolean> = { [DOC]: true };
let resumeKey = 0;
const plan = planControlledContentInvalidation({
reason: 'list_sync',
candidateIds: [DOC, 'doc_proj6172_research'],
activeDocId: DOC,
editorOpen: false,
});
const next = applyControlledContentInvalidation({ cache, ready }, plan);
cache = next.cache;
ready = next.ready;
if (plan.rehydrateActive) resumeKey += 1;
assert.deepEqual(plan, { dropIds: [], rehydrateActive: false, requestResume: false });
assert.equal(cache[DOC], 'UI06-mu0o5hbq');
assert.equal(ready[DOC], true);
assert.equal(resumeKey, 0);
assert.equal(viewOf(cache, ready), 'content');
});
it('post_task invalidation drops active cache and requires a new hydrate epoch', () => {
let cache: Record<string, string> = { [DOC]: 'stale-before-agent' };
let ready: Record<string, boolean> = { [DOC]: true };
let resumeKey = 0;
const plan = planControlledContentInvalidation({
reason: 'post_task',
candidateIds: [DOC],
activeDocId: DOC,
editorOpen: false,
});
assert.deepEqual(plan.dropIds, [DOC]);
assert.equal(plan.rehydrateActive, true);
const next = applyControlledContentInvalidation({ cache, ready }, plan);
cache = next.cache;
ready = next.ready;
if (plan.rehydrateActive) resumeKey += 1;
assert.equal(cache[DOC], undefined);
assert.equal(ready[DOC], undefined);
assert.equal(resumeKey, 1);
assert.equal(viewOf(cache, ready), 'loading');
cache = { ...cache, [DOC]: 'agent-output' };
ready = { ...ready, [DOC]: true };
assert.equal(viewOf(cache, ready), 'content');
});
it('editor open / save epoch keeps draft and rejects a stale GET after PATCH', () => {
const gate = createEditorHydrationGate();
gate.setActiveDocId(DOC);
const stale = gate.beginHydration(DOC);
assert.ok(stale);
let cache: Record<string, string> = { [DOC]: 'draft-in-editor' };
let ready: Record<string, boolean> = { [DOC]: true };
gate.setEditorOpen(true);
const listPlan = planControlledContentInvalidation({
reason: 'list_sync',
candidateIds: [DOC],
activeDocId: DOC,
editorOpen: true,
});
const taskPlan = planControlledContentInvalidation({
reason: 'post_task',
candidateIds: [DOC],
activeDocId: DOC,
editorOpen: true,
});
const afterList = applyControlledContentInvalidation({ cache, ready }, listPlan);
const afterTask = applyControlledContentInvalidation(afterList, taskPlan);
cache = afterTask.cache;
ready = afterTask.ready;
assert.equal(listPlan.dropIds.length, 0);
assert.equal(taskPlan.dropIds.length, 0);
assert.equal(taskPlan.rehydrateActive, false);
assert.equal(taskPlan.requestResume, true);
assert.equal(cache[DOC], 'draft-in-editor');
assert.equal(ready[DOC], true);
gate.markSaved();
cache = { ...cache, [DOC]: 'patched-r1' };
assert.equal(gate.decide(stale).apply, false);
assert.equal(gate.decide(stale).reason, 'editor_open');
gate.requestHydrationResume();
gate.setEditorOpen(false);
assert.equal(gate.decide(stale).reason, 'superseded_by_save');
assert.equal(gate.takeHydrationResume(), true);
assert.equal(cache[DOC], 'patched-r1');
});
it('never drops the active doc without signaling rehydrate', () => {
const plan = planControlledContentInvalidation({
reason: 'post_task',
candidateIds: [DOC, 'doc_proj6172_research'],
activeDocId: DOC,
editorOpen: false,
});
assert.ok(plan.dropIds.includes(DOC));
assert.equal(plan.rehydrateActive, true);
});
it('candidate helper keeps template / doc-001 / freeform / upload out of wipe lists', () => {
const docs: DeliverableItem[] = [
serverDoc,
{ ...serverDoc, docId: 'doc-001', projectId: 1, taskKey: 'feasibility_reg' },
{ ...serverDoc, docId: 'doc-p143-intake', isTemplateOnly: true, taskKey: 'intake' },
{
...serverDoc,
docId: 'doc-free-1',
governanceMode: 'freeform',
taskKey: 'freeform_doc',
isFixedWorkflow: false,
},
{ ...serverDoc, docId: 'doc-upload-1', sourceType: 'uploaded_file' },
];
assert.deepEqual(controlledHydrateCandidateIds(docs, 6172), [DOC]);
assert.equal(shouldLoadControlledContent(docs[1]), false);
assert.equal(shouldLoadControlledContent(docs[2]), false);
assert.equal(shouldLoadControlledContent(docs[3]), false);
assert.equal(shouldLoadControlledContent(docs[4]), false);
});
it('dropRecordKeys is a no-op when ids are absent', () => {
const ready = { other: true };
assert.equal(dropRecordKeys(ready, [DOC]), ready);
});
});
@@ -0,0 +1,88 @@
import { shouldLoadControlledContent } from './controlledDocHydrate';
import type { DeliverableItem } from '../types/workbench';
/**
* 受控正文 cache / ready 的统一失效规划。
*
* 任何清空必须经过这里:晚到的列表同步不得擦掉刚成功的 hydrate;
* 若清空了当前活动文档,调用方必须 bump hydrate epoch 才能重新拉正文。
*/
export type ControlledInvalidationReason = 'list_sync' | 'post_task';
export type ControlledInvalidationInput = {
reason: ControlledInvalidationReason;
candidateIds: readonly string[];
activeDocId: string | undefined;
editorOpen: boolean;
};
export type ControlledInvalidationPlan = {
dropIds: string[];
rehydrateActive: boolean;
requestResume: boolean;
};
export function controlledHydrateCandidateIds(
docs: DeliverableItem[],
projectId?: number,
): string[] {
return docs
.filter(
(d) =>
(projectId == null || d.projectId === projectId) && shouldLoadControlledContent(d),
)
.map((d) => d.docId);
}
export function planControlledContentInvalidation(
input: ControlledInvalidationInput,
): ControlledInvalidationPlan {
const active = (input.activeDocId || '').trim();
const candidates = [...new Set(input.candidateIds.filter((id) => !!id?.trim()))];
if (input.reason === 'list_sync') {
return { dropIds: [], rehydrateActive: false, requestResume: false };
}
const dropIds = candidates.filter((id) => !(input.editorOpen && id === active));
const rehydrateActive = !!active && dropIds.includes(active);
const requestResume = input.editorOpen && !!active && candidates.includes(active);
return { dropIds, rehydrateActive, requestResume };
}
export function dropRecordKeys<T>(
map: Record<string, T>,
dropIds: readonly string[],
): Record<string, T> {
if (dropIds.length === 0) return map;
let changed = false;
const next = { ...map };
for (const id of dropIds) {
if (id in next) {
delete next[id];
changed = true;
}
}
return changed ? next : map;
}
export function applyControlledContentInvalidation<TCache>(
state: { cache: Record<string, TCache>; ready: Record<string, boolean> },
plan: ControlledInvalidationPlan,
): { cache: Record<string, TCache>; ready: Record<string, boolean> } {
return {
cache: dropRecordKeys(state.cache, plan.dropIds),
ready: dropRecordKeys(state.ready, plan.dropIds),
};
}
/** 与 App docAST 受控分支一致:ready 被清且不再 hydrate 就会永久停在 loading。 */
export function resolveControlledContentView(input: {
needsServerContent: boolean;
hasCache: boolean;
ready: boolean;
}): 'content' | 'loading' | 'local' {
if (!input.needsServerContent) return 'local';
if (input.hasCache && input.ready) return 'content';
return 'loading';
}
+10
View File
@@ -128,6 +128,16 @@ describe('createEditorHydrationGate', () => {
assert.equal(gate.takeHydrationResume(), true);
});
it('requestHydrationResume is consumed once after a protected edit session', () => {
const gate = createEditorHydrationGate();
gate.setActiveDocId('doc_a');
gate.setEditorOpen(true);
gate.requestHydrationResume();
gate.setEditorOpen(false);
assert.equal(gate.takeHydrationResume(), true);
assert.equal(gate.takeHydrationResume(), false);
});
it('late hydrate after a successful PATCH does not roll back the new baseline', () => {
const gate = createEditorHydrationGate();
gate.setActiveDocId('doc_a');
+5
View File
@@ -59,6 +59,8 @@ export type EditorHydrationGate = {
* so a first-load skeleton is not left forever (effect deps may not change).
*/
takeHydrationResume: () => boolean;
/** 列表/任务同步在编辑中保护了活动文档时,关闭编辑器后补一次 hydrate。 */
requestHydrationResume: () => void;
};
export function createEditorHydrationGate(): EditorHydrationGate {
@@ -107,5 +109,8 @@ export function createEditorHydrationGate(): EditorHydrationGate {
blockedNeedsResume = false;
return true;
},
requestHydrationResume() {
blockedNeedsResume = true;
},
};
}