Files
writer-work-flow/memory/gotchas.md
Yaojia Wang 5fb7bfb1de feat: M3 — 伏笔账本 + 节奏引擎 + 大纲(含并发记账 bugfix)
- 伏笔账本:纯函数状态机(OPEN/PARTIAL/CLOSED/OVERDUE) + ForeshadowLedger repo;验收后到期扫描(BackgroundTask 自建 session 置 OVERDUE);登记/状态变更端点
- 节奏 + 三审齐:foreshadow-analyst + pace-checker 并入 LangGraph 并行审(REVIEW_SPECS),collect 分列落 chapter_reviews(conflicts/foreshadow_sug/pace),review SSE 加 foreshadow/pace 事件
- 大纲:outliner Agent 产 OutlineResult(含 foreshadow_windows),POST /outline 逐章 upsert outline 表;GET /foreshadow?status= 看板
- 前端:伏笔四泳道看板(OVERDUE 琥珀) + 大纲编辑器(窗口徽标) + 节奏节拍图(▁▃▅) + 审稿页消费 foreshadow/pace SSE
- bugfix(T3.8):并行三审共用请求 session 记账触发 'Session is already flushing' → foreshadow/pace 静默丢失;SqlAlchemyLedgerSink.record 改 add-only(靠端点/事务 commit),加并发回归测试
- M3 E2E:真实 DB + mock 网关零 token 走通 埋设→进展→验收后扫描 OVERDUE→看板 + 大纲含窗口 + 三审齐 SSE/留痕;E2E 暴露并钉住上述 bug
- 门禁绿:mypy 111 / pytest 228(0 xfailed) / alembic 无漂移;前端 gen:api/lint/tsc/vitest 69/build
2026-06-18 14:21:17 +02:00

51 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 踩坑与约定append-only
> 实现中发现的坑、易错点、约定俗成——让兄弟 agent 不重复踩。一条一项,最新在最上。
> 只记**非显而易见**的;规格/CLAUDE.md 已写的别重复。
格式:`- [date] @skill <坑/约定> — 缘由 + 怎么做`
---
- [2026-06-18] @llm **`SqlAlchemyLedgerSink.record` 改 add-onlyT3.8 修,去 `await flush()`**:原 add+flush 在并行审里 flush 重入炸(见下条)。改为只 `session.add(row)`——`add()` 同步不让步→并行协程不交错;持久化靠端点/事务 `commit()`(自动 flush。draft/review/accept/outline 端点末尾均已 commit记账不丢。**凡新增「并行用网关产 usage」的路径sink 必须保持 add-only不得在并行段 `await flush`。** 旧 gotcha「record 只 flush 不 commit」措辞已过时——现为「add-onlycommit 归调用方」commit 仍会 flush故记账语义不变
- [2026-06-18] @qa/@llm **并行审记账撞 sessionM3 真 bugT3.7 暴露→T3.8 修)**:三审同 LangGraph superstep 并行,各自 `gateway.run()`→共用**请求 session** 的 `SqlAlchemyLedgerSink.record`(`session.add`+`await session.flush()`)。`AsyncSession` 非并发安全→第二/三审 flush 撞 `Session is already flushing`→被 `run_review` 失败隔离吞成 `incomplete`→**foreshadow/pace 静默丢失**SSE 无事件、`chapter_reviews.foreshadow_sug`/`pace` 列空、日志 `review_node_incomplete error='Session is already flushing'`。M2 单审未触发。根因=并行路径里有 `await flush()`(唯一 await 的 DB-IO。修向审稿期记账避免并发 flushadd-only 靠端点 commit / 或缓冲后 collect 串行落 / 或并行审记账用独立 session
- [2026-06-18] @qa **E2E 验「验收后 BackgroundTask 扫描」时序**httpx `ASGITransport``await client.post(...)` 会等 ASGI app 协程(含 Starlette background tasks跑完才返回故 client 上下文退出后断言稳定不 flaky必 override `get_session_factory``e2e_sm`(真 sessionmaker同测试 engine/loop否则默认 `get_sessionmaker()` 另建 engine 绑别的 loop。
- [2026-06-18] @frontend **审稿 seed 扩成三态**`useReviewStream.seed` 入参由 `ReviewConflict[]``ReviewSeed{conflicts,foreshadow,pace}`(进页同时种伏笔建议/节奏留痕,免重审即可看)。`ReviewStreamState``foreshadow:ForeshadowSuggestion[]`(累加) / `pace:PaceReport|null`(**替换非累加**,对齐后端 collect「pace 整 dict 入列」)。
- [2026-06-18] @frontend **伏笔 transition 乐观更新只改 status、回滚存快照**`useForeshadow.transition` 本地先改 statusPATCH 失败 `setItems(snapshot)` 回滚 + 读 `error.details.reason`(duplicate/invalid_transition/empty_update) 映射文案。register **不乐观**(要服务端 code 唯一校验):成功才追加返回行,重复 code→422 友好提示不改 items。大纲页**无 GET 端点**:进页 `initialChapters=[]`,靠 `POST .../outline` 生成填充无凭据→503 引导去设置)。
- [2026-06-18] @backend **端点级测「无凭据→LLM_UNAVAILABLE」不要靠真实 `get_*_gateway` dep 解析**——`FakeSession` 不支持 `.execute``SqlCredentialStore` 会炸成 500。做法override 网关 dep 注一个 `raise AppError(LLM_UNAVAILABLE)` 的 async 函数(等价无凭据行为),与 review/accept/outline 测试一致。
- [2026-06-18] @backend **BackgroundTask 必须自建独立 session**FastAPI BackgroundTasks 在 response 发回、请求 session 关闭后才跑——复用 `Depends(get_session)` 的 session 已关闭会炸。验收后到期扫描经可注入 `SessionFactory`(=`get_sessionmaker()`)`run_overdue_scan``async with factory()` 开新 session 自己 commit再加 `repo_factory` 缝便于单测注 fake、纯函数 await 不起后台线程。**accept 端点新增 `get_session_factory` 依赖→所有 accept 测试 client 需 override 它**(否则 dep 解析会建真 engine
- [2026-06-18] @backend **伏笔登记重复 code / 非法转移 → `VALIDATION`(422) 非 409**:现有唯一 409 码 `CONFLICT_UNRESOLVED` 专指未决冲突禁验收,不复用;`details.reason``duplicate`/`invalid_transition`/`empty_update` 供前端区分。register 捕 SQLAlchemy `IntegrityError`→rollback→`AppError(VALIDATION)`transition 捕 `InvalidTransition`/`LookupError`(404)。
- [2026-06-18] @llm **三审列类型不齐**`chapter_reviews.foreshadow_sug` 是 JSONB **list**`pace` 是 JSONB **dict**——collect 把 `ForeshadowReview{planted,resolved}`(dict) **扁平成单 list**、每条加 `kind:"planted"|"resolved"``PaceReview` 整体入 dict 列。贴合既有 DB 列类型、不改 @db。新审种加入须同步collect 列映射 + `sse._section_result_events` 分支 + `normalize_review` 键名。`sse.py` import `.collect` 的 spec.name 常量做 section 分流无循环collect 不 import sse
- [2026-06-18] @llm **三审并行图测试按 `req.output_schema` 路由 parsed**`SchemaRoutingRunGateway`)——单 `FakeRunGateway` 对所有审返同一 parsed 会让三审拿错 schema。验失败隔离某 schema 不登记→网关抛 KeyError→`run_review` 隔离为 `incomplete`,无需改 gateway。
- [2026-06-18] @backend **伏笔 `record_progress` append JSONB 必须新建 list 重赋值**`row.progress = [*old, entry]`),不可原地 `.append()`——SQLAlchemy 默认不侦测可变 JSONB 原地突变原地改不脏标记→flush 丢失。`scan_overdue` 仅在有变更时 flush空扫描零写。状态机`transition` 同态(current==to)幂等放行、CLOSED 为终态(离开 CLOSED 全非法抛 `InvalidTransition`)`is_overdue` 严格大于(current==expected_close_to 仍在窗口内不逾期)、无 expected_close_to 永不逾期。
- [2026-06-18] @llm **orchestrator 内每模块各自声明 `GatewayRun` Protocol**`review_node.py``outline_node.py` 各一份,按模块最小依赖)——**不跨模块复用、不在 orchestrator `__init__` 重复导出**`__init__` 只导出 review_node 那个,避免 re-export 名冲突outline 的为模块内部用。
- [2026-06-18] @qa **M2 E2E 多档位假适配器**`config.tier_defaults` writer/analyst/light 默认同 provider(deepseek)→单个假适配器(`provider="deepseek"`)即覆盖三档位;据 `req.output_schema is ContinuityReview`(续审)/否则 digest facts schema 分支返回 `parsed`;三档位用不同 `input_tokens` 区分以断言各自落 `usage_ledger`。三端点记账闭环 = review 端点流末 commit + accept 验收事务末 commit 都把网关 ledger flush 真正提交M1 ledger bug 在 M2 无复发)。
- [2026-06-18] @qa **E2E 验证「digest 从终稿非草稿」(#4) 手法**final_text 注入草稿没有的标记串,假 light 适配器把它放进 digest facts 的 `summary`,断言 `chapter_digests.facts["summary"]==标记``标记 not in draft_text`。accept 409 gate 经 ASGITransport 正常返回(`AppError` 不上抛),断言 `resp.json()["error"]["details"]["missing_conflict_indices"]``ErrorCode` StrEnum → `"CONFLICT_UNRESOLVED"`)。
- [2026-06-18] @frontend **审稿历史 `conflicts` 在 OpenAPI 被标松散 `{[k]:unknown}[]`**(后端用 dict/JSONB 列)→ 前端 `lib/review/history.ts` 安全收窄成 `ReviewConflict{type,where,refs,suggestion}`,缺字段给默认、**保序**(顺序=冲突 gate 的 `conflict_index` 身份,不可重排,否则裁决错位)。
- [2026-06-18] @frontend **审稿页重审完成重置裁决草稿用 streaming→done 边沿判定**`wasReviewingRef`):不能用 conflicts 长度变化判(同数不同组会漏重置),也不能在 seedphase=idle进页种历史留痕时误触发。
- [2026-06-18] @backend **冲突 gate 判据accept**:冲突身份 = 「最近一条 `chapter_reviews.conflicts` 列表的下标」;裁决 `ConflictDecision{conflict_index, verdict:accept|ignore|manual, note?}`gate 通过 = 裁决的 `conflict_index` 集合**覆盖** `range(len(conflicts))`缺判→409 `CONFLICT_UNRESOLVED` + `details.missing_conflict_indices`/`conflict_count`;无审稿留痕或零冲突→直通验收。
- [2026-06-18] @backend/@qa **ASGITransport 默认 `raise_app_exceptions=True`**accept 事务回滚测试里repo 抛的非-`AppError`(如 `RuntimeError`)会**上抛到 client 调用方**而非返回 500——测试用 `pytest.raises(RuntimeError)` 包住请求调用、再断言 `session.commits == 0`证明未部分提交。DB 级原子回滚由 T2.7 真 pg 覆盖。
- [2026-06-18] @llm **langgraph 并行节点同写一个 state key 必须配 reducer**:并行四审同 superstep 各写 `{spec.name: ...}``reviews`,无 reducer → LangGraph 抛 `InvalidUpdateError`。用 `Annotated[dict, merge_reviews]`(浅合并、返回新 dict、不可变`ChapterState` 因逐节点填充改 `total=False`
- [2026-06-18] @llm **langgraph `add_node` 重载拒收显式 `Callable` 类型别名**`make_review_node` 返回的具名 `BoundReviewNode` 别名会让 mypy 报 incompatible arg-type。图工厂里改用 **inline `async def` 闭包**spec 经默认参 `_spec=spec` 绑定避开循环晚绑定mypy 才推出精确函数类型匹配重载。`make_review_node` 仍作公共缝(供 T2.5 跑单审)单独保留+单测。
- [2026-06-18] @llm **审稿失败隔离两层**:审级失败在 `run_review` 内标 `incomplete`§5.2 任一审不阻塞其余);归一级意外(畸形 entry`normalize_review` 发一条 `error` 事件后收尾(同 `normalize_deltas` 纪律)。
- [2026-06-18] @llm **instructor 1.15.3 结构化输出接线**:用 `AsyncInstructor.create_with_completion(messages=..., response_model=..., model=..., max_tokens=...)` 同时拿 `(parsed, raw_completion)`——`raw.usage` 用于记账,避免结构化路径丢 usageusage 提取统一走 `_usage_from(raw_usage)`(文本/结构化/流共用。adapter 经可注入 `StructuredClient` Protocol 注 fake测试不联网。结构化路径 `ProviderResult.text = parsed.model_dump_json()`(日志/留痕),消费走 `parsed`。**带 `output_schema``gateway.run(req).parsed` 必非 None**。
- [2026-06-18] @backend **frozen View 的运行时不可变断言因类型分两种**dataclass frozen(`ChapterView`)→赋值抛 `FrozenInstanceError`**mypy 会静态报错**(测试里故意赋值需 `# type: ignore[misc]`Pydantic frozen(`DigestView`/`ReviewView`)→抛 `ValidationError`**mypy 不静态校验****勿加** `# type: ignore`,否则被判 unused-ignoreruff/mypy 红)。
- [2026-06-18] @backend **M2 验收-side repos 只 flush 不 commit**`promote_to_accepted`/`digest.append`/`review.record`/`set_decisions` 均只 `flush()`,交由 T2.4 验收事务单次 `commit()`(对齐「写库副作用在事务/编排层」);唯独 draft `save_draft` 仍自 commitM1 自动保存语义)。`(project_id,chapter_no,version)` 唯一性是 **DB 级**(T0.2 models 定义),纯 fake 单测不断言它(不引 pg 依赖以免 pytest 门禁需起库)→ 由 T2.7 E2E 真实 DB 覆盖。
- [2026-06-18] @backend/@llm **网关 ledger 只 flush、调用方必须 commit**(不变量「写库副作用在编排层不在网关」的代价):`SqlAlchemyLedgerSink.record``flush()``commit()``get_session` 退出时不提交 → 隐式回滚。draft SSE 端点曾因此把 `usage_ledger` 行丢掉T1.9 暴露)。修复:端点在 SSE 流**耗尽后** `await session.commit()`FastAPI 缓存 `Depends(get_session)`,网关 ledger 与端点同一 session。**M2 起凡用网关产 usage 的路径(四审/accept都要确保所在事务最终提交**,否则记账静默丢失。
- [2026-06-18] @frontend **Next 里消费 SSE 用 `fetch`+`ReadableStream` reader不用 `EventSource`**EventSource 不能 POST、不能干净 abort。"停"=`AbortController.abort()`,吞掉 `AbortError`、已收 token 留在 state 并被自动保存。流前错误无凭据→503 `LLM_UNAVAILABLE`**JSON 信封非帧**)经 `!res.ok` 检出、从 `{error:{code,message}}` 解析。
- [2026-06-18] @frontend apps/web 测试环境坑:`server-only` 包未装→别 importServer Component 仅靠约定vitest 是 **2.x**(无 `toHaveBeenCalledExactlyOnceWith`,用 `toHaveBeenCalledTimes`+`toHaveBeenCalledWith`**未装 jsdom/testing-library**→单测走 node env 测纯逻辑SSE reducer/帧缓冲、debounce、向导状态机组件 DOM 渲染留给 T1.9 Playwright。
- [2026-06-18] @backend **stub user 未 seed → FK 风险**`projects.owner_id` / `usage_ledger.owner_id` / `provider_credentials.owner_id` 全 FK→`users.id`,但仓库无 seeded stub user。约定 `STUB_OWNER_ID = uuid.UUID(int=1)`(对齐网关 `Scope.user_id` stub。任何写这些表的路径写章记账、立项、存凭据跑前必须存在该 user 行——T1.4 幂等 seedstartup/lifespanauth 落地后替换为真实 principal。
- [2026-06-18] @backend 含 nullable 列的唯一约束别用 PG `ON CONFLICT``provider_credentials(owner_id,project_id,provider)` / `tier_routing(project_id,tier)``project_id` 可空PG 默认 NULLS DISTINCT → 全局行(`project_id=NULL`)的 `ON CONFLICT` 不去重、会插重复。T1.7 用显式 read-modify-write(`project_id IS NULL`)。若 @db 后续给约束加 `NULLS NOT DISTINCT` 可改回原生 upsert。
- [2026-06-18] @llm langgraph **1.2.5** 实装pyproject 写 `>=0.2.40` 但装了 1.x用 1.x API`from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver``AsyncPostgresSaver.from_conn_string(...)`(async ctx) → `await saver.setup()`(只在 migrations/CI。mypy strict 下:`add_node` 不收 `functools.partial`(用 `async def` 闭包绑定依赖);`StateGraph[...]`/`CompiledStateGraph[...]`/`BaseCheckpointSaver[Any]` 需写全类型参;测试 state dict 标注 `: ChapterState``ainvoke` config 标注 `: RunnableConfig`
- [2026-06-18] @orchestrator **跨包测试同名碰撞**:每包 `tests/`__init__(避免与顶层 `tests` 包撞),但多包并存时 ① pytest 全跑:同名顶层模块 `fakes.py` 撞("import file mismatch")→ 测试替身用**全局唯一**名(`fakes_gateway`/`fakes_providers`/`fakes_orchestrator`);② 聚合 `mypy packages apps`:多个 rootless `conftest.py` 撞成同名模块 → root pyproject `[tool.mypy] exclude=["(^|/)conftest\\.py$"]`conftest 仅 fixtures按包仍受检。test_*.py 保持全局唯一名。
- [2026-06-18] @backend Fernet 凭据 key 取 `settings.credential_enc_key`(env `CREDENTIAL_ENC_KEY`)`get_settings` 是 lru_cache测试改 env 后须 `get_settings.cache_clear()`(在 client fixture 里。list/GET 不解密(只回脱敏占位),仅 probe 按需解密——缩小明文暴露面。
- [2026-06-18] @orchestrator 后台 fork 子代理会因 "stream idle timeout" 早夭T1.1 网关 fork 跑了 8.5 分钟、0 产出、0 文件)。坑:别盲等/盲重启重型 fork。做法fork 完成后先核对**产物文件 + 自跑门禁**再认其结果;早夭则编排者内联实现该任务(已有完整上下文),不再二次 fork 同一关键路径任务。
- [2026-06-18] @backend 记忆选择走**确定性子串名匹配**(在 flatten+sorted 的 beats/facts 文本上 + 显式 entities 列表),非 pg_trgm/向量§3.4 的 pg_trgm/作者 pin 兜底属后续。`selection``render_cards` 均按 `(kind, name)` 排序→输出与 repo 返回顺序无关CJK 名按 **codepoint** 排(乙 U+4E59 < U+7532测试断言按 codepoint 而非甲乙丙语义`latest_state` 只进 `volatile`卡片`stable_core` 故意不含它以保缓存前缀字节稳定
- [2026-06-18] @qa/@llm 包内单测放 `packages/<pkg>/tests/`** __init__.py**避免与顶层 `tests` 包同名冲突测试替身放独立 `fakes.py` 用绝对导入 `from fakes import ...`不能 `from .conftest import`相对导入在无包目录下报 no known parent package`conftest.py` 只放 fixtures门禁按包跑`uv run {ruff check|mypy|pytest} packages/<pkg>`
- [2026-06-18] @frontend pnpm 11 配置已迁出 package.json/.npmrc 只读 `apps/web/pnpm-workspace.yaml``pnpm run <script>` 前会跑 verifyDepsBeforeRun 触发隐式 install `ERR_PNPM_IGNORED_BUILDS`(esbuild/sharp/unrs-resolver 被默认拦截)直接整条命令失败做法 `pnpm-workspace.yaml` `onlyBuiltDependencies:` 白名单 + `verifyDepsBeforeRun: false`
- [2026-06-18] @frontend gen:api 离线管线`scripts/gen-api.mjs` `execFileSync(uv, [...])` 取后端 OpenAPI(不靠运行中的服务) + 直接调 `node_modules/.bin/openapi-typescript`(别用 `pnpm exec`会再触发 deps 检查)。改后端 schema 后跑 `pnpm gen:api` 重生成 `lib/api/schema.d.ts`
- [2026-06-18] @backend async engine 跨事件循环坑`get_sessionmaker` `lru_cache`engine 绑定首个 looppytest-asyncio 每测试新 loop 复用会报 `Connection._cancel never awaited`/连接失败测试里每个 DB 测试 `get_sessionmaker.cache_clear()` 在当前 loop 重建并 `dispose()`
- [2026-06-18] @db mypy strict + 跨包 editable 安装模型里 `from ww_db.base import Base` 会被判成 Any( "cannot subclass Any")需在根 `pyproject.toml` `[tool.mypy] mypy_path=[...各包源码目录...]` + `namespace_packages=true`否则 editable 包解析不到源码
- [2026-06-17] @docs 命名契约后端 Python/Pydantic 一律 **snake_case**字段/schema/JSON前端经 OpenAPI 生成类型消费——别手写 camelCase 共享类型评审里曾因 TS 旧栈遗留 camelCase Pydantic 契约对不上)。
- [2026-06-17] @docs 数据写入只走**验收事务**四审 agent 只读不写任何 AI 产出入库必经 `accept`HITL gate)。别在 agent 节点里直接写库